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>
154 lines
7.4 KiB
PHP
154 lines
7.4 KiB
PHP
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
|
|
|
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
|
|
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
|
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
|
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
|
Expressive's selected shape and tertiary-container, and a `check` at its end unless it has an
|
|
`icon-right` of its own — M3 asks for a cue beyond the colour and the shape. `current` is for
|
|
a menu of places rather than choices — a section picker — and marks the page you are on:
|
|
`aria-current="page"`, the selected shape in secondary-container, the colour M3 gives the
|
|
navigation indicator. `badge` draws `<x-badge>` at the end of the row: `true` for a dot, or a
|
|
count. `disabled` keeps it in the list, reachable by the keyboard but not selectable — M3
|
|
keeps a disabled item focusable so a person can find out it is there. `keep-open` leaves the
|
|
menu open when it is activated — for a choice the person may want to change twice.
|
|
|
|
`submenu` turns the item into a menu of its own: the slot holds `<x-menu-item>`s instead of a
|
|
label, and they open in a second popover beside this one, on the item's end, flipping to its
|
|
start where the window has no room. The item says so — `aria-haspopup="menu"`,
|
|
`aria-expanded`, and a chevron at its end — and keeps the APG menu keyboard: Right, Enter or
|
|
Space open it on its first item, Left or Escape close it and come back here, and on a fine
|
|
pointer resting on the item opens it. Choosing anything inside closes the whole menu, as it
|
|
would from the outer list.
|
|
|
|
In the bottom sheet of an `<x-menu sheet-at-compact>` on a compact window the same markup opens
|
|
in place instead: the list stands under the item, inset, and the chevron turns to point down
|
|
at it (menu.css) — a sheet has no room beside an item, and M3 calls submenus "best suited to
|
|
large screens" (docs/reference/m3/components-actions-communication-containment.md § Menus →
|
|
Behaviour). It is never shown as a popover there, so menu.js only flips `aria-expanded`, and
|
|
the keyboard stays the one above: Right, Enter or Space open it, Left or Escape close it and
|
|
come back to the item, and Escape closes nothing more.
|
|
|
|
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
|
sport's colour (`icon-class="sport-run"`, the application's own class). A colour there paints
|
|
the icon over the ink this file gives it, because a caller's unlayered class always outranks
|
|
the package's layer — except a disabled item's icon, which stays disabled regardless.
|
|
|
|
48px tall (M3's published row height wins over SegmentedMenuTokens' own 44dp, ACT-28),
|
|
body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive spacing,
|
|
ACT-11), 4px corners that open to 12px at the ends of the list — the corner on the spatial
|
|
spring and the colour on the effects one, apart (ACT-19).
|
|
|
|
Drawn by resources/css/components/menu-item.css from `data-md-menu-item`,
|
|
`data-md-description` and the item's aria-* state. --}}
|
|
|
|
@props([
|
|
'label' => null,
|
|
'icon' => null,
|
|
'iconClass' => null,
|
|
'iconRight' => null,
|
|
'description' => null,
|
|
'shortcut' => null,
|
|
'link' => null,
|
|
'external' => false,
|
|
'noWireNavigate' => false,
|
|
'selected' => null,
|
|
'current' => false,
|
|
'badge' => null,
|
|
'disabled' => false,
|
|
'keepOpen' => false,
|
|
'submenu' => false,
|
|
])
|
|
|
|
@php
|
|
$isLink = filled($link);
|
|
$tag = $isLink ? 'a' : 'button';
|
|
|
|
// A submenu's own popover, named like the menu's: a new id and a new anchor name with every
|
|
// render, matched through a morph by the key rather than by either of them.
|
|
$key = $submenu ? \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
|
|
$anchor = $submenu ? "--material-submenu-{$key}" : null;
|
|
|
|
$attributes = $attributes->merge(array_filter([
|
|
'data-md-menu-item' => true,
|
|
'data-md-description' => filled($description) ? true : null,
|
|
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
|
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
|
'aria-current' => $current ? 'page' : null,
|
|
'aria-disabled' => $disabled ? 'true' : null,
|
|
'tabindex' => '-1',
|
|
'x-ref' => $submenu ? 'trigger' : null,
|
|
'style' => $submenu ? "anchor-name: {$anchor}" : null,
|
|
'aria-haspopup' => $submenu ? 'menu' : null,
|
|
'aria-expanded' => $submenu ? 'false' : null,
|
|
'aria-controls' => $submenu ? "material-submenu-{$key}" : null,
|
|
'x-on:click' => $submenu ? "toggle('first')" : null,
|
|
'type' => $isLink ? null : 'button',
|
|
'href' => $isLink ? $link : null,
|
|
'target' => $isLink && $external ? '_blank' : null,
|
|
'rel' => $isLink && $external ? 'noopener' : null,
|
|
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
|
// Opening a submenu is not choosing anything: the outer menu stays where it was.
|
|
'data-md-keep-open' => $keepOpen || $submenu ? true : null,
|
|
], fn ($value): bool => $value !== null))->class(['md-state-layer', 'md-focus-ring']);
|
|
@endphp
|
|
|
|
@if ($submenu)
|
|
<div x-data="materialSubmenu"
|
|
x-on:keydown.right.prevent.stop="open('first')"
|
|
x-on:pointerenter="hover($event)"
|
|
x-on:pointerleave="unhover()"
|
|
>
|
|
@endif
|
|
|
|
<{{ $tag }} {{ $attributes }}>
|
|
@if ($icon)
|
|
<x-livewire-material::icon :name="$icon" size="20" :filled="$selected === true || $current" class="{{ $iconClass }}" />
|
|
@endif
|
|
|
|
<span data-md-menu-item-text>
|
|
<span data-md-menu-item-label>{{ $submenu ? $label : ($label ?? $slot) }}</span>
|
|
|
|
@if ($description)
|
|
<span data-md-menu-item-description>{{ $description }}</span>
|
|
@endif
|
|
</span>
|
|
|
|
@if ($badge !== null && $badge !== false && $badge !== '')
|
|
<x-livewire-material::badge :value="$badge === true ? null : $badge" />
|
|
@endif
|
|
@if ($shortcut)
|
|
<span data-md-menu-item-shortcut>{{ $shortcut }}</span>
|
|
@endif
|
|
|
|
@if ($iconRight)
|
|
<x-livewire-material::icon :name="$iconRight" size="20" />
|
|
@elseif ($submenu)
|
|
{{-- M3's submenu marker: it points the way the list opens, and turns over in an RTL page. --}}
|
|
<x-livewire-material::icon name="chevron_right" size="20" mirror-rtl data-md-submenu-chevron />
|
|
@elseif ($selected === true)
|
|
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
|
|
<x-livewire-material::icon name="check" size="20" />
|
|
@endif
|
|
</{{ $tag }}>
|
|
|
|
@if ($submenu)
|
|
<div
|
|
x-ref="menu"
|
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-submenu-'.substr(md5((string) $label), 0, 10)]) }}
|
|
id="material-submenu-{{ $key }}"
|
|
popover="auto"
|
|
role="menu"
|
|
data-md-submenu
|
|
data-md-popover-exit
|
|
aria-label="{{ $label }}"
|
|
tabindex="-1"
|
|
style="position-anchor: {{ $anchor }}"
|
|
x-on:keydown.stop="navigate($event)"
|
|
x-on:click="activate($event)"
|
|
>
|
|
{{ $slot }}
|
|
</div>
|
|
</div>
|
|
@endif
|