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:
co-authored by
Claude Opus 5
parent
12cdeaaf67
commit
deb1442dfb
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user