<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id that is new with every render, and Livewire's morph matches an element without a wire:key by its id. So every render of the component around them swapped the popover for a closed copy: an open menu closed, its listeners stayed behind on the old element (Escape or a press outside then left aria-expanded="true" on the trigger and focus unreturned), a keep-open item's wire:click closed its menu when the response came, and a rich tooltip went on showing the detached bubble, so it never opened again. <x-carousel>'s row had the same kind of id: after a render it scrolled without its listeners, and its items stopped re-masking. The popover, the bubble and the row now carry a wire:key, so the morph patches them in place and changes the id and anchor name together with the trigger's, as it already did for everything else. The key goes through an attribute bag: Livewire compiles a wire:key written in a template into the key of the loop iteration around it, which would have given every child component after the menu in a row the same key. A morph also removes the menu button's ARIA attributes, which only script writes. menu.js now writes them again after every morph, so the button of a menu that stays open, and a FAB menu's close look, still say it is open, and aria-controls names the popover's new id. A second press on an open menu's button opened it again, render or not: the popover closes on the press, and the guard against the click that follows was timed from the toggle event, which is queued and arrives after that click. It is timed from beforetoggle now. Browser tests with Livewire probes in Chromium, Firefox and WebKit, and a render test for the keys and the keys of the child components after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
254 lines
9.3 KiB
JavaScript
254 lines
9.3 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')
|
|
},
|
|
|
|
focusItem(which) {
|
|
const items = this.items()
|
|
|
|
;(which === 'last' ? items.at(-1) : items[0])?.focus()
|
|
},
|
|
|
|
navigate(event) {
|
|
const items = this.items()
|
|
const current = items.indexOf(document.activeElement)
|
|
|
|
const move = (index) => {
|
|
event.preventDefault()
|
|
items[(index + items.length) % items.length]?.focus()
|
|
}
|
|
|
|
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()
|
|
match.focus()
|
|
}
|
|
}
|
|
},
|
|
|
|
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())
|
|
},
|
|
}))
|
|
})
|