Add buttons, menus and the rest of M3 Expressive's actions
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
<x-button> (label buttons, icon buttons and toggles in five sizes, with filled, tonal, outlined, elevated and text variants in any colour role), <x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>, <x-group> as a connected button group, <x-split-button>, <x-fab>, <x-fab-menu> and <x-loading>. Sizes, colours and shapes come from androidx Compose Material 3's tokens; the loading indicator ports its Morph into SVG + SMIL. Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS anchor positioning. Browser tests run in Chromium, Firefox and WebKit. The showcase fetches the icon names on demand: inlined, they tripped Pest's test server into HTTP 431s under Firefox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
b48e879254
commit
cd64f4f371
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('groups buttons with the spacing of their size', function () {
|
||||
expect((string) $this->blade('<x-button-group label="Views" size="md"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('role="group"')
|
||||
->toContain('aria-label="Views"')
|
||||
->toContain('data-button-group="standard"')
|
||||
->toContain('data-size="md"')
|
||||
->toContain('gap-2')
|
||||
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('gap-3');
|
||||
});
|
||||
|
||||
it('connects buttons 2px apart', function () {
|
||||
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('gap-0.5')
|
||||
->not->toContain('flex-wrap');
|
||||
});
|
||||
|
||||
it('marks icon buttons, which keep their width when a group is pressed', function () {
|
||||
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-icon-button')
|
||||
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-icon-button');
|
||||
});
|
||||
|
||||
it('draws a choice as connected radios', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-group label="Theme" name="theme" x-model="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Theme</legend>')
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('type="radio"')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
->toContain('has-checked:bg-secondary has-checked:text-on-secondary')
|
||||
->toContain('disabled')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
it('draws several choices as checkboxes', function () {
|
||||
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('name="days[]"')
|
||||
->toContain('has-checked:bg-inverse-surface');
|
||||
});
|
||||
|
||||
it('binds to a Livewire property and shows its validation message', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public string $theme = 'dark';
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('wire:model="theme"')
|
||||
->assertSee('How it looks')
|
||||
->set('theme', 'light')
|
||||
->assertSet('theme', 'light')
|
||||
->set('theme', 'dark')
|
||||
->call('save')
|
||||
->assertSee('Pick light.')
|
||||
->assertDontSee('How it looks');
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* The classes on the rendered button element.
|
||||
*/
|
||||
function buttonClasses(string $blade): string
|
||||
{
|
||||
preg_match('/<(?:button|a)\b[^>]*\bclass="([^"]*)"/', (string) test()->blade($blade), $matches);
|
||||
|
||||
return $matches[1] ?? '';
|
||||
}
|
||||
|
||||
it('is a text button in the primary colour by default', function () {
|
||||
expect(buttonClasses('<x-button label="Cancel" />'))
|
||||
->toContain('text-primary')
|
||||
->not->toContain('bg-primary');
|
||||
});
|
||||
|
||||
it('draws each M3 variant', function (string $variant, string $classes) {
|
||||
expect(buttonClasses("<x-button label=\"Go\" variant=\"{$variant}\" />"))->toContain($classes);
|
||||
})->with([
|
||||
'filled' => ['filled', 'bg-primary text-on-primary'],
|
||||
'tonal' => ['tonal', 'bg-secondary-container text-on-secondary-container'],
|
||||
'outlined' => ['outlined', 'border-outline-variant text-on-surface-variant'],
|
||||
'elevated' => ['elevated', 'bg-surface-container-low text-primary'],
|
||||
'text' => ['text', 'text-primary'],
|
||||
]);
|
||||
|
||||
it('draws a variant in another colour role', function () {
|
||||
expect(buttonClasses('<x-button label="Go" variant="filled" color="tertiary" />'))->toContain('bg-tertiary text-on-tertiary')
|
||||
->and(buttonClasses('<x-button label="Go" variant="tonal" color="error" />'))->toContain('bg-error-container text-on-error-container')
|
||||
->and(buttonClasses('<x-button label="Go" variant="outlined" tone="success" />'))->toContain('text-success');
|
||||
});
|
||||
|
||||
it('keeps the maryUI and ReStride shorthands', function () {
|
||||
expect(buttonClasses('<x-button label="Save" primary />'))->toContain('bg-primary text-on-primary')
|
||||
->and(buttonClasses('<x-button label="Delete" danger />'))->toContain('bg-error text-on-error')
|
||||
->and(buttonClasses('<x-button label="Take down" caution />'))->toContain('bg-warning text-on-warning');
|
||||
});
|
||||
|
||||
it('falls back to the defaults for values it does not know', function () {
|
||||
expect(buttonClasses('<x-button label="Go" variant="glass" color="purple" size="huge" />'))
|
||||
->toContain('text-primary')
|
||||
->toContain('h-10 gap-2 px-4 type-label-lg');
|
||||
});
|
||||
|
||||
it('sizes a label button on Expressive\'s scale', function (string $size, string $classes, string $icon) {
|
||||
$html = (string) $this->blade("<x-button label=\"Upload\" icon=\"upload\" size=\"{$size}\" />");
|
||||
|
||||
expect($html)->toContain($classes)->toContain($icon);
|
||||
})->with([
|
||||
'xs' => ['xs', 'h-8 gap-2 px-3 type-label-lg', 'size-5'],
|
||||
'sm' => ['sm', 'h-10 gap-2 px-4 type-label-lg', 'size-5'],
|
||||
'md' => ['md', 'h-14 gap-2 px-6 type-title-md', 'size-6'],
|
||||
'lg' => ['lg', 'h-24 gap-3 px-12 type-headline-sm', 'size-8'],
|
||||
'xl' => ['xl', 'h-34 gap-4 px-16 type-headline-lg', 'size-10'],
|
||||
]);
|
||||
|
||||
it('rounds by default, squares on request, and squares off further while pressed', function () {
|
||||
expect(buttonClasses('<x-button label="Go" size="md" />'))->toContain('rounded-corner-full')->toContain('active:rounded-corner-md')
|
||||
->and(buttonClasses('<x-button label="Go" size="md" shape="square" />'))->toContain('rounded-corner-lg')
|
||||
->and(buttonClasses('<x-button label="Go" size="xl" shape="square" />'))->toContain('rounded-corner-xl')->toContain('active:rounded-corner-lg');
|
||||
});
|
||||
|
||||
it('reaches a 48px touch target below the medium size', function () {
|
||||
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('after:min-h-12')
|
||||
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('after:min-h-12');
|
||||
});
|
||||
|
||||
it('is an icon button named by its tooltip when it has no label', function () {
|
||||
$html = (string) $this->blade('<x-button icon="close" tooltip="Close menu" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('aria-label="Close menu"')
|
||||
->toContain('size-10')
|
||||
->toContain('text-on-surface-variant')
|
||||
->toContain('popover="manual"')
|
||||
->toContain('aria-hidden="true"');
|
||||
});
|
||||
|
||||
it('sizes an icon button by width', function () {
|
||||
expect(buttonClasses('<x-button icon="share" label="" width="wide" aria-label="Share" />'))->toContain('h-10 w-13')
|
||||
->and(buttonClasses('<x-button icon="share" size="xl" width="narrow" aria-label="Share" />'))->toContain('h-34 w-26');
|
||||
});
|
||||
|
||||
it('toggles with aria-pressed and M3\'s selected colours and shape', function () {
|
||||
expect((string) $this->blade('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('aria-pressed="true"')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('bg-primary')->toContain('rounded-corner-md')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="false" />'))->toContain('bg-surface-container text-on-surface-variant')->toContain('rounded-corner-full')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="tonal" :selected="true" />'))->toContain('bg-secondary text-on-secondary')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="outlined" :selected="true" />'))->toContain('bg-inverse-surface text-inverse-on-surface');
|
||||
});
|
||||
|
||||
it('squares a selected round icon button and rounds a selected square one', function () {
|
||||
expect(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" :selected="true" />'))->toContain('rounded-corner-md')
|
||||
->and(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" shape="square" :selected="true" />'))->toContain('rounded-corner-full')
|
||||
->and((string) $this->blade('<x-button icon="favorite" aria-label="Like" :selected="true" />'))->toContain('text-primary');
|
||||
});
|
||||
|
||||
it('navigates inside the app, and leaves it for an external link', function () {
|
||||
$this->blade('<x-button label="Shares" link="/admin/shares" />')
|
||||
->assertSee('<a ', false)
|
||||
->assertSee('href="/admin/shares"', false)
|
||||
->assertSee('wire:navigate', false)
|
||||
->assertDontSee('type="button"', false);
|
||||
|
||||
$this->blade('<x-button label="M3" link="https://m3.material.io" external />')
|
||||
->assertSee('target="_blank"', false)
|
||||
->assertSee('rel="noopener"', false)
|
||||
->assertDontSee('wire:navigate', false);
|
||||
});
|
||||
|
||||
it('disables a button, and a link as far as a link can be', function () {
|
||||
$this->blade('<x-button label="Save" variant="filled" disabled />')
|
||||
->assertSee('disabled="disabled"', false)
|
||||
->assertSee('disabled:bg-on-surface/10', false);
|
||||
|
||||
$this->blade('<x-button label="Shares" link="/admin/shares" disabled />')
|
||||
->assertSee('aria-disabled="true"', false)
|
||||
->assertSee('tabindex="-1"', false);
|
||||
});
|
||||
|
||||
it('shows the loading indicator while its own action runs', function () {
|
||||
$this->blade('<x-button label="Save" wire:click="save" spinner />')
|
||||
->assertSee('wire:loading.attr="disabled"', false)
|
||||
->assertSee('wire:target="save"', false);
|
||||
|
||||
$this->blade('<x-button label="Save" wire:click="save" spinner="upload" />')
|
||||
->assertSee('wire:target="upload"', false);
|
||||
});
|
||||
|
||||
it('hides a responsive label below lg', function () {
|
||||
$this->blade('<x-button label="New share" icon="add" responsive />')
|
||||
->assertSee('<span class="max-lg:hidden">New share</span>', false);
|
||||
});
|
||||
|
||||
it('anchors its tooltip to itself', function () {
|
||||
$html = (string) $this->blade('<x-button label="Save" tooltip-bottom="Saves the draft" />');
|
||||
|
||||
preg_match('/anchor-name: (--material-button-[a-z0-9]+)/', $html, $anchor);
|
||||
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('[position-area:bottom]')
|
||||
->not->toContain('aria-label="Saves the draft"');
|
||||
});
|
||||
|
||||
it('is a FAB below sm and a filled button above it, in one element', function () {
|
||||
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
|
||||
|
||||
expect(substr_count($html, '<button'))->toBe(1)
|
||||
->and($html)->toContain('max-sm:fixed')->toContain('max-sm:bg-primary-container')->toContain('bg-primary text-on-primary');
|
||||
});
|
||||
|
||||
it('submits a form when asked', function () {
|
||||
$this->blade('<x-button label="Save" type="submit" />')
|
||||
->assertSee('type="submit"', false);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
it('draws a FAB in its container colour, named by its tooltip', function () {
|
||||
expect((string) $this->blade('<x-fab icon="add" tooltip="New share" />'))
|
||||
->toContain('size-14 rounded-corner-lg')
|
||||
->toContain('bg-primary-container text-on-primary-container')
|
||||
->toContain('shadow-elevation-3')
|
||||
->toContain('aria-label="New share"')
|
||||
->toContain('popover="manual"');
|
||||
});
|
||||
|
||||
it('sizes a FAB at 56, 80 and 96px', function (string $size, string $classes, string $icon) {
|
||||
expect((string) $this->blade("<x-fab icon=\"add\" size=\"{$size}\" aria-label=\"Add\" />"))->toContain($classes)->toContain($icon);
|
||||
})->with([
|
||||
'sm' => ['sm', 'size-14 rounded-corner-lg', 'size-6'],
|
||||
'md' => ['md', 'size-20 rounded-corner-lg-increased', 'size-7'],
|
||||
'lg' => ['lg', 'size-24 rounded-corner-xl', 'size-8'],
|
||||
]);
|
||||
|
||||
it('extends with a label', function () {
|
||||
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
|
||||
->toContain('h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg')
|
||||
->toContain('bg-tertiary text-on-tertiary')
|
||||
->toContain('<span>Upload</span>')
|
||||
->not->toContain('aria-label');
|
||||
});
|
||||
|
||||
it('opens a FAB menu of end-aligned actions above it', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-fab-menu label="New" color="secondary">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" color="secondary" wire:click="upload" />
|
||||
</x-fab-menu>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('x-data="materialMenu"')
|
||||
->toContain('aria-label="New"')
|
||||
->toContain('bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary')
|
||||
->toContain('aria-expanded:rounded-corner-full')
|
||||
->toContain('role="menu"')
|
||||
->toContain('[position-area:top_span-left]')
|
||||
->toContain('role="menuitem"')
|
||||
->toContain('wire:click="upload"')
|
||||
->toContain('h-14')
|
||||
->toContain('Upload files');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
it('is a named progressbar in the primary colour, 48px by default', function () {
|
||||
$html = (string) $this->blade('<x-loading />');
|
||||
|
||||
expect($html)
|
||||
->toContain('role="progressbar"')
|
||||
->toContain('aria-label="Loading"')
|
||||
->toContain('size-12')
|
||||
->toContain('text-primary')
|
||||
->toContain('<animateTransform')
|
||||
->toContain('class="size-full motion-reduce:hidden"')
|
||||
->toContain('class="hidden size-full motion-reduce:block"');
|
||||
});
|
||||
|
||||
it('sits on a primary-container circle when contained', function () {
|
||||
expect((string) $this->blade('<x-loading contained class="size-8" label="Uploading" />'))
|
||||
->toContain('rounded-corner-full bg-primary-container text-on-primary-container')
|
||||
->toContain('size-8')
|
||||
->not->toContain('size-12')
|
||||
->toContain('aria-label="Uploading"');
|
||||
});
|
||||
|
||||
it('takes the caller\'s colour', function () {
|
||||
expect((string) $this->blade('<x-loading class="text-tertiary" />'))
|
||||
->toContain('text-tertiary')
|
||||
->not->toContain('text-primary');
|
||||
});
|
||||
|
||||
it('says nothing where something else already does', function () {
|
||||
expect((string) $this->blade('<x-loading :label="false" />'))
|
||||
->toContain('aria-hidden="true"')
|
||||
->not->toContain('role="progressbar"');
|
||||
|
||||
expect((string) $this->blade('<x-button label="Save" wire:click="save" spinner />'))
|
||||
->toContain('<animateTransform')
|
||||
->not->toContain('role="progressbar"');
|
||||
});
|
||||
|
||||
it('animates without script and rests the same shape', function () {
|
||||
$animated = file_get_contents(__DIR__.'/../../../resources/svg/loading-indicator/indeterminate.svg');
|
||||
$static = file_get_contents(__DIR__.'/../../../resources/svg/loading-indicator/static.svg');
|
||||
|
||||
expect($animated)->toStartWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor">')
|
||||
->toContain('repeatCount="indefinite"')
|
||||
->not->toContain('<script')
|
||||
->not->toContain('__ID__')
|
||||
->and($static)->toStartWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor">')
|
||||
->not->toContain('<animate');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
it('opens a popover menu from its trigger', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-menu label="Share actions">
|
||||
<x-slot:trigger><button>More</button></x-slot:trigger>
|
||||
<x-menu-item label="Copy link" icon="content_copy" />
|
||||
</x-menu>
|
||||
BLADE);
|
||||
|
||||
preg_match('/anchor-name: (--material-menu-([a-z0-9]+))/', $html, $anchor);
|
||||
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('x-data="materialMenu"')
|
||||
->toContain('<button>More</button>')
|
||||
->toContain('popover="auto"')
|
||||
->toContain('role="menu"')
|
||||
->toContain('aria-label="Share actions"')
|
||||
->toContain("id=\"material-menu-{$anchor[2]}\"")
|
||||
->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('bg-surface-container-low')
|
||||
->toContain('[position-area:bottom_span-right]');
|
||||
});
|
||||
|
||||
it('opens at the position asked for, in the vibrant colours on request', function () {
|
||||
$html = (string) $this->blade('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');
|
||||
|
||||
expect($html)->toContain('[position-area:top_span-left]')->toContain('bg-tertiary-container text-on-tertiary-container');
|
||||
});
|
||||
|
||||
it('draws an item as a menuitem button', function () {
|
||||
$html = (string) $this->blade('<x-menu-item label="Download" icon="download" shortcut="⌘D" description="As a ZIP" wire:click="download" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('role="menuitem"')
|
||||
->toContain('tabindex="-1"')
|
||||
->toContain('type="button"')
|
||||
->toContain('wire:click="download"')
|
||||
->toContain('Download')
|
||||
->toContain('⌘D')
|
||||
->toContain('As a ZIP')
|
||||
->toContain('size-5 text-on-surface-variant');
|
||||
});
|
||||
|
||||
it('makes a selectable item a menuitemcheckbox', function () {
|
||||
expect((string) $this->blade('<x-menu-item label="Newest" :selected="true" keep-open />'))
|
||||
->toContain('role="menuitemcheckbox"')
|
||||
->toContain('aria-checked="true"')
|
||||
->toContain('bg-tertiary-container')
|
||||
->toContain('data-keep-open')
|
||||
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
|
||||
->toContain('aria-checked="false"');
|
||||
});
|
||||
|
||||
it('links an item, and keeps a disabled one out of reach', function () {
|
||||
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
||||
->assertSee('href="/settings"', false)
|
||||
->assertSee('wire:navigate', false);
|
||||
|
||||
$this->blade('<x-menu-item label="Reset" disabled />')
|
||||
->assertSee('aria-disabled="true"', false)
|
||||
->assertSee('pointer-events-none', false);
|
||||
});
|
||||
|
||||
it('separates and labels groups', function () {
|
||||
$this->blade('<x-menu-separator />')->assertSee('role="separator"', false);
|
||||
|
||||
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
||||
->assertSee('role="group" aria-label="Sort by"', false);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
it('puts the action on the leading button and the menu on the trailing one', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options" class="mt-4">
|
||||
<x-menu-item label="As a ZIP" />
|
||||
</x-split-button>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-button-group="split"')
|
||||
->toContain('mt-4')
|
||||
->toContain('data-split="leading"')
|
||||
->toContain('wire:click="downloadZip"')
|
||||
->toContain('data-split="trailing"')
|
||||
->toContain('aria-label="Download options"')
|
||||
->toContain('role="menu"')
|
||||
->toContain('As a ZIP')
|
||||
->and(substr_count($html, 'wire:click="downloadZip"'))->toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the corners to the group', function () {
|
||||
$html = (string) $this->blade('<x-split-button label="Save"><x-menu-item label="Draft" /></x-split-button>');
|
||||
|
||||
preg_match_all('/<button\b[^>]*data-split="[^"]*"[^>]*>/', $html, $halves);
|
||||
|
||||
expect($halves[0])->toHaveCount(2);
|
||||
|
||||
foreach ($halves[0] as $half) {
|
||||
expect($half)->not->toContain('rounded-corner-full')->not->toContain('active:rounded-corner');
|
||||
}
|
||||
});
|
||||
|
||||
it('is filled unless another container variant is asked for', function () {
|
||||
expect((string) $this->blade('<x-split-button label="Save" variant="text"><x-menu-item label="Draft" /></x-split-button>'))->toContain('bg-primary text-on-primary')
|
||||
->and((string) $this->blade('<x-split-button label="Save" variant="tonal"><x-menu-item label="Draft" /></x-split-button>'))->toContain('bg-secondary-container');
|
||||
});
|
||||
|
||||
it('gives the two smallest sizes their narrower inner padding and 48px trailing button', function () {
|
||||
expect((string) $this->blade('<x-split-button label="Save" size="xs"><x-menu-item label="Draft" /></x-split-button>'))
|
||||
->toContain('pe-2.5')
|
||||
->toContain('w-12');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('wraps a trigger and anchors the bubble to it', function () {
|
||||
$html = (string) $this->blade('<x-tooltip text="Copy link"><button>Copy</button></x-tooltip>');
|
||||
|
||||
preg_match('/anchor-name: (--material-tooltip-[a-z0-9]+)/', $html, $anchor);
|
||||
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('<button>Copy</button>')
|
||||
->toContain('popover="manual"')
|
||||
->toContain('x-data="materialTooltip"')
|
||||
->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('bg-inverse-surface')
|
||||
->toContain('[position-area:top]');
|
||||
});
|
||||
|
||||
it('places the bubble on the side asked for, flipping when there is no room', function (string $side, string $classes) {
|
||||
expect((string) $this->blade("<x-tooltip text=\"Hi\" side=\"{$side}\"><span>x</span></x-tooltip>"))->toContain($classes);
|
||||
})->with([
|
||||
'bottom' => ['bottom', '[position-area:bottom] [position-try-fallbacks:flip-block]'],
|
||||
'left' => ['left', '[position-area:left] [position-try-fallbacks:flip-inline]'],
|
||||
'right' => ['right', '[position-area:right] [position-try-fallbacks:flip-inline]'],
|
||||
]);
|
||||
|
||||
it('escapes its text', function () {
|
||||
$this->blade('<x-tooltip text="<b>bold</b>"><span>x</span></x-tooltip>')
|
||||
->assertSee('<b>bold</b>', false);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||
|
||||
/**
|
||||
* Reboot the application with the showcase switched on or off. The routes are mounted
|
||||
* while the provider boots, so the flag has to be in the environment before that.
|
||||
@@ -42,3 +44,10 @@ it('answers 404 for a symbol or style that does not exist', function () {
|
||||
$this->get('/material/symbols/outlined/not_a_symbol_at_all.svg')->assertNotFound();
|
||||
$this->get('/material/symbols/sharp/favorite.svg')->assertNotFound();
|
||||
});
|
||||
|
||||
it('lists the symbol names for the icon search', function () {
|
||||
$this->getJson('/material/symbols.json')
|
||||
->assertOk()
|
||||
->assertJsonFragment(['calendar_month'])
|
||||
->assertJsonCount(count(SvgFile::symbolNames()));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user