Menus, submenus, tooltips, rich tooltips and the FAB menu held their exit with `transition-behavior: allow-discrete` on `display` and `overlay`. Firefox transitions neither (MDN browser-compat-data, `display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so every one of them vanished on its first frame there. A script cannot hold a popover open instead: `beforetoggle` is not cancellable on the way out, and the browser's own light dismiss (Escape, a press outside) never asks. resources/js/popover-exit.js: a popover marked `data-md-popover-exit` closes for real at once — focus, aria-expanded and toggle stay the browser's — and a copy taken in `beforetoggle`, while it is still drawn, stands in for the exit. The copy is decoration: a manual popover in the top layer (closing no other popover), inert, aria-hidden, without ids or nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to the popover's box with its resolved colours. It is shown with its transitions off, so `@starting-style` does not replay the entry, then marked `data-md-popover-closing`, which each stylesheet turns into its closed values (`:popover-open:not([data-md-popover-closing])`, and the FAB menu's items' sink), so it moves on the component's own tokens. It is removed once the longest of them has run, and opening the popover again takes it away. Under reduced motion every duration is zero and no copy is made. `display`, `overlay` and `allow-discrete` leave the transitions, so Chrome and Safari take the same path. Browser tests in Chrome, Firefox and Safari slow the motion tokens so a round trip still finds the exit on screen: a menu after Escape and after a press outside (the real menu closed and focus back on its button, the copy inert, fading, with no Alpine state, and gone after), a reopen part-way through, reduced motion, a submenu while its menu stays open, a tooltip, the FAB menu's items part-way down their sink, and a persistent rich tooltip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
13 KiB
PHP
235 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-popover-exit
|
|
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>
|