Open a menu in a bottom sheet on a compact window
Plan step 22, item 8 (actions.md § Missing, "Adaptive menu → bottom sheet
at compact"): M3 says "at compact breakpoints, consider swapping a menu for
a bottom sheet", and nothing connected <x-menu> to <x-bottom-sheet>.
<x-menu sheet-at-compact> draws its slot twice, in the popover and in a
modal <x-bottom-sheet> teleported to <body>, and below `medium`
(upTo('medium')) the trigger opens the sheet. The trigger says
aria-haspopup="dialog" there and "menu" from medium, aria-expanded in
both. Items keep their roles and the APG keyboard in the sheet; choosing
one, Escape or Tab close it and return focus to the trigger; a submenu
opens in place under its item; `filter` works in both; a resize across
600px closes whichever is open. The sheet has a fixed id that menu.js
makes unique and keeps as its wire:key, and the lists are keyed, so a
Livewire render patches an open sheet instead of swapping it.
bottom-sheet.blade.php is used as is. The filter now finds its field and
empty row per list instead of through x-refs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
12cdeaaf67
commit
deb1442dfb
@@ -184,7 +184,6 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
|
||||
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\"")
|
||||
@@ -192,6 +191,7 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
|
||||
->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\"")
|
||||
@@ -203,7 +203,7 @@ it('names a bare filter field, and leaves a plain menu alone', function () {
|
||||
->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"')
|
||||
->not->toContain('role="combobox"')
|
||||
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
|
||||
});
|
||||
|
||||
@@ -215,3 +215,105 @@ it('tells a vibrant menu apart, so the submenus inside it take the same containe
|
||||
->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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user