tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
The showcase is now an overview and one page per section behind a grouped navigation rail, moved between with wire:navigate, instead of one long page under a scrolling top bar. The switch exposed a WebKit bug in the menu: inside a focusable region, WebKit moves focus out of the closing popover before its toggle event, so Escape no longer returned focus to the trigger. The menu now reads it on beforetoggle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
183 lines
6.1 KiB
JavaScript
183 lines
6.1 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.
|
|
*/
|
|
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.
|
|
const REOPEN_GUARD_MS = 250
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
window.Alpine.data('materialMenu', () => ({
|
|
closedAt: -Infinity,
|
|
returnFocus: true,
|
|
focusWasInside: false,
|
|
listeners: [],
|
|
|
|
init() {
|
|
const menu = this.$refs.menu
|
|
|
|
this.label()
|
|
|
|
// 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)
|
|
})
|
|
|
|
this.listen(menu, 'toggle', (event) => {
|
|
const opened = event.newState === 'open'
|
|
|
|
this.control()?.setAttribute('aria-expanded', String(opened))
|
|
|
|
if (opened) {
|
|
return
|
|
}
|
|
|
|
this.closedAt = performance.now()
|
|
|
|
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]')
|
|
},
|
|
|
|
label() {
|
|
const control = this.control()
|
|
|
|
if (!control) {
|
|
return
|
|
}
|
|
|
|
control.setAttribute('aria-haspopup', 'menu')
|
|
control.setAttribute('aria-controls', this.$refs.menu.id)
|
|
control.setAttribute('aria-expanded', String(this.isOpen()))
|
|
},
|
|
|
|
isOpen() {
|
|
return this.$refs.menu.matches(':popover-open')
|
|
},
|
|
|
|
open(focus = 'first') {
|
|
this.label()
|
|
|
|
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())
|
|
},
|
|
}))
|
|
})
|