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>`
+61 -2
View File
@@ -79,17 +79,76 @@
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
`display: flex` utility would otherwise beat the user agent's `[hidden] { display: none }`. */
[data-menu] [hidden] {
:is([data-menu], [data-menu-sheet]) [hidden] {
display: none;
}
/* `data-active` is the row the arrow keys are on while the focus stays in the field the state
layer the roving focus would have drawn, without taking the focus off the field. A chosen or
current row keeps its own colour. */
[data-menu] [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
:is([data-menu], [data-menu-sheet]) [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
background: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 8%, transparent);
}
/*
* `<x-menu sheet-at-compact>`: the copy of the list in the bottom sheet a compact window opens
* instead of the popover. The sheet is surface-container-low, as the standard menu is, so the
* fallbacks above already paint its field; the field reaches both edges of the sheet and holds
* its icon where the rows hold theirs, 24px in (the list is 8px in, a row's own padding 16px).
*/
[data-menu-sheet] [data-menu-filter] {
padding-inline: 1.5rem;
}
/*
* A submenu there opens in place: its list stands under the item, inset by 16px, instead of in a
* popover beside it, which a sheet has no room for (M3 calls submenus "best suited to large
* screens", docs/reference/m3/components-actions-communication-containment.md § Menus). The
* markup is the popover's, never shown as one: the user agent's `[popover]` box is undone, the
* list is shown while its item says `aria-expanded="true"`, and it fades in on the effects track.
*/
[data-menu-sheet] [data-submenu] {
position: static;
inset: auto;
inline-size: auto;
max-inline-size: none;
block-size: auto;
max-block-size: none;
margin: 0;
padding-block: 0.125rem;
padding-inline: 1rem 0;
overflow: visible;
border-radius: 0;
background: transparent;
color: inherit;
box-shadow: none;
opacity: 1;
scale: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-menu-sheet] [aria-expanded="true"] + [data-submenu] {
display: block;
@starting-style {
opacity: 0;
}
}
/* The chevron turns from the way a popover would open to the way this list did: down, which is a
quarter turn the other way on the chevron `rtl:-scale-x-100` mirrors so the same RTL test. */
[data-menu-sheet] [data-submenu-chevron] {
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-menu-sheet] [aria-expanded="true"] > [data-submenu-chevron] {
rotate: 90deg;
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
rotate: -90deg;
}
}
.field-menu {
max-block-size: 18rem;
overflow-y: auto;
+331 -41
View File
@@ -1,6 +1,7 @@
/**
* `materialMenu`: the behaviour of `<x-menu>` WAI-ARIA's menu button pattern on a popover.
* `materialSubmenu`: the same pattern one level in, for `<x-menu-item submenu>`.
* `materialMenuSheet`: the `open` an `<x-menu sheet-at-compact>` lends its bottom sheet.
*
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
* script, which a Livewire morph removes along with anything else the server did not render,
@@ -18,15 +19,27 @@
* nested `popover="auto"` light-dismisses only down to its DOM ancestor and closes the inner one
* when the outer goes. Its trigger *is* the item, which the server names itself, so the two pieces
* that exist only for a wrapper (moving the anchor name, and finding the button inside the trigger
* slot) are overridden away. `items()` stops at the popover it belongs to, so the arrow keys in a
* slot) are overridden away. `items()` stops at the list it belongs to, so the arrow keys in a
* menu never walk into an open submenu's rows, nor a submenu's back out into its parent's.
*
* `<x-menu filter>` adds a text field at the top of the same list. The field keeps the focus while
* the arrow keys move a highlight APG's combobox, which is what a text field inside a popup
* asks for so `refine()`, `visible()`, `mark()` and `search()` work on `aria-activedescendant`
* and the `hidden` attribute rather than on the roving focus the rest of this file uses. They do
* nothing at all in a menu with no field: `$refs.filter` is what turns them on.
* nothing at all in a menu with no field: `field()` finding one is what turns them on.
*
* `<x-menu sheet-at-compact>` has a second copy of its list in a modal `<x-bottom-sheet>`, which
* the server teleports to <body>, and below `medium` (`upTo('medium')`, 600px) `open()` shows that
* instead of the popover (M3 § Menus Behaviour: "at compact breakpoints, consider swapping a
* menu for a bottom sheet"). `shown` remembers which of the two was opened last, and `list()`
* is that one's list, so the keyboard, the filter and `activate()` work on whichever the person is
* looking at. The sheet's own ways out its scrim, a swipe, Escape, its handle only set `open`
* false, and `sheetToggled()` follows them with the trigger's `aria-expanded` and its focus. A
* submenu in that copy is never shown as a popover: `inline` makes it open in place under its
* item, which menu.css draws from the item's `aria-expanded`.
*/
import { upTo } from './breakpoints.js'
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
@@ -48,6 +61,14 @@ const menu = () => ({
focusWasInside: false,
listeners: [],
// `<x-menu sheet-at-compact>` only: the compact window's media query, whether the sheet is
// open, which presentation was opened last ('menu' or 'sheet'), and what had the focus as the
// sheet opened.
compact: null,
sheetOpen: false,
shown: 'menu',
focusedBefore: null,
init() {
const menu = this.$refs.menu
@@ -103,10 +124,7 @@ const menu = () => ({
this.focusWasInside = false
// A filtered menu opens on the whole list again: the query belonged to that visit.
if (this.$refs.filter) {
this.$refs.filter.value = ''
this.refine()
}
this.clear(menu)
})
// A press outside closes the menu without pulling focus back to the trigger.
@@ -115,6 +133,32 @@ const menu = () => ({
this.returnFocus = false
}
})
if (this.$el.hasAttribute('data-sheet-at-compact')) {
this.compact = upTo('medium')
// A window resized across 600px while the menu is open would leave it in the
// presentation the window no longer asks for; it closes instead, and the trigger says
// what it opens now.
this.listen(this.compact, 'change', () => {
this.close()
this.label()
})
this.$watch('sheetOpen', (opened) => this.sheetToggled(opened))
// The sheet is teleported after this runs, so what reads it waits a tick: a morph gives
// it a new id with the popover's, and the trigger has to point at the one it opens.
this.$nextTick(() => {
const dialog = this.sheetDialog()
if (dialog) {
observer.observe(dialog, { attributes: true, attributeFilter: ['id'] })
}
this.label()
})
}
},
control() {
@@ -154,7 +198,10 @@ const menu = () => ({
}
},
/** Writes only what differs: the observer that calls this watches these same attributes. */
/**
* Writes only what differs: the observer that calls this watches these same attributes. On a
* compact window a `sheet-at-compact` trigger opens a dialog, and says so.
*/
label() {
const control = this.control()
@@ -162,7 +209,15 @@ const menu = () => ({
return
}
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
// Named even on a wide window, so no two sheets on a page share the rendered id.
const dialog = this.sheetDialog()
const sheet = this.sheeted() ? dialog : null
const attributes = {
'aria-haspopup': sheet ? 'dialog' : 'menu',
'aria-controls': sheet ? sheet.id : this.$refs.menu.id,
'aria-expanded': String(this.isOpen()),
}
for (const [name, value] of Object.entries(attributes)) {
if (control.getAttribute(name) !== value) {
@@ -172,10 +227,51 @@ const menu = () => ({
},
isOpen() {
return this.$refs.menu.matches(':popover-open')
return this.sheetOpen || this.$refs.menu.matches(':popover-open')
},
/** Whether opening now means the sheet: a `sheet-at-compact` menu on a compact window. */
sheeted() {
return Boolean(this.compact?.matches && this.$refs.sheetHost)
},
/**
* The sheet's `role="dialog"`, which the trigger controls while the window is compact. Every
* menu's sheet is rendered with the same id, which a morph matches it by (menu.blade.php), so
* this names each one after its popover and keeps the rendered id as its `wire:key`: the key
* matches the next render's id, and a render that puts the rendered id back is named again
* the id observer calls here through `label()`.
*/
sheetDialog() {
const dialog = this.$refs.sheetHost?.querySelector('[role="dialog"]') ?? null
const id = `${this.$refs.menu.id}-sheet`
if (dialog && dialog.id !== id) {
dialog.setAttribute('wire:key', 'material-menu-sheet')
dialog.id = id
}
return dialog
},
/** The list the person is looking at: the popover, or the sheet's copy of it. */
list() {
return this.shown === 'sheet' ? this.$refs.sheetHost.querySelector('[data-menu-sheet]') : this.$refs.menu
},
/** The filter field at the top of a list, if the menu has one; a submenu never does. */
field(root = this.list()) {
return root?.querySelector(':scope > [data-menu-filter] input') ?? null
},
open(focus = 'first') {
if (this.sheeted()) {
this.openSheet(focus)
return
}
this.shown = 'menu'
this.label()
this.anchor()
@@ -188,10 +284,8 @@ const menu = () => ({
// A filtering menu hands the focus to its field, not to a row: the field is where the
// typing goes, and `aria-activedescendant` says which row the arrows are on meanwhile.
if (this.$refs.filter) {
this.$refs.filter.focus()
this.$refs.filter.select()
this.mark(this.visible()[0] ?? null)
if (this.field()) {
this.lookUp()
return
}
@@ -203,8 +297,45 @@ const menu = () => ({
}
},
/**
* The sheet shows a frame or two after `open` turns true, once its transition has begun, and
* Alpine holds `$nextTick` until then. Its focus trap starts on a timer of its own and keeps a
* focus already inside it, so the item (or the field) M3 asks to be focused first wins over
* the drag handle the trap would otherwise pick.
*/
openSheet(focus) {
this.focusedBefore = document.activeElement
this.shown = 'sheet'
this.sheetOpen = true
this.control()?.setAttribute('aria-expanded', 'true')
this.label()
this.$nextTick(() => {
if (this.field()) {
this.lookUp()
return
}
this.focusItem(focus)
})
},
/** Focus in the field, its text selected, and the first row it leaves highlighted. */
lookUp() {
const field = this.field()
field.focus()
field.select()
this.mark(this.visible()[0] ?? null)
},
close() {
if (this.isOpen()) {
if (this.sheetOpen) {
this.sheetOpen = false
}
if (this.$refs.menu.matches(':popover-open')) {
this.$refs.menu.hidePopover()
}
@@ -220,15 +351,56 @@ const menu = () => ({
},
/**
* Every item of *this* menu, disabled ones included: M3 keeps a disabled item focusable
* Follows the sheet opening or closing, however it was done. Its focus trap hands the focus
* back on a timer of its own, started as it lets go, to whatever had it when the trap began:
* an item of the sheet now sliding away, or what had it before the sheet opened in WebKit,
* where a press does not focus a button, a region around the trigger. The trigger takes it on
* a timer started inside a timer, which runs after the trap's, unless something else (a
* dialog an item opened) has it by then.
*/
sheetToggled(opened) {
this.control()?.setAttribute('aria-expanded', String(opened))
if (opened) {
return
}
const list = this.$refs.sheetHost?.querySelector('[data-menu-sheet]')
if (list) {
this.clear(list)
// A submenu opened in place is closed with the sheet, as a nested popover closes with
// its menu.
for (const submenu of list.querySelectorAll('[data-submenu]')) {
window.Alpine.$data(submenu)?.close?.()
}
}
setTimeout(() =>
setTimeout(() => {
const active = document.activeElement
if (active === null || active === document.body || active === this.focusedBefore || this.$refs.sheetHost?.contains(active)) {
this.control()?.focus()
}
this.focusedBefore = null
}),
)
},
/**
* Every item of *this* list, disabled ones included: M3 keeps a disabled item focusable
* ("disabled items can still receive focus, just aren't selectable") so a person reading the
* menu with the keyboard learns that it exists. activate() is where the refusal lives.
*
* An open submenu is a popover of its own nested in this one, and its rows are its: the
* nearest popover around a row says which menu the arrow keys should find it in.
* An open submenu is a list of its own nested in this one, and its rows are its: the nearest
* popover or sheet list around a row says which menu the arrow keys should find it in. A
* submenu keeps its `popover` attribute in the sheet too, where it opens in place.
*/
items() {
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover]') === this.$refs.menu)
items(root = this.list()) {
return [...root.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover], [data-menu-sheet]') === root)
},
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
@@ -285,58 +457,102 @@ const menu = () => ({
}
},
/**
* The sheet's keyboard, heard on its host before anything inside: the sheet's scope, where its
* items live, has a `close()` and an `activate()` of its own. The field gets the combobox keys
* and the list's own items the menu's; an item of a submenu opened in place is left to that
* submenu. Tab leaves a menu (APG) and so the sheet around it, which its focus trap would
* otherwise keep circling; Escape reaches the sheet itself, which closes.
*/
sheetKeydown(event) {
const field = this.field()
const item = event.target.closest(ITEMS)
if (event.key === 'Tab' && (item !== null || event.target === field)) {
event.preventDefault()
this.close()
return
}
if (field !== null && event.target === field) {
this.search(event)
} else if (item !== null && this.items().includes(item)) {
this.navigate(event)
}
},
/** Empties a list's filter field and shows every row again. */
clear(root) {
const field = this.field(root)
if (field) {
field.value = ''
this.refine(root)
}
},
/**
* `<x-menu filter>`: M3's menu as a filtering surface. The rows are already rendered, so this
* only hides the ones the query leaves out with the `hidden` attribute, which menu.css turns
* into `display: none` over the row's own `display: flex`. A divider means nothing between two
* filtered clusters, and a group whose every row has gone is a heading over nothing.
*/
refine() {
const query = this.$refs.filter.value.trim().toLowerCase()
refine(root = this.list()) {
const field = this.field(root)
for (const item of this.items()) {
if (!field) {
return
}
const query = field.value.trim().toLowerCase()
for (const item of this.items(root)) {
item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query)
}
for (const rule of this.$refs.menu.querySelectorAll('[role="separator"]')) {
for (const rule of root.querySelectorAll('[role="separator"]')) {
rule.hidden = query !== ''
}
for (const group of this.$refs.menu.querySelectorAll('[role="group"]')) {
for (const group of root.querySelectorAll('[role="group"]')) {
group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden)
}
const left = this.visible()
const left = this.visible(root)
this.$refs.empty.hidden = left.length > 0
this.mark(left[0] ?? null)
root.querySelector('[data-menu-empty]').hidden = left.length > 0
this.mark(left[0] ?? null, root)
},
/** The rows a query has left, in the order they are read. */
visible() {
return this.items().filter((item) => !item.hidden && item.closest('[hidden]') === null)
visible(root = this.list()) {
return this.items(root).filter((item) => !item.hidden && item.closest('[hidden]') === null)
},
/**
* Moves the highlight the arrow keys carry while the focus stays in the field. The row needs
* an id for `aria-activedescendant` to name it, and gets one if the caller wrote none.
* an id for `aria-activedescendant` to name it, and gets one if the caller wrote none from
* the list's own id, which the popover and the sheet do not share.
*/
mark(item) {
for (const each of this.items()) {
mark(item, root = this.list()) {
const field = this.field(root)
for (const each of this.items(root)) {
if (each !== item) {
each.removeAttribute('data-active')
}
}
if (!item) {
this.$refs.filter.removeAttribute('aria-activedescendant')
field?.removeAttribute('aria-activedescendant')
return
}
item.id ||= `${this.$refs.menu.id}-item-${this.items().indexOf(item)}`
item.id ||= `${root.id}-item-${this.items(root).indexOf(item)}`
item.setAttribute('data-active', '')
this.$refs.filter.setAttribute('aria-activedescendant', item.id)
field?.setAttribute('aria-activedescendant', item.id)
if (this.isOpen()) {
item.scrollIntoView({ block: 'nearest' })
@@ -374,8 +590,8 @@ const menu = () => ({
return
case 'Escape':
// The browser's own light dismiss closes the popover; this only says where the
// focus goes after it.
// The browser's own light dismiss closes the popover, and the sheet's Escape the
// sheet; this only says where the focus goes after it.
this.returnFocus = true
return
@@ -408,12 +624,37 @@ const menu = () => ({
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', menu)
/**
* `<x-bottom-sheet>` reads and writes `open` from the scope around it, and in a menu's scope
* `open` is a method. This scope sits between the two and passes the sheet's `open` through to
* the menu's `sheetOpen`: a getter and setter pair, which Alpine calls with the scope that
* asked, so `this` reaches the menu from inside the sheet as well.
*/
window.Alpine.data('materialMenuSheet', () => ({
get open() {
return this.sheetOpen
},
set open(value) {
this.sheetOpen = Boolean(value)
},
}))
window.Alpine.data('materialSubmenu', () => {
const base = menu()
return {
...base,
hoverTimer: null,
inline: false,
expanded: false,
/** In the sheet of a `sheet-at-compact` menu the submenu opens in place, under its item. */
init() {
this.inline = this.$el.closest('[data-menu-sheet]') !== null
base.init.call(this)
},
/** The item is the menu button, and the server named it: nothing has to be moved. */
control() {
@@ -422,11 +663,57 @@ document.addEventListener('alpine:init', () => {
anchor() {},
isOpen() {
return this.inline ? this.expanded : base.isOpen.call(this)
},
/**
* The sheet's copy of a submenu is rendered with the popover copy's id; it takes one of
* its own, and takes it again after a morph puts the rendered one back.
*/
label() {
const list = this.$refs.menu
if (this.inline && !list.id.endsWith('-sheet')) {
list.id = `${list.id}-sheet`
}
base.label.call(this)
},
/** In place, the item's `aria-expanded` is what shows the list (menu.css). */
open(focus = 'first') {
if (!this.inline) {
base.open.call(this, focus)
return
}
this.expanded = true
this.label()
if (focus !== false) {
this.focusItem(focus)
}
},
close() {
if (!this.inline) {
base.close.call(this)
return
}
this.expanded = false
this.control()?.setAttribute('aria-expanded', 'false')
},
navigate(event) {
// APG: Left closes a submenu and puts the focus back on the item that opened it.
// Escape does the same through the browser's own light dismiss, which the `toggle`
// listener follows with the focus.
if (event.key === 'ArrowLeft') {
// listener follows with the focus — or, opened in place in a sheet, where nothing
// light-dismisses it, here, and no further: the sheet stays open.
if (event.key === 'ArrowLeft' || (this.inline && event.key === 'Escape')) {
event.preventDefault()
this.returnFocus = true
this.close()
@@ -438,9 +725,12 @@ document.addEventListener('alpine:init', () => {
base.navigate.call(this, event)
},
/** Hover opens a submenu only where hovering means something, and never on a first tap. */
/**
* Hover opens a submenu only where hovering means something, and never on a first tap,
* nor in a sheet, where the list would jump open under the pointer.
*/
fine(event) {
return (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches
return !this.inline && (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches
},
hover(event) {
@@ -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>
@@ -60,6 +60,23 @@
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'A bottom sheet on a phone, a menu from medium' => <<<'BLADE'
<x-menu label="Photo actions" sheet-at-compact>
<x-slot:trigger>
<x-button label="Photo" icon="photo" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-item label="Set as wallpaper" icon="wallpaper" description="Home and lock screen" />
<x-menu-item label="Add to album" icon="photo_album" submenu>
<x-menu-item label="Holidays" icon="beach_access" />
<x-menu-item label="Family" icon="family_restroom" />
<x-menu-item label="Favourites" icon="favorite" />
</x-menu-item>
<x-menu-item label="Print" icon="print" shortcut="⌘P" />
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'Clusters: a gap, or a divider' => <<<'BLADE'
<x-menu label="Grouped by a gap">
<x-slot:trigger>
@@ -128,7 +145,8 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-menu&gt;</code> with <code>&lt;x-menu-item&gt;</code>, <code>&lt;x-menu-group&gt;</code> and <code>&lt;x-menu-separator&gt;</code>.
Open one with the keyboard too: arrows, Home, End, a letter, Escape. A <code>submenu</code> item opens a second list beside it
&mdash; Right to enter it, Left to come back.
&mdash; Right to enter it, Left to come back. A <code>sheet-at-compact</code> menu opens the same items in a bottom sheet
below 600px, where a submenu opens in place: narrow the window to see it.
</p>
@foreach ($examples as $title => $code)
+104 -2
View File
@@ -184,7 +184,6 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
expect($id)->not->toBeEmpty()
->and($html)
->toContain('data-menu-filter')
->toContain('x-ref="filter"')
->toContain('role="combobox"')
->toContain('aria-autocomplete="list"')
->toContain("aria-controls=\"{$id[1]}-list\"")
@@ -192,6 +191,7 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
->toContain('placeholder="Find a person"')
->toContain('x-on:input="refine()"')
->toContain('x-on:keydown.stop="search($event)"')
->toContain('<p data-menu-empty hidden')
->toContain('Nothing matches')
// A text field is not something a `role="menu"` may hold, so the list moves inside it.
->toContain("<div id=\"{$id[1]}-list\" role=\"menu\" aria-label=\"Assign to\"")
@@ -203,7 +203,7 @@ it('names a bare filter field, and leaves a plain menu alone', function () {
->toContain('aria-label="Filter"')
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->not->toContain('data-menu-filter')
->not->toContain('x-ref="filter"')
->not->toContain('role="combobox"')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
});
@@ -215,3 +215,105 @@ it('tells a vibrant menu apart, so the submenus inside it take the same containe
->toContain('data-menu')
->not->toContain('data-vibrant');
});
it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as the popover', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu label="Photo actions" sheet-at-compact>
<x-slot:trigger><button>More</button></x-slot:trigger>
<x-menu-item label="Set as wallpaper" icon="wallpaper" wire:click="wallpaper" />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE);
preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key);
expect($key)->not->toBeEmpty();
[$popover, $sheet] = explode('<template x-teleport="body">', $html);
// The popover is the menu it always was, and the root tells menu.js to look for the sheet.
expect($popover)
->toMatch('/<div x-data="materialMenu"\s+data-sheet-at-compact/')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"[^>]*aria-label="Photo actions"/')
->toContain('wire:click="wallpaper"')
// The sheet goes to the end of <body>, out of any bar's stacking context, and its host
// hears the keyboard, the clicks and the filter in the menu's scope.
->and($sheet)
->toContain('x-ref="sheetHost"')
->toContain('x-on:keydown.capture="sheetKeydown($event)"')
->toContain('x-on:click="activate($event)"')
->toContain('<div x-data="materialMenuSheet">')
// A modal bottom sheet, named by the menu's label. Its id is the same in every render, so
// a Livewire morph patches it rather than swapping it (menu.js makes it unique and keeps
// this one as the key); the lists carry keys of their own, as the popover does.
->toContain('...materialBottomSheet(false, JSON.parse(')
->toContain('id="material-menu-sheet"')
->not->toContain("material-menu-{$key[1]}-sheet\"")
// A key given to the Blade component would become the key of the loop around the menu.
->not->toMatch('/<section\s[^>]*wire:key/')
->toContain('role="dialog"')
->toContain('aria-modal="true"')
->toContain('x-trap.inert.noscroll="open"')
->toContain('aria-label="Photo actions"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
// The same slot, drawn again as a menu: the items keep their roles and their actions.
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-menu-sheet/")
->and(substr_count($html, 'role="menuitem"'))->toBe(4)
->and(substr_count($html, 'wire:click="wallpaper"'))->toBe(2);
});
it('leaves the sheet out of a menu that does not ask for it', function () {
$html = (string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Copy" /></x-menu>');
expect($html)
->not->toContain('data-sheet-at-compact')
->not->toContain('x-teleport')
->not->toContain('materialBottomSheet')
->not->toContain('data-menu-sheet')
->and(substr_count($html, 'role="menuitem"'))->toBe(1);
});
it('names a sheet with no menu label, and draws a filtering menu\'s field in the sheet too', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu filter="Find a person" sheet-at-compact vibrant>
<x-slot:trigger><button>x</button></x-slot:trigger>
<x-menu-item label="Ada" />
</x-menu>
BLADE);
preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key);
[, $sheet] = explode('<template x-teleport="body">', $html);
expect($sheet)
->toContain('aria-label="Menu"')
->toContain('x-on:input="refine()"')
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-menu-sheet")
->toContain('data-menu-filter')
->toContain('role="combobox"')
->toContain("aria-controls=\"material-menu-{$key[1]}-sheet-menu-list\"")
->toContain('placeholder="Find a person"')
->toContain("<div wire:key=\"material-menu-sheet-list\" id=\"material-menu-{$key[1]}-sheet-menu-list\" role=\"menu\"")
->toContain('<p data-menu-empty hidden')
// The sheet is M3's own container whatever the menu's colour.
->not->toContain('data-vibrant')
->not->toContain('bg-tertiary-container')
->and(substr_count($html, 'role="combobox"'))->toBe(2);
});
it('marks a submenu\'s chevron, which turns to point down at the list a sheet opens in place', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu label="Share" sheet-at-compact>
<x-slot:trigger><button>x</button></x-slot:trigger>
<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>
</x-menu>
BLADE);
[$popover, $sheet] = explode('<template x-teleport="body">', $html);
// One markup for both: menu.js tells the sheet's copy apart by the list around it.
expect($popover)->toContain('data-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('data-submenu')
->and($sheet)->toContain('data-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('aria-haspopup="menu"')->toContain('data-submenu')
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->not->toContain('data-submenu-chevron');
});