From deb1442dfbd3a395032d336ef10a4dc9730598a4 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 11:30:55 +0200 Subject: [PATCH] Open a menu in a bottom sheet on a compact window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 to . draws its slot twice, in the popover and in a modal teleported to , 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 20 +- resources/css/components/menu.css | 63 ++- resources/js/menu.js | 372 ++++++++++++++++-- .../views/components/menu-item.blade.php | 10 +- resources/views/components/menu.blade.php | 108 ++++- .../views/showcase/sections/menus.blade.php | 20 +- tests/Feature/Components/MenuTest.php | 106 ++++- 7 files changed, 648 insertions(+), 51 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 9379edcc..d8656589 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -261,7 +261,7 @@ M3's plain tooltip, standalone around any trigger: ` ``` -``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` 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`. ``: `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. +``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` 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`. ``: `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 ``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 ``); 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, `` 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 ``, and from `medium` up it opens the popover. Write the items once — the slot is drawn in both: + +```blade + + + + + + + + + + + +``` + +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 ``, 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: `` draws M3's line, `` 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. `` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was. ### `` diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css index ac785bf5..b4c45a7b 100644 --- a/resources/css/components/menu.css +++ b/resources/css/components/menu.css @@ -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); } +/* + * ``: 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; diff --git a/resources/js/menu.js b/resources/js/menu.js index 38836293..d762ed64 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -1,6 +1,7 @@ /** * `materialMenu`: the behaviour of `` — WAI-ARIA's menu button pattern on a popover. * `materialSubmenu`: the same pattern one level in, for ``. + * `materialMenuSheet`: the `open` an `` 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. * * `` 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. + * + * `` has a second copy of its list in a modal ``, which + * the server teleports to , 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: [], + // `` 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) + } + }, + /** * ``: 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) + /** + * `` 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) { diff --git a/resources/views/components/menu-item.blade.php b/resources/views/components/menu-item.blade.php index 8ef4d9c5..1104835e 100644 --- a/resources/views/components/menu-item.blade.php +++ b/resources/views/components/menu-item.blade.php @@ -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 `` 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 @@ @elseif ($submenu) {{-- M3's submenu marker: it points the way the list opens, and turns over in an RTL page. --}} - + @elseif ($selected === true) {{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}} diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index ee31e313..f7b3a4ec 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -50,6 +50,33 @@ one `` 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 `` — 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 : 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 -
class('relative inline-flex') }}> +
class('relative inline-flex') }}> filled($value))) }} class="p-1"> {{ $slot }} - +
@else {{ $slot }} @endif
+ + @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`. --}} + + @endif diff --git a/resources/views/showcase/sections/menus.blade.php b/resources/views/showcase/sections/menus.blade.php index c4e95cfe..b478db88 100644 --- a/resources/views/showcase/sections/menus.blade.php +++ b/resources/views/showcase/sections/menus.blade.php @@ -60,6 +60,23 @@
BLADE, + 'A bottom sheet on a phone, a menu from medium' => <<<'BLADE' + + + + + + + + + + + + + + + + BLADE, 'Clusters: a gap, or a divider' => <<<'BLADE' @@ -128,7 +145,8 @@

<x-menu> with <x-menu-item>, <x-menu-group> and <x-menu-separator>. Open one with the keyboard too: arrows, Home, End, a letter, Escape. A submenu item opens a second list beside it - — Right to enter it, Left to come back. + — Right to enter it, Left to come back. A sheet-at-compact menu opens the same items in a bottom sheet + below 600px, where a submenu opens in place: narrow the window to see it.

@foreach ($examples as $title => $code) diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index 635e928f..dfff180f 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -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('

toContain('Nothing matches') // A text field is not something a `role="menu"` may hold, so the list moves inside it. ->toContain("

toContain('aria-label="Filter"') ->and((string) $this->blade('')) ->not->toContain('data-menu-filter') - ->not->toContain('x-ref="filter"') + ->not->toContain('role="combobox"') ->toMatch('/]*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' + + + + + + BLADE); + + preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key); + + expect($key)->not->toBeEmpty(); + + [$popover, $sheet] = explode('