Plan step 36. The rewrite drew the popover's 18rem cap as 288px, and its stylesheet opened with "two lists" before naming three; the headers now say what the rules do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
234 lines
13 KiB
PHP
234 lines
13 KiB
PHP
{{-- An M3 Expressive menu: a list of actions that opens from a trigger.
|
|
|
|
<x-menu label="Share actions">
|
|
<x-slot:trigger>
|
|
<x-button icon="more_vert" tooltip="More" />
|
|
</x-slot:trigger>
|
|
|
|
<x-menu-item label="Copy link" icon="content_copy" wire:click="copy" />
|
|
<x-menu-separator />
|
|
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
|
|
</x-menu>
|
|
|
|
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
|
|
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
|
|
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
|
|
when there is no room — to the other side, the other end, or both, so a menu on a FAB in a
|
|
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
|
|
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
|
|
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
|
|
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
|
|
|
|
The anchor name is rendered on the wrapper around the trigger slot, the only element the
|
|
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
|
|
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
|
|
box where the page put it, and the menu opened there.
|
|
|
|
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
|
|
a Livewire morph matches it by before the id, so a render of the component around an open
|
|
menu patches it in place — still open, focus and listeners kept — instead of swapping in a
|
|
closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes
|
|
through an attribute bag: Livewire compiles a `wire:key` written in a template into the key
|
|
of the loop iteration around it, which would give every child component after the menu the
|
|
same key.
|
|
|
|
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
|
|
top layer where nothing can reach it (ACT-04): 288px at most, and less on a short window. The
|
|
arrow keys, Home, End and typeahead bring the item they move to into view, and a disabled
|
|
item is among them: M3 keeps one reachable so a person can find out that it exists.
|
|
|
|
It opens by growing out of the corner nearest its trigger and fades as it goes, which is the
|
|
transition M3 asks to tie a menu to what opened it (ACT-26).
|
|
|
|
`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.
|
|
|
|
`sheet-at-compact` is M3's adaptive menu: "at compact breakpoints, consider swapping a menu
|
|
for a bottom sheet (more room for items/longer labels); at medium/expanded breakpoints, menus
|
|
work well in context" (docs/reference/m3/components-actions-communication-containment.md
|
|
§ Menus → Behaviour; foundations.md § Layout gives compact the bottom sheet for supplemental
|
|
selection and actions, and medium up the menu). Below `medium` (600px) the trigger opens the
|
|
items in a modal `<x-bottom-sheet>` — M3's "alternative to inline menus … on mobile", closed
|
|
by choosing an item, the scrim, a swipe down or Escape (§ Bottom sheets → Behaviour) — and
|
|
from `medium` it opens the popover. The trigger says which: `aria-haspopup="dialog"` and the
|
|
sheet's id while the window is compact, `menu` and the popover's otherwise, `aria-expanded`
|
|
in both. The slot is written once and drawn twice, in the popover and in the sheet, so a
|
|
Livewire render patches both copies and a chosen item shows chosen in either. The sheet is
|
|
teleported to the end of <body>: a menu in a sticky app bar or a toolbar would otherwise cover
|
|
the window only inside that bar's stacking context, under the navigation bar.
|
|
|
|
Inside the sheet the items keep their roles and the keyboard above — arrows, Home, End, a
|
|
letter; Escape closes the sheet and Tab does too, and either returns focus to the trigger —
|
|
and choosing one closes the sheet as it closes the popover. A submenu opens in place under
|
|
its item instead of beside it (M3 calls submenus "best suited to large screens"), and a
|
|
`filter` field stands at the top of the sheet. A window resized across 600px closes whichever
|
|
is open rather than leaving the other shown. The sheet is M3's own container
|
|
(surface-container-low) for a `vibrant` menu too, and is as tall as its items up to the
|
|
half screen M3 caps a modal sheet's first position at, scrolling inside past that. The
|
|
sheet's id is fixed and the lists are keyed, so a render keeps an open sheet open with its
|
|
focus where it was (below). Since the items exist twice, an `id` of the caller's or a nested
|
|
Livewire component among them would exist twice too: keep those out of a `sheet-at-compact`
|
|
menu.
|
|
|
|
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).
|
|
|
|
Drawn by resources/css/components/menu.css, which also draws the dropdown a form's own lists
|
|
wear — `<x-select>`'s exposed picker and `<x-choices searchable>`'s listbox — so a menu and a
|
|
form's own dropdowns read as one family (`[data-md-field-menu]`, `[data-md-field-option]`). --}}
|
|
|
|
@props([
|
|
'label' => null,
|
|
'position' => 'bottom-start',
|
|
'vibrant' => false,
|
|
'filter' => false,
|
|
'sheetAtCompact' => 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');
|
|
|
|
// A dialog needs a name; the menu's own label is the one it has.
|
|
$sheet = (bool) $sheetAtCompact;
|
|
$sheetLabel = filled($label) ? $label : __('Menu');
|
|
@endphp
|
|
|
|
<div x-data="materialMenu" data-md-menu @if ($sheet) data-md-sheet-at-compact @endif {{ $attributes }}>
|
|
<span x-ref="trigger" data-md-menu-trigger style="anchor-name: {{ $anchor }}"
|
|
x-on:click="toggle('first')"
|
|
x-on:keydown.down.prevent="open('first')"
|
|
x-on:keydown.up.prevent="open('last')"
|
|
>{{ $trigger }}</span>
|
|
|
|
<div
|
|
x-ref="menu"
|
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
|
|
id="material-menu-{{ $key }}"
|
|
popover="auto"
|
|
@unless ($filtering) role="menu" @endunless
|
|
data-md-menu-popover
|
|
data-md-position="{{ $position }}"
|
|
@if ($vibrant) data-md-vibrant @endif
|
|
@if ($label && ! $filtering) aria-label="{{ $label }}" @endif
|
|
tabindex="-1"
|
|
style="position-anchor: {{ $anchor }}"
|
|
x-on:keydown="navigate($event)"
|
|
x-on:click="activate($event)"
|
|
>
|
|
@if ($filtering)
|
|
<div data-md-menu-filter>
|
|
<x-livewire-material::icon name="search" size="20" />
|
|
|
|
<input
|
|
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))) }} data-md-menu-list>
|
|
{{ $slot }}
|
|
|
|
<p data-md-menu-empty hidden>{{ __('Nothing matches') }}</p>
|
|
</div>
|
|
@else
|
|
{{ $slot }}
|
|
@endif
|
|
</div>
|
|
|
|
@if ($sheet)
|
|
{{-- The compact presentation. The host is the element menu.js knows the sheet by, and its
|
|
listeners run in this menu's scope; the one inside it only lends the bottom sheet the
|
|
`open` it reads, which in this scope is the name of a method. Everything inside the
|
|
sheet is in the sheet's scope, where `close` and `activate` are the sheet's, so
|
|
nothing in there calls the menu by name.
|
|
|
|
A Livewire morph matches an element by its `wire:key`, or else by its id, and swaps in
|
|
a fresh copy where the two differ — which would take the focus out of an open sheet.
|
|
The lists carry keys of their own, as the popover does. The bottom sheet cannot carry
|
|
one: a `wire:key` given to a Blade component becomes the key of the loop around it,
|
|
for every Livewire component after it. So the sheet is rendered with one fixed id,
|
|
the same in every render, and menu.js gives each sheet a unique id of its own while
|
|
keeping the fixed one as its `wire:key`. --}}
|
|
<template x-teleport="body">
|
|
<div
|
|
x-ref="sheetHost"
|
|
data-md-menu-sheet-host
|
|
x-on:keydown.capture="sheetKeydown($event)"
|
|
x-on:click="activate($event)"
|
|
x-on:input="refine()"
|
|
>
|
|
<div x-data="materialMenuSheet">
|
|
<x-livewire-material::bottom-sheet id="material-menu-sheet" aria-label="{{ $sheetLabel }}">
|
|
@if ($filtering)
|
|
<div {{ new \Illuminate\View\ComponentAttributeBag([
|
|
'wire:key' => 'material-menu-sheet-menu',
|
|
'id' => "material-menu-{$key}-sheet-menu",
|
|
]) }} data-md-menu-sheet>
|
|
<div data-md-menu-filter>
|
|
<x-livewire-material::icon name="search" size="20" />
|
|
|
|
<input
|
|
type="text"
|
|
role="combobox"
|
|
autocomplete="off"
|
|
aria-autocomplete="list"
|
|
aria-expanded="true"
|
|
aria-controls="material-menu-{{ $key }}-sheet-menu-list"
|
|
aria-label="{{ $filterLabel }}"
|
|
placeholder="{{ $filterLabel }}"
|
|
/>
|
|
</div>
|
|
|
|
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
|
|
'wire:key' => 'material-menu-sheet-list',
|
|
'id' => "material-menu-{$key}-sheet-menu-list",
|
|
'role' => 'menu',
|
|
'aria-label' => $label,
|
|
], fn ($value): bool => filled($value))) }} data-md-menu-list>
|
|
{{ $slot }}
|
|
|
|
<p data-md-menu-empty hidden>{{ __('Nothing matches') }}</p>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
|
|
'wire:key' => 'material-menu-sheet-menu',
|
|
'id' => "material-menu-{$key}-sheet-menu",
|
|
'role' => 'menu',
|
|
'aria-label' => $label,
|
|
], fn ($value): bool => filled($value))) }} data-md-menu-sheet>
|
|
{{ $slot }}
|
|
</div>
|
|
@endif
|
|
</x-livewire-material::bottom-sheet>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
@endif
|
|
</div>
|