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
@@ -261,7 +261,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`submenu` makes an item a menu of its own — the slot holds the nested `<x-menu-item>`s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:
@@ -276,6 +276,24 @@ The item says so with `aria-haspopup="menu"`, `aria-expanded` and a chevron; Rig
`filter` puts a text field at the top of the list (M3's menu as a filtering surface) and narrows the items to those whose label holds what has been typed — in the browser, over the items already rendered, so nothing is fetched and every `wire:click` stays where it was. `filter="Find a person"` names the field; bare `filter` calls it "Filter". The field keeps the focus while the arrow keys, Home and End move a highlighted row and Enter chooses it (`aria-activedescendant`, as `<x-choices searchable>`); a query that leaves nothing says "Nothing matches". Reach for it once a menu is long enough to hunt through; for a value bound to a property, `<x-choices searchable>` is the field, not the menu.
`sheet-at-compact` is M3's adaptive menu ("at compact breakpoints, consider swapping a menu for a bottom sheet"): below `medium` (600px) the trigger opens the same items in a modal `<x-bottom-sheet>`, and from `medium` up it opens the popover. Write the items once — the slot is drawn in both:
```blade
<x-menu label="Photo actions" sheet-at-compact>
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-item label="Set as wallpaper" icon="wallpaper" description="Home and lock screen" wire:click="wallpaper" />
<x-menu-item label="Add to album" icon="photo_album" submenu>
<x-menu-item label="Holidays" wire:click="addTo('holidays')" />
</x-menu-item>
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
</x-menu>
```
On a compact window the trigger says `aria-haspopup="dialog"` (and `menu` from `medium`), with `aria-expanded` in both. In the sheet the items keep their `menuitem` roles and the menu keyboard (arrows, Home, End, a letter; Escape or Tab close it and focus returns to the trigger); choosing an item closes it, as do the scrim and a swipe down; a `submenu` opens in place under its item rather than beside it; a `filter` field sits at the top; a `keep-open` item's render keeps the sheet open. Resizing the window across 600px while it is open closes it. The sheet is teleported to the end of `<body>`, so it covers the window from inside a sticky app bar or a toolbar, and is surface-container-low even for a `vibrant` menu. Because the items are rendered twice, do not give them an `id` or nest a Livewire component in a `sheet-at-compact` menu.
Clusters: `<x-menu-separator />` draws M3's line, `<x-menu-group gap>` M3 Expressive's grouped layout — no line, the cluster set 8px off its neighbours with its items 2px apart and its ends rounded. Reach for the divider first (M3: "on web, use dividers to separate items", and it is the only one a scrolling menu may use); reach for the gap for one or two clusters in a menu short enough not to scroll, and never vary the gap. `<x-menu-group>` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was.
### `<x-button-group>`