Merge branch 'worktree-agent-a0d44b9ad5ca7814d'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 10:38:56 +02:00
22 changed files with 1332 additions and 239 deletions
@@ -20,6 +20,69 @@ it('connects buttons 2px apart', function () {
->toContain('gap-0.5');
});
it('squares a group, and a choice drawn as one', function () {
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
// every button in the group, so it need not be written on each one.
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group connected shape="square"><x-button label="Day" /></x-button-group>'))
->toContain('data-button-group="connected"')
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="round"')
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="round"');
});
it('takes over aria-pressed for a group that carries a selection', function () {
$html = (string) $this->blade('<x-button-group connected selection="single" required label="View"><x-button label="Day" value="day" :selected="true" /></x-button-group>');
expect($html)
->toContain('data-selection="single"')
->toContain('data-selection-required')
->toContain('multiple: false')
->toContain('required: true')
->toContain('value: null')
->toContain('x-on:click="press($event)"')
// No model: the group takes the state from the buttons' own aria-pressed and goes on.
->toContain('x-modelable="value"')
->and((string) $this->blade('<x-button-group connected selection="multi"><x-button label="Bold" value="bold" :selected="false" /></x-button-group>'))
->toContain('multiple: true')
->toContain('required: false')
->toContain('value: []')
->and((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
->not->toContain('data-selection')
->not->toContain('press($event)');
});
it('entangles a selection group with the property it binds, and keeps wire:model off the div', function () {
$component = new class extends Component
{
public string $view = 'week';
public function render(): string
{
return <<<'BLADE'
<div>
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" :selected="$view === 'day'" />
<x-button label="Week" value="week" :selected="$view === 'week'" />
</x-button-group>
</div>
BLADE;
}
};
$html = Livewire::test($component)->assertSet('view', 'week')->html();
expect($html)
->toContain('data-selection="single"')
->toMatch('/value: window\.Livewire\.find\(/')
->and(substr_count($html, 'wire:model.live="view"'))->toBe(0);
});
it('never wraps a group onto a second line', function () {
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->not->toContain('flex-wrap');
+17
View File
@@ -41,6 +41,23 @@ it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (str
'lg' => ['lg', 'h-24 min-w-24 gap-5'],
]);
it('collapses an extended FAB to a FAB on scroll when asked', function () {
$html = (string) $this->blade('<x-fab icon="add" label="New share" size="lg" collapse-on-scroll />');
expect($html)
->toContain('x-data="materialFab"')
->toContain('x-bind:data-collapsed="collapsed ? &#039;&#039; : null"')
->toContain('data-fab-collapsible')
->toContain('data-fab-size="lg"')
// The label is clipped, never removed, so the collapsed FAB keeps its name.
->toContain('<span data-fab-label><span>New share</span></span>');
// A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing.
expect((string) $this->blade('<x-fab icon="add" aria-label="New" collapse-on-scroll />'))->not->toContain('materialFab')
->and((string) $this->blade('<x-fab label="New share" collapse-on-scroll />'))->not->toContain('materialFab')
->and((string) $this->blade('<x-fab icon="add" label="New share" />'))->not->toContain('materialFab')->toContain('<span>New share</span>');
});
it('marks its root for the places that draw a nested FAB their own way', function () {
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
});
+98
View File
@@ -103,6 +103,25 @@ it('separates and labels groups', function () {
->assertSee('role="group" aria-label="Sort by"', false);
});
it('separates clusters by a gap instead of a divider when asked', function () {
$html = (string) $this->blade('<x-menu-group gap><x-menu-item label="Cut" /></x-menu-group>');
// M3 Expressive's grouped layout: 2px between the items of a cluster, 8px between clusters —
// the same 8px the divider keeps above and below its line.
expect($html)
->toContain('role="group"')
->toContain('not-first:mt-2')
->toContain('space-y-0.5')
->not->toContain('aria-label')
->not->toContain('py-1 first:pt-0')
->and((string) $this->blade('<x-menu-group label="Then" gap><x-menu-item label="Cut" /></x-menu-group>'))
->toContain('aria-label="Then"')
->toContain('space-y-0.5')
->and((string) $this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>'))
->toContain('py-1 first:pt-0 last:pb-0')
->not->toContain('space-y-0.5');
});
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
@@ -117,3 +136,82 @@ it('adds icon-class to the leading icon, over its own colour but not over disabl
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
->not->toContain('text-sport-run');
});
it('opens a submenu beside the item that holds it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu-item label="Send to" icon="send" submenu>
<x-menu-item label="A person" icon="person" />
</x-menu-item>
BLADE);
preg_match('/anchor-name: (--material-submenu-([a-z0-9]+))/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialSubmenu"')
->toContain('x-on:keydown.right.prevent.stop="open(\'first\')"')
->toContain('x-on:pointerenter="hover($event)"')
->toContain('aria-haspopup="menu"')
->toContain('aria-expanded="false"')
->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"")
->toContain("id=\"material-submenu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}")
->toContain('data-submenu')
->toContain('aria-label="Send to"')
->toContain('[position-area:inline-end_span-block-end]')
->toContain('[position-try-fallbacks:flip-inline]')
// Opening a submenu chooses nothing, so the menu around it stays where it was.
->toContain('data-keep-open')
->toContain('A person');
});
it('marks a submenu item with a chevron instead of a tick', function () {
$chevron = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/chevron_right.svg'));
$chevron = substr($chevron, (int) strpos($chevron, '><') + 1);
expect((string) $this->blade('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->toContain($chevron)
->toContain('rtl:-scale-x-100')
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->not->toContain($chevron);
});
it('embeds a text field that filters the list, as a combobox over the menu', function () {
$html = (string) $this->blade('<x-menu label="Assign to" filter="Find a person"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Ada" /></x-menu>');
preg_match('/id="(material-menu-[a-z0-9]+)"/', $html, $id);
expect($id)->not->toBeEmpty()
->and($html)
->toContain('data-menu-filter')
->toContain('x-ref="filter"')
->toContain('role="combobox"')
->toContain('aria-autocomplete="list"')
->toContain("aria-controls=\"{$id[1]}-list\"")
->toContain('aria-label="Find a person"')
->toContain('placeholder="Find a person"')
->toContain('x-on:input="refine()"')
->toContain('x-on:keydown.stop="search($event)"')
->toContain('Nothing matches')
// A text field is not something a `role="menu"` may hold, so the list moves inside it.
->toContain("<div id=\"{$id[1]}-list\" role=\"menu\" aria-label=\"Assign to\"")
->toMatch('/<div\s[^>]*popover="auto"(?![^>]*role="menu")/');
});
it('names a bare filter field, and leaves a plain menu alone', function () {
expect((string) $this->blade('<x-menu filter><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('aria-label="Filter"')
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->not->toContain('data-menu-filter')
->not->toContain('x-ref="filter"')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
});
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->toContain('data-vibrant')
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->not->toContain('data-vibrant');
});
+12
View File
@@ -21,6 +21,18 @@ it('draws no state icon, and reaches 48px from its 40px controls', function () {
->toContain('state-layer focus-ring touch-target inline-flex size-10');
});
it('grows to M3\'s two-line height, and wraps the action below on a compact window', function () {
$html = (string) $this->blade('<x-toast />');
// SnackbarTokens: 48dp for one line, 68dp for two — a description is that second line.
expect($html)
->toContain('x-bind:class="current.description ? \'min-h-17\' : \'min-h-12\'"')
->toContain('flex-wrap')
// M3's "two lines with longer action": the action leaves the text's line below `medium`.
->toContain('x-bind:class="current.description && current.action ? \'max-medium:basis-full\' : \'\'"')
->toContain('touch-target ms-auto h-10');
});
it('can sit at the start', function () {
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
});