tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
<x-button> (label buttons, icon buttons and toggles in five sizes, with filled, tonal, outlined, elevated and text variants in any colour role), <x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>, <x-group> as a connected button group, <x-split-button>, <x-fab>, <x-fab-menu> and <x-loading>. Sizes, colours and shapes come from androidx Compose Material 3's tokens; the loading indicator ports its Morph into SVG + SMIL. Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS anchor positioning. Browser tests run in Chromium, Firefox and WebKit. The showcase fetches the icon names on demand: inlined, they tripped Pest's test server into HTTP 431s under Firefox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
173 lines
5.5 KiB
JavaScript
173 lines
5.5 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,
|
|
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.
|
|
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 && menu.contains(document.activeElement)) {
|
|
this.control()?.focus()
|
|
}
|
|
})
|
|
|
|
// 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())
|
|
},
|
|
}))
|
|
})
|