Merge branch 'worktree-agent-a9c6ef32512bc556d'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 11:31:19 +02:00
7 changed files with 648 additions and 51 deletions
+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) {