Items were 44px tall with the baseline menu's 12px sides, the separator had half its 8dp padding, a disabled item was skipped by the keyboard entirely, a chosen item was told by colour and shape alone, and the list simply faded in place. Now: 48px rows with 16px either side, an 8px separator, disabled items focusable but never activatable, a trailing check on a selected item, and an enter-exit that scales the list out of the corner nearest its trigger — the scale on the spatial spring, the fade on the effects one. Plan step 18, actions.md ACT-11, ACT-12, ACT-13, ACT-26, ACT-27, ACT-28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
92 lines
4.8 KiB
PHP
92 lines
4.8 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: 18rem 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
|
|
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
|
|
|
|
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 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto 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]',
|
|
'popover-transition',
|
|
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
|
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
|
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
|
|
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
|
|
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
|
|
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
|
|
])
|
|
>
|
|
{{ $slot }}
|
|
</div>
|
|
</div>
|