<x-icon size> writes --md-icon-size (24px by default) and takes the optical-size-20 cut for 20px or less, as M3 asks; mirror-rtl flips a directional symbol. <x-shape size> writes --md-shape-size. Both draw from plain-CSS stylesheets in material.components, gathered in components.css until all.css replaces it; a caller's size class still wins while the other components are rewritten. Plan steps 35 and 36, the prop every component rewrite depends on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
320 lines
16 KiB
PHP
320 lines
16 KiB
PHP
<?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('scrolls a menu too long for the window instead of running off it', function () {
|
|
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
|
->toContain('max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto')
|
|
->not->toContain('overflow-visible');
|
|
});
|
|
|
|
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, ticked at its end', function () {
|
|
$check = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/check.svg'));
|
|
$check = substr($check, (int) strpos($check, '><') + 1);
|
|
|
|
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')
|
|
->toContain($check)
|
|
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
|
|
->toContain('aria-checked="false"')
|
|
->not->toContain($check)
|
|
->and((string) $this->blade('<x-menu-item label="Newest" :selected="true" icon-right="chevron_right" />'))
|
|
->not->toContain($check);
|
|
});
|
|
|
|
it('gives an item M3\'s 48px row and 16px sides, and the separator its 8px', function () {
|
|
expect((string) $this->blade('<x-menu-item label="Copy" />'))->toContain('min-h-12')->toContain('px-4')
|
|
->and((string) $this->blade('<x-menu-separator />'))->toContain('mx-4 my-2')
|
|
->and((string) $this->blade('<x-menu-group label="Sort by" />'))->toContain('px-4 pt-2 pb-1');
|
|
});
|
|
|
|
it('marks the page an item leads to, and carries a badge', function () {
|
|
$html = (string) $this->blade('<x-menu-item label="Support" icon="support" link="/admin/support" current badge="3" />');
|
|
|
|
expect($html)
|
|
->toContain('role="menuitem"')
|
|
->toContain('aria-current="page"')
|
|
->not->toContain('aria-checked')
|
|
->toContain('bg-secondary-container text-on-secondary-container')
|
|
->toContain('>3<')
|
|
->and((string) $this->blade('<x-menu-item label="Users" link="/admin/users" />'))
|
|
->not->toContain('aria-current')
|
|
->not->toContain('secondary-container');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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] : '';
|
|
|
|
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
|
|
->toBe('size-5 [:where(&)]:text-on-surface-variant text-sport-run')
|
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" :selected="true" />')))
|
|
->toBe('size-5 [:where(&)]:text-on-tertiary-container text-sport-run')
|
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
|
|
->toBe('size-5 text-sport-run text-on-surface/38!')
|
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
|
|
->toBe('size-5 text-on-surface-variant')
|
|
->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('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('<p data-menu-empty hidden')
|
|
->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('role="combobox"')
|
|
->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');
|
|
});
|
|
|
|
it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as the popover', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-menu label="Photo actions" sheet-at-compact>
|
|
<x-slot:trigger><button>More</button></x-slot:trigger>
|
|
<x-menu-item label="Set as wallpaper" icon="wallpaper" wire:click="wallpaper" />
|
|
<x-menu-item label="Delete" icon="delete" />
|
|
</x-menu>
|
|
BLADE);
|
|
|
|
preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key);
|
|
|
|
expect($key)->not->toBeEmpty();
|
|
|
|
[$popover, $sheet] = explode('<template x-teleport="body">', $html);
|
|
|
|
// The popover is the menu it always was, and the root tells menu.js to look for the sheet.
|
|
expect($popover)
|
|
->toMatch('/<div x-data="materialMenu"\s+data-sheet-at-compact/')
|
|
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"[^>]*aria-label="Photo actions"/')
|
|
->toContain('wire:click="wallpaper"')
|
|
// The sheet goes to the end of <body>, out of any bar's stacking context, and its host
|
|
// hears the keyboard, the clicks and the filter in the menu's scope.
|
|
->and($sheet)
|
|
->toContain('x-ref="sheetHost"')
|
|
->toContain('x-on:keydown.capture="sheetKeydown($event)"')
|
|
->toContain('x-on:click="activate($event)"')
|
|
->toContain('<div x-data="materialMenuSheet">')
|
|
// A modal bottom sheet, named by the menu's label. Its id is the same in every render, so
|
|
// a Livewire morph patches it rather than swapping it (menu.js makes it unique and keeps
|
|
// this one as the key); the lists carry keys of their own, as the popover does.
|
|
->toContain('...materialBottomSheet(false, JSON.parse(')
|
|
->toContain('id="material-menu-sheet"')
|
|
->not->toContain("material-menu-{$key[1]}-sheet\"")
|
|
// A key given to the Blade component would become the key of the loop around the menu.
|
|
->not->toMatch('/<section\s[^>]*wire:key/')
|
|
->toContain('role="dialog"')
|
|
->toContain('aria-modal="true"')
|
|
->toContain('x-trap.inert.noscroll="open"')
|
|
->toContain('aria-label="Photo actions"')
|
|
->toContain('rounded-t-corner-xl bg-surface-container-low')
|
|
// The same slot, drawn again as a menu: the items keep their roles and their actions.
|
|
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-menu-sheet/")
|
|
->and(substr_count($html, 'role="menuitem"'))->toBe(4)
|
|
->and(substr_count($html, 'wire:click="wallpaper"'))->toBe(2);
|
|
});
|
|
|
|
it('leaves the sheet out of a menu that does not ask for it', function () {
|
|
$html = (string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Copy" /></x-menu>');
|
|
|
|
expect($html)
|
|
->not->toContain('data-sheet-at-compact')
|
|
->not->toContain('x-teleport')
|
|
->not->toContain('materialBottomSheet')
|
|
->not->toContain('data-menu-sheet')
|
|
->and(substr_count($html, 'role="menuitem"'))->toBe(1);
|
|
});
|
|
|
|
it('names a sheet with no menu label, and draws a filtering menu\'s field in the sheet too', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-menu filter="Find a person" sheet-at-compact vibrant>
|
|
<x-slot:trigger><button>x</button></x-slot:trigger>
|
|
<x-menu-item label="Ada" />
|
|
</x-menu>
|
|
BLADE);
|
|
|
|
preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key);
|
|
|
|
[, $sheet] = explode('<template x-teleport="body">', $html);
|
|
|
|
expect($sheet)
|
|
->toContain('aria-label="Menu"')
|
|
->toContain('x-on:input="refine()"')
|
|
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-menu-sheet")
|
|
->toContain('data-menu-filter')
|
|
->toContain('role="combobox"')
|
|
->toContain("aria-controls=\"material-menu-{$key[1]}-sheet-menu-list\"")
|
|
->toContain('placeholder="Find a person"')
|
|
->toContain("<div wire:key=\"material-menu-sheet-list\" id=\"material-menu-{$key[1]}-sheet-menu-list\" role=\"menu\"")
|
|
->toContain('<p data-menu-empty hidden')
|
|
// The sheet is M3's own container whatever the menu's colour.
|
|
->not->toContain('data-vibrant')
|
|
->not->toContain('bg-tertiary-container')
|
|
->and(substr_count($html, 'role="combobox"'))->toBe(2);
|
|
});
|
|
|
|
it('marks a submenu\'s chevron, which turns to point down at the list a sheet opens in place', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-menu label="Share" sheet-at-compact>
|
|
<x-slot:trigger><button>x</button></x-slot:trigger>
|
|
<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>
|
|
</x-menu>
|
|
BLADE);
|
|
|
|
[$popover, $sheet] = explode('<template x-teleport="body">', $html);
|
|
|
|
// One markup for both: menu.js tells the sheet's copy apart by the list around it.
|
|
expect($popover)->toContain('data-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('data-submenu')
|
|
->and($sheet)->toContain('data-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('aria-haspopup="menu"')->toContain('data-submenu')
|
|
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
|
|
->not->toContain('data-submenu-chevron');
|
|
});
|