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:
co-authored by
Claude Opus 5
parent
3f651c5c08
commit
88c46001fb
@@ -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',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user