Files
livewire-material/resources/views/components/menu.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id
that is new with every render, and Livewire's morph matches an element
without a wire:key by its id. So every render of the component around
them swapped the popover for a closed copy: an open menu closed, its
listeners stayed behind on the old element (Escape or a press outside
then left aria-expanded="true" on the trigger and focus unreturned), a
keep-open item's wire:click closed its menu when the response came, and
a rich tooltip went on showing the detached bubble, so it never opened
again. <x-carousel>'s row had the same kind of id: after a render it
scrolled without its listeners, and its items stopped re-masking.

The popover, the bubble and the row now carry a wire:key, so the morph
patches them in place and changes the id and anchor name together with
the trigger's, as it already did for everything else. 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 have
given every child component after the menu in a row the same key.

A morph also removes the menu button's ARIA attributes, which only
script writes. menu.js now writes them again after every morph, so the
button of a menu that stays open, and a FAB menu's close look, still
say it is open, and aria-controls names the popover's new id.

A second press on an open menu's button opened it again, render or not:
the popover closes on the press, and the guard against the click that
follows was timed from the toggle event, which is queued and arrives
after that click. It is timed from beforetoggle now.

Browser tests with Livewire probes in Chromium, Firefox and WebKit, and
a render test for the keys and the keys of the child components after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00

84 lines
4.3 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.
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). --}}
@props([
'label' => null,
'position' => 'bottom-start',
'vibrant' => 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}";
@endphp
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
<span x-ref="trigger" class="inline-flex" 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"
role="menu"
@if ($label) 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 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
'[position-area:bottom_span-right]' => $position === 'bottom-start',
'[position-area:bottom_span-left]' => $position === 'bottom-end',
'[position-area:top_span-right]' => $position === 'top-start',
'[position-area:top_span-left]' => $position === 'top-end',
])
>
{{ $slot }}
</div>
</div>