/** * `materialMenu`: the behaviour of `` — 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()) }, })) })