Files
livewire-material/resources/views/components/menu-item.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 25091ebf21 Open a submenu from a menu item
Plan step 22, actions.md § Missing (Submenus): M3's Expressive vertical
menu specifies submenus and the Left/Right keys that open and close them,
and the library had neither.

`<x-menu-item submenu>` puts its slot in a second popover on the item's
end, flipping to the start where there is no room. `materialSubmenu`
reuses the menu button pattern one level in: the item is its own trigger,
so the anchor move and the button lookup are overridden away; Right,
Enter and Space open it, Left and Escape close it and return the focus,
and a fine pointer resting on the item opens it after a moment. `items()`
now stops at the popover it belongs to, so the arrows never walk between
a menu and an open submenu. The menu publishes its container as custom
properties so a vibrant menu's submenus are vibrant too.

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

170 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- 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.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
item's too: the icon's own colour then carries no specificity, because which of two colour
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
disabled.
48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive
spacing), 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 (`state-transition-fast`). The row is 48px rather
than SegmentedMenuTokens' 44px: M3's menu page publishes 48dp and asks for ≥48×48 targets
inside an item's slots, and the library's own select and choices menus are 48px, so the more
binding source wins over the more specific one. --}}
@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
->class([
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
'state-transition-fast',
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
'py-2' => filled($description),
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true,
'pointer-events-none text-on-surface/38' => $disabled,
])
->merge(array_filter([
'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-keep-open' => $keepOpen || $submenu ? true : null,
], fn ($value): bool => $value !== null));
$iconInk = match (true) {
$disabled => 'text-on-surface/38',
$selected === true => 'text-on-tertiary-container',
$current => 'text-on-secondary-container',
default => 'text-on-surface-variant',
};
$leadingIcon = match (true) {
blank($iconClass) => 'size-5 '.$iconInk,
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
$current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]),
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
};
@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" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
<span class="block truncate type-body-lg">{{ $submenu ? $label : ($label ?? $slot) }}</span>
@if ($description)
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
@endif
</span>
@if ($badge !== null && $badge !== false && $badge !== '')
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
@endif
@if ($shortcut)
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
@endif
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
@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" optical="20" :class="'size-5 rtl:-scale-x-100 '.$iconInk" />
@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" optical="20" :class="'size-5 '.$iconInk" />
@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-submenu
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown.stop="navigate($event)"
x-on:click="activate($event)"
class="m-0 mx-1 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] origin-top overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 popover-transition [inset:auto] [position-area:inline-end_span-block-end] [position-try-fallbacks:flip-inline]"
>
{{ $slot }}
</div>
</div>
@endif