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
@@ -261,7 +261,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`submenu` makes an item a menu of its own — the slot holds the nested `<x-menu-item>`s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:
@@ -274,6 +274,8 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
The item says so with `aria-haspopup="menu"`, `aria-expanded` and a chevron; Right, Enter or Space open it on its first item, Left or Escape close it and come back, and on a fine pointer resting on the item opens it. Choosing anything inside closes the whole menu. Arrows stay inside the list they are in. M3 calls submenus a large-screen pattern — on a phone give the menu `sheet-at-compact`, or keep the list flat.
`filter` puts a text field at the top of the list (M3's menu as a filtering surface) and narrows the items to those whose label holds what has been typed — in the browser, over the items already rendered, so nothing is fetched and every `wire:click` stays where it was. `filter="Find a person"` names the field; bare `filter` calls it "Filter". The field keeps the focus while the arrow keys, Home and End move a highlighted row and Enter chooses it (`aria-activedescendant`, as `<x-choices searchable>`); a query that leaves nothing says "Nothing matches". Reach for it once a menu is long enough to hunt through; for a value bound to a property, `<x-choices searchable>` is the field, not the menu.
Clusters: `<x-menu-separator />` draws M3's line, `<x-menu-group gap>` M3 Expressive's grouped layout — no line, the cluster set 8px off its neighbours with its items 2px apart and its ends rounded. Reach for the divider first (M3: "on web, use dividers to separate items", and it is the only one a scrolling menu may use); reach for the gap for one or two clusters in a menu short enough not to scroll, and never vary the gap. `<x-menu-group>` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was.
### `<x-button-group>`
+47
View File
@@ -43,6 +43,53 @@
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
}
/*
* `<x-menu filter>`: M3's menu as a filtering surface. The field stays put while the list scrolls
* under it and takes the menu's own container, so a vibrant menu's field is vibrant too. It is a
* plain <input> with no field chrome — M3's menus embed a text field, not a text field component.
*/
[data-menu-filter] {
position: sticky;
inset-block-start: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-inline: 1rem;
border-block-end: 1px solid var(--md-sys-color-outline-variant);
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
[data-menu-filter] input {
flex: 1;
min-inline-size: 0;
border: 0;
background: transparent;
padding: 0;
color: inherit;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
outline: none;
}
[data-menu-filter] input::placeholder {
color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 70%, transparent);
}
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
`display: flex` utility would otherwise beat the user agent's `[hidden] { display: none }`. */
[data-menu] [hidden] {
display: none;
}
/* `data-active` is the row the arrow keys are on while the focus stays in the field — the state
layer the roving focus would have drawn, without taking the focus off the field. A chosen or
current row keeps its own colour. */
[data-menu] [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
background: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 8%, transparent);
}
.field-menu {
max-block-size: 18rem;
overflow-y: auto;
+122
View File
@@ -20,6 +20,12 @@
* that exist only for a wrapper (moving the anchor name, and finding the button inside the trigger
* slot) are overridden away. `items()` stops at the popover it belongs to, so the arrow keys in a
* menu never walk into an open submenu's rows, nor a submenu's back out into its parent's.
*
* `<x-menu filter>` adds a text field at the top of the same list. The field keeps the focus while
* the arrow keys move a highlight — APG's combobox, which is what a text field inside a popup
* asks for — so `refine()`, `visible()`, `mark()` and `search()` work on `aria-activedescendant`
* and the `hidden` attribute rather than on the roving focus the rest of this file uses. They do
* nothing at all in a menu with no field: `$refs.filter` is what turns them on.
*/
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
@@ -95,6 +101,12 @@ const menu = () => ({
}
this.focusWasInside = false
// A filtered menu opens on the whole list again: the query belonged to that visit.
if (this.$refs.filter) {
this.$refs.filter.value = ''
this.refine()
}
})
// A press outside closes the menu without pulling focus back to the trigger.
@@ -174,6 +186,16 @@ const menu = () => ({
this.control()?.setAttribute('aria-expanded', 'true')
// A filtering menu hands the focus to its field, not to a row: the field is where the
// typing goes, and `aria-activedescendant` says which row the arrows are on meanwhile.
if (this.$refs.filter) {
this.$refs.filter.focus()
this.$refs.filter.select()
this.mark(this.visible()[0] ?? null)
return
}
// `false` opens without taking the focus: a submenu the pointer rested on belongs to the
// pointer, and taking the focus out from under the keyboard would be the wrong answer.
if (focus !== false) {
@@ -263,6 +285,106 @@ const menu = () => ({
}
},
/**
* `<x-menu filter>`: M3's menu as a filtering surface. The rows are already rendered, so this
* only hides the ones the query leaves out — with the `hidden` attribute, which menu.css turns
* into `display: none` over the row's own `display: flex`. A divider means nothing between two
* filtered clusters, and a group whose every row has gone is a heading over nothing.
*/
refine() {
const query = this.$refs.filter.value.trim().toLowerCase()
for (const item of this.items()) {
item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query)
}
for (const rule of this.$refs.menu.querySelectorAll('[role="separator"]')) {
rule.hidden = query !== ''
}
for (const group of this.$refs.menu.querySelectorAll('[role="group"]')) {
group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden)
}
const left = this.visible()
this.$refs.empty.hidden = left.length > 0
this.mark(left[0] ?? null)
},
/** The rows a query has left, in the order they are read. */
visible() {
return this.items().filter((item) => !item.hidden && item.closest('[hidden]') === null)
},
/**
* Moves the highlight the arrow keys carry while the focus stays in the field. The row needs
* an id for `aria-activedescendant` to name it, and gets one if the caller wrote none.
*/
mark(item) {
for (const each of this.items()) {
if (each !== item) {
each.removeAttribute('data-active')
}
}
if (!item) {
this.$refs.filter.removeAttribute('aria-activedescendant')
return
}
item.id ||= `${this.$refs.menu.id}-item-${this.items().indexOf(item)}`
item.setAttribute('data-active', '')
this.$refs.filter.setAttribute('aria-activedescendant', item.id)
if (this.isOpen()) {
item.scrollIntoView({ block: 'nearest' })
}
},
/** The APG combobox keyboard, on the field: the list moves under it and Enter takes a row. */
search(event) {
const left = this.visible()
const current = left.findIndex((item) => item.hasAttribute('data-active'))
const move = (index) => {
event.preventDefault()
if (left.length > 0) {
this.mark(left[(index + left.length) % left.length])
}
}
switch (event.key) {
case 'ArrowDown':
return move(current + 1)
case 'ArrowUp':
return move(current < 0 ? left.length - 1 : current - 1)
case 'Home':
return move(0)
case 'End':
return move(left.length - 1)
case 'Enter':
event.preventDefault()
if (left[current] && left[current].getAttribute('aria-disabled') !== 'true') {
left[current].click()
}
return
case 'Escape':
// The browser's own light dismiss closes the popover; this only says where the
// focus goes after it.
this.returnFocus = true
return
case 'Tab':
this.returnFocus = false
this.close()
}
},
activate(event) {
const item = event.target.closest(ITEMS)
+48 -3
View File
@@ -40,6 +40,16 @@
It opens by growing out of the corner nearest its trigger and fades as it goes
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
`filter` is M3's menu as a filtering surface ("autocomplete"): a text field at the top of the
list, which stays put while the list scrolls under it, narrowing the items to those whose
label holds what has been typed in the browser, over the items already rendered, so nothing
is fetched and a `wire:click` stays where it was. `filter="Find a person"` names the field;
bare `filter` calls it "Filter". The field, not the list, holds the focus, so a person can
type and steer at once: the arrow keys, Home and End move a highlighted row and say which one
through `aria-activedescendant`, and Enter chooses it the APG combobox keyboard, the same
one `<x-choices searchable>` uses. The list around it stays a `role="menu"` of its own inside
the popover, because a text field is not a thing a menu may contain.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0). --}}
@@ -48,12 +58,16 @@
'label' => null,
'position' => 'bottom-start',
'vibrant' => false,
'filter' => false,
])
@php
$position = in_array($position, ['bottom-start', 'bottom-end', 'top-start', 'top-end'], true) ? $position : 'bottom-start';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-menu-{$key}";
$filtering = $filter !== false && $filter !== null && $filter !== '';
$filterLabel = is_string($filter) && filled($filter) ? $filter : __('Filter');
@endphp
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
@@ -68,16 +82,17 @@
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@unless ($filtering) role="menu" @endunless
data-menu
@if ($vibrant) data-vibrant @endif
@if ($label) aria-label="{{ $label }}" @endif
@if ($label && ! $filtering) aria-label="{{ $label }}" @endif
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'p-1' => ! $filtering,
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'popover-transition',
'bg-surface-container-low text-on-surface' => ! $vibrant,
@@ -88,6 +103,36 @@
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
])
>
@if ($filtering)
<div data-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
<input
x-ref="filter"
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="material-menu-{{ $key }}-list"
aria-label="{{ $filterLabel }}"
placeholder="{{ $filterLabel }}"
x-on:input="refine()"
x-on:keydown.stop="search($event)"
/>
</div>
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
'id' => "material-menu-{$key}-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="p-1">
{{ $slot }}
<p x-ref="empty" hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
</div>
@else
{{ $slot }}
@endif
</div>
</div>
@@ -91,6 +91,22 @@
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'A menu that filters as you type' => <<<'BLADE'
<x-menu label="Assign to" filter="Find a person">
<x-slot:trigger>
<x-button label="Assign to" icon="person_add" variant="tonal" />
</x-slot:trigger>
<x-menu-item label="Ada Lovelace" icon="person" description="Engineering" />
<x-menu-item label="Grace Hopper" icon="person" description="Engineering" />
<x-menu-item label="Katherine Johnson" icon="person" description="Research" />
<x-menu-item label="Mary Jackson" icon="person" description="Research" />
<x-menu-item label="Radia Perlman" icon="person" description="Networks" />
<x-menu-item label="Barbara Liskov" icon="person" description="Networks" />
<x-menu-separator />
<x-menu-item label="Nobody, for now" icon="person_off" />
</x-menu>
BLADE,
'Icons in their own colour' => <<<'BLADE'
<x-menu label="New plan">
<x-slot:trigger>
+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')