Files
livewire-material/resources/js/menu.js
T
Andreas Reinhold / reiniandClaude Opus 5 cd64f4f371
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
Add buttons, menus and the rest of M3 Expressive's actions
<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
2026-09-13 06:09:28 +02:00

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())
},
}))
})