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

<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:
Andreas Reinhold / reini
2026-09-13 06:09:28 +02:00
co-authored by Claude Opus 5
parent b48e879254
commit cd64f4f371
46 changed files with 2968 additions and 36 deletions
@@ -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');
});
+158
View File
@@ -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);
});
+46
View File
@@ -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');
});
+50
View File
@@ -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');
});
+71
View File
@@ -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');
});
+29
View File
@@ -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('&lt;b&gt;bold&lt;/b&gt;', false);
});