Filter a menu from a field at the top of it

Plan step 22, actions.md § Missing (Menus as a filtering surface): M3's
menus page describes a menu that embeds a text field and filters its
options as you type, and nothing in the library did that.

`<x-menu filter>` renders the field, sticky above the list, and hides the
rows the query leaves out — client-side over the items already rendered,
so nothing is fetched and a `wire:click` stays where it was. The field
keeps the focus and the arrow keys move a highlight it names through
`aria-activedescendant`, the APG combobox keyboard `<x-choices
searchable>` already uses; Enter chooses the highlighted row, and a query
that leaves nothing says so. The list becomes a `role="menu"` inside the
popover, because a text field is not something a menu may contain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:38:02 +02:00
co-authored by Claude Opus 5
parent 3f651c5c08
commit 88c46001fb
6 changed files with 268 additions and 5 deletions
+31
View File
@@ -176,6 +176,37 @@ it('marks a submenu item with a chevron instead of a tick', function () {
->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')