Open a menu in a bottom sheet on a compact window

Plan step 22, item 8 (actions.md § Missing, "Adaptive menu → bottom sheet
at compact"): M3 says "at compact breakpoints, consider swapping a menu for
a bottom sheet", and nothing connected <x-menu> to <x-bottom-sheet>.

<x-menu sheet-at-compact> draws its slot twice, in the popover and in a
modal <x-bottom-sheet> teleported to <body>, and below `medium`
(upTo('medium')) the trigger opens the sheet. The trigger says
aria-haspopup="dialog" there and "menu" from medium, aria-expanded in
both. Items keep their roles and the APG keyboard in the sheet; choosing
one, Escape or Tab close it and return focus to the trigger; a submenu
opens in place under its item; `filter` works in both; a resize across
600px closes whichever is open. The sheet has a fixed id that menu.js
makes unique and keeps as its wire:key, and the lists are keyed, so a
Livewire render patches an open sheet instead of swapping it.

bottom-sheet.blade.php is used as is. The filter now finds its field and
empty row per list instead of through x-refs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 11:30:55 +02:00
co-authored by Claude Opus 5
parent 12cdeaaf67
commit deb1442dfb
7 changed files with 648 additions and 51 deletions
@@ -21,6 +21,14 @@
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="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
@@ -141,7 +149,7 @@
<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" />
<x-livewire-material::icon name="chevron_right" optical="20" data-submenu-chevron :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" />
+105 -3
View File
@@ -50,6 +50,33 @@
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). --}}
@@ -59,6 +86,7 @@
'position' => 'bottom-start',
'vibrant' => false,
'filter' => false,
'sheetAtCompact' => false,
])
@php
@@ -68,9 +96,13 @@
$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" {{ $attributes->class('relative inline-flex') }}>
<div x-data="materialMenu" @if ($sheet) data-sheet-at-compact @endif {{ $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')"
@@ -108,7 +140,6 @@
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
<input
x-ref="filter"
type="text"
role="combobox"
autocomplete="off"
@@ -129,10 +160,81 @@
], fn ($value): bool => filled($value))) }} class="p-1">
{{ $slot }}
<p x-ref="empty" hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
<p data-menu-empty hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('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-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-menu-sheet class="-mx-6">
<div data-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
<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))) }} class="px-2 pt-1">
{{ $slot }}
<p data-menu-empty hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('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-menu-sheet class="-mx-6 px-2">
{{ $slot }}
</div>
@endif
</x-livewire-material::bottom-sheet>
</div>
</div>
</template>
@endif
</div>