Files
livewire-material/resources/js/menu.js
T
Andreas ReinholdandClaude Opus 5 c1ca157341 Scroll a menu that is too long for the window
The popover was fit-content with overflow visible, so a long menu ran past the
edge of the top layer, where the page's own scrolling cannot reach it. It now
caps at 18rem — less on a short window — and scrolls, as M3's menu behaviour
asks, and the keyboard brings the item it moves to into view. Plan step 11,
actions.md ACT-04.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:55:38 +02:00

260 lines
9.6 KiB
JavaScript

/**
* `materialMenu`: the behaviour of `<x-menu>` — WAI-ARIA's menu button pattern on a popover.
*
* 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,
* so they are written again whenever the trigger is used and after every morph — an open menu
* lives through one (the popover is keyed), and its button must still say so.
*
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
* wrapper around the trigger slot, and a trigger taken out of the flow — a `position: fixed` FAB
* in a corner of the window — leaves that wrapper behind as an empty box where the page put it,
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
* which puts the server's attributes, and a fresh name, back.
*/
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
// would open it again. A close this recent is taken as that press. It is timed from
// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
// click.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity,
anchored: null,
returnFocus: true,
focusWasInside: false,
listeners: [],
init() {
const menu = this.$refs.menu
this.label()
this.anchor()
// A morph rewrites the wrapper's style with this render's name and the button's without
// it, takes the button's ARIA attributes away and gives the popover a new id; the
// observer runs before the next frame is drawn, so an open menu never moves and its
// button never shows it shut.
const observer = new MutationObserver(() => {
this.anchor()
this.label()
})
observer.observe(this.$refs.trigger, {
attributes: true,
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
childList: true,
subtree: true,
})
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
this.listeners.push(() => observer.disconnect())
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut.
// Whether focus was in the menu is read before it closes: once closed, a browser may
// already have handed focus to what had it before the menu opened (WebKit does, when
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
if (event.newState === 'closed') {
this.closedAt = performance.now()
}
})
this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open'
this.control()?.setAttribute('aria-expanded', String(opened))
if (opened) {
return
}
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus()
}
this.focusWasInside = false
})
// A press outside closes the menu without pulling focus back to the trigger.
this.listen(document, 'pointerdown', (event) => {
if (!menu.contains(event.target) && !this.$refs.trigger.contains(event.target)) {
this.returnFocus = false
}
})
},
control() {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
},
/**
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
* name only as rendered — this render's, which the popover's `position-anchor` matches — so
* it is read there.
*/
anchor() {
const trigger = this.$refs.trigger
const control = this.control()
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
const name = rendered.startsWith('--') ? rendered : this.anchored
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
if (!control || !name) {
return
}
const names = control.style
.getPropertyValue('anchor-name')
.split(',')
.map((each) => each.trim())
.filter((each) => each.startsWith('--'))
if (!names.includes(name)) {
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
}
this.anchored = name
if (rendered !== '') {
trigger.style.removeProperty('anchor-name')
}
},
/** Writes only what differs: the observer that calls this watches these same attributes. */
label() {
const control = this.control()
if (!control) {
return
}
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
for (const [name, value] of Object.entries(attributes)) {
if (control.getAttribute(name) !== value) {
control.setAttribute(name, value)
}
}
},
isOpen() {
return this.$refs.menu.matches(':popover-open')
},
open(focus = 'first') {
this.label()
this.anchor()
if (!this.isOpen()) {
this.$refs.menu.showPopover()
this.returnFocus = true
}
this.control()?.setAttribute('aria-expanded', 'true')
this.focusItem(focus)
},
close() {
if (this.isOpen()) {
this.$refs.menu.hidePopover()
}
this.control()?.setAttribute('aria-expanded', 'false')
},
toggle(focus = 'first') {
if (this.isOpen()) {
this.close()
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
this.open(focus)
}
},
items() {
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true')
},
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
focusItem(which) {
const items = this.items()
this.reach(which === 'last' ? items.at(-1) : items[0])
},
reach(item) {
item?.focus()
item?.scrollIntoView({ block: 'nearest' })
},
navigate(event) {
const items = this.items()
const current = items.indexOf(document.activeElement)
const move = (index) => {
event.preventDefault()
this.reach(items[(index + items.length) % items.length])
}
switch (event.key) {
case 'ArrowDown':
return move(current + 1)
case 'ArrowUp':
return move(current < 0 ? items.length - 1 : current - 1)
case 'Home':
return move(0)
case 'End':
return move(items.length - 1)
case 'Escape':
this.returnFocus = true
return
case 'Tab':
this.returnFocus = false
this.close()
return
}
// Typeahead: a printable letter moves to the next item whose label starts with it.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const letter = event.key.toLowerCase()
const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)]
const match = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(letter))
if (match) {
event.preventDefault()
this.reach(match)
}
}
},
activate(event) {
const item = event.target.closest(ITEMS)
if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) {
return
}
this.close()
},
listen(target, type, handler) {
target.addEventListener(type, handler)
this.listeners.push(() => target.removeEventListener(type, handler))
},
destroy() {
this.listeners.forEach((remove) => remove())
},
}))
})