/** * `materialMenu`: the behaviour of `` — WAI-ARIA's menu button pattern on a popover. * `materialSubmenu`: the same pattern one level in, for ``. * `materialMenuSheet`: the `open` an `` lends its bottom sheet. * * 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. * * A submenu's popover sits inside its parent's, so the browser keeps the two open together — a * nested `popover="auto"` light-dismisses only down to its DOM ancestor — and closes the inner one * when the outer goes. Its trigger *is* the item, which the server names itself, so the two pieces * that exist only for a wrapper (moving the anchor name, and finding the button inside the trigger * slot) are overridden away. `items()` stops at the list it belongs to, so the arrow keys in a * menu never walk into an open submenu's rows, nor a submenu's back out into its parent's. * * `` adds a text field at the top of the same list. The field keeps the focus while * the arrow keys move a highlight — APG's combobox, which is what a text field inside a popup * asks for — so `refine()`, `visible()`, `mark()` and `search()` work on `aria-activedescendant` * and the `hidden` attribute rather than on the roving focus the rest of this file uses. They do * nothing at all in a menu with no field: `field()` finding one is what turns them on. * * `` has a second copy of its list in a modal ``, which * the server teleports to , and below `medium` (`upTo('medium')`, 600px) `open()` shows that * instead of the popover (M3 § Menus → Behaviour: "at compact breakpoints, consider swapping a * menu for a bottom sheet"). `shown` remembers which of the two was opened last, and `list()` * is that one's list, so the keyboard, the filter and `activate()` work on whichever the person is * looking at. The sheet's own ways out — its scrim, a swipe, Escape, its handle — only set `open` * false, and `sheetToggled()` follows them with the trigger's `aria-expanded` and its focus. A * submenu in that copy is never shown as a popover: `inline` makes it open in place under its * item, which menu.css draws from the item's `aria-expanded`. */ import { upTo } from './breakpoints.js' 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 // A submenu opens after the pointer has rested on its item for a moment, and closes a moment after // it leaves the pair — long enough to cross the gap between them. APG's menu pattern asks for both // delays. A coarse pointer has no hover to speak of, so there it opens on the press instead. const HOVER_OPEN_MS = 180 const HOVER_CLOSE_MS = 320 const menu = () => ({ closedAt: -Infinity, anchored: null, returnFocus: true, focusWasInside: false, listeners: [], // `` only: the compact window's media query, whether the sheet is // open, which presentation was opened last ('menu' or 'sheet'), and what had the focus as the // sheet opened. compact: null, sheetOpen: false, shown: 'menu', focusedBefore: null, 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 filtered menu opens on the whole list again: the query belonged to that visit. this.clear(menu) }) // 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 } }) if (this.$el.hasAttribute('data-sheet-at-compact')) { this.compact = upTo('medium') // A window resized across 600px while the menu is open would leave it in the // presentation the window no longer asks for; it closes instead, and the trigger says // what it opens now. this.listen(this.compact, 'change', () => { this.close() this.label() }) this.$watch('sheetOpen', (opened) => this.sheetToggled(opened)) // The sheet is teleported after this runs, so what reads it waits a tick: a morph gives // it a new id with the popover's, and the trigger has to point at the one it opens. this.$nextTick(() => { const dialog = this.sheetDialog() if (dialog) { observer.observe(dialog, { attributes: true, attributeFilter: ['id'] }) } this.label() }) } }, 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. On a * compact window a `sheet-at-compact` trigger opens a dialog, and says so. */ label() { const control = this.control() if (!control) { return } // Named even on a wide window, so no two sheets on a page share the rendered id. const dialog = this.sheetDialog() const sheet = this.sheeted() ? dialog : null const attributes = { 'aria-haspopup': sheet ? 'dialog' : 'menu', 'aria-controls': sheet ? sheet.id : 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.sheetOpen || this.$refs.menu.matches(':popover-open') }, /** Whether opening now means the sheet: a `sheet-at-compact` menu on a compact window. */ sheeted() { return Boolean(this.compact?.matches && this.$refs.sheetHost) }, /** * The sheet's `role="dialog"`, which the trigger controls while the window is compact. Every * menu's sheet is rendered with the same id, which a morph matches it by (menu.blade.php), so * this names each one after its popover and keeps the rendered id as its `wire:key`: the key * matches the next render's id, and a render that puts the rendered id back is named again — * the id observer calls here through `label()`. */ sheetDialog() { const dialog = this.$refs.sheetHost?.querySelector('[role="dialog"]') ?? null const id = `${this.$refs.menu.id}-sheet` if (dialog && dialog.id !== id) { dialog.setAttribute('wire:key', 'material-menu-sheet') dialog.id = id } return dialog }, /** The list the person is looking at: the popover, or the sheet's copy of it. */ list() { return this.shown === 'sheet' ? this.$refs.sheetHost.querySelector('[data-menu-sheet]') : this.$refs.menu }, /** The filter field at the top of a list, if the menu has one; a submenu never does. */ field(root = this.list()) { return root?.querySelector(':scope > [data-menu-filter] input') ?? null }, open(focus = 'first') { if (this.sheeted()) { this.openSheet(focus) return } this.shown = 'menu' this.label() this.anchor() if (!this.isOpen()) { this.$refs.menu.showPopover() this.returnFocus = true } this.control()?.setAttribute('aria-expanded', 'true') // A filtering menu hands the focus to its field, not to a row: the field is where the // typing goes, and `aria-activedescendant` says which row the arrows are on meanwhile. if (this.field()) { this.lookUp() return } // `false` opens without taking the focus: a submenu the pointer rested on belongs to the // pointer, and taking the focus out from under the keyboard would be the wrong answer. if (focus !== false) { this.focusItem(focus) } }, /** * The sheet shows a frame or two after `open` turns true, once its transition has begun, and * Alpine holds `$nextTick` until then. Its focus trap starts on a timer of its own and keeps a * focus already inside it, so the item (or the field) M3 asks to be focused first wins over * the drag handle the trap would otherwise pick. */ openSheet(focus) { this.focusedBefore = document.activeElement this.shown = 'sheet' this.sheetOpen = true this.control()?.setAttribute('aria-expanded', 'true') this.label() this.$nextTick(() => { if (this.field()) { this.lookUp() return } this.focusItem(focus) }) }, /** Focus in the field, its text selected, and the first row it leaves highlighted. */ lookUp() { const field = this.field() field.focus() field.select() this.mark(this.visible()[0] ?? null) }, close() { if (this.sheetOpen) { this.sheetOpen = false } if (this.$refs.menu.matches(':popover-open')) { 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) } }, /** * Follows the sheet opening or closing, however it was done. Its focus trap hands the focus * back on a timer of its own, started as it lets go, to whatever had it when the trap began: * an item of the sheet now sliding away, or what had it before the sheet opened — in WebKit, * where a press does not focus a button, a region around the trigger. The trigger takes it on * a timer started inside a timer, which runs after the trap's, unless something else (a * dialog an item opened) has it by then. */ sheetToggled(opened) { this.control()?.setAttribute('aria-expanded', String(opened)) if (opened) { return } const list = this.$refs.sheetHost?.querySelector('[data-menu-sheet]') if (list) { this.clear(list) // A submenu opened in place is closed with the sheet, as a nested popover closes with // its menu. for (const submenu of list.querySelectorAll('[data-submenu]')) { window.Alpine.$data(submenu)?.close?.() } } setTimeout(() => setTimeout(() => { const active = document.activeElement if (active === null || active === document.body || active === this.focusedBefore || this.$refs.sheetHost?.contains(active)) { this.control()?.focus() } this.focusedBefore = null }), ) }, /** * Every item of *this* list, disabled ones included: M3 keeps a disabled item focusable * ("disabled items can still receive focus, just aren't selectable") so a person reading the * menu with the keyboard learns that it exists. activate() is where the refusal lives. * * An open submenu is a list of its own nested in this one, and its rows are its: the nearest * popover or sheet list around a row says which menu the arrow keys should find it in. A * submenu keeps its `popover` attribute in the sheet too, where it opens in place. */ items(root = this.list()) { return [...root.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover], [data-menu-sheet]') === root) }, /** The menu scrolls when it is too long for the window, so the item taken has to be shown. */ focusItem(which) { const items = this.items() this.reach(which === 'last' ? items.at(-1) : items[0]) }, reach(item) { item?.focus() item?.scrollIntoView({ block: 'nearest' }) }, navigate(event) { const items = this.items() const current = items.indexOf(document.activeElement) const move = (index) => { event.preventDefault() this.reach(items[(index + items.length) % items.length]) } 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() this.reach(match) } } }, /** * The sheet's keyboard, heard on its host before anything inside: the sheet's scope, where its * items live, has a `close()` and an `activate()` of its own. The field gets the combobox keys * and the list's own items the menu's; an item of a submenu opened in place is left to that * submenu. Tab leaves a menu (APG) and so the sheet around it, which its focus trap would * otherwise keep circling; Escape reaches the sheet itself, which closes. */ sheetKeydown(event) { const field = this.field() const item = event.target.closest(ITEMS) if (event.key === 'Tab' && (item !== null || event.target === field)) { event.preventDefault() this.close() return } if (field !== null && event.target === field) { this.search(event) } else if (item !== null && this.items().includes(item)) { this.navigate(event) } }, /** Empties a list's filter field and shows every row again. */ clear(root) { const field = this.field(root) if (field) { field.value = '' this.refine(root) } }, /** * ``: M3's menu as a filtering surface. The rows are already rendered, so this * only hides the ones the query leaves out — with the `hidden` attribute, which menu.css turns * into `display: none` over the row's own `display: flex`. A divider means nothing between two * filtered clusters, and a group whose every row has gone is a heading over nothing. */ refine(root = this.list()) { const field = this.field(root) if (!field) { return } const query = field.value.trim().toLowerCase() for (const item of this.items(root)) { item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query) } for (const rule of root.querySelectorAll('[role="separator"]')) { rule.hidden = query !== '' } for (const group of root.querySelectorAll('[role="group"]')) { group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden) } const left = this.visible(root) root.querySelector('[data-menu-empty]').hidden = left.length > 0 this.mark(left[0] ?? null, root) }, /** The rows a query has left, in the order they are read. */ visible(root = this.list()) { return this.items(root).filter((item) => !item.hidden && item.closest('[hidden]') === null) }, /** * Moves the highlight the arrow keys carry while the focus stays in the field. The row needs * an id for `aria-activedescendant` to name it, and gets one if the caller wrote none — from * the list's own id, which the popover and the sheet do not share. */ mark(item, root = this.list()) { const field = this.field(root) for (const each of this.items(root)) { if (each !== item) { each.removeAttribute('data-active') } } if (!item) { field?.removeAttribute('aria-activedescendant') return } item.id ||= `${root.id}-item-${this.items(root).indexOf(item)}` item.setAttribute('data-active', '') field?.setAttribute('aria-activedescendant', item.id) if (this.isOpen()) { item.scrollIntoView({ block: 'nearest' }) } }, /** The APG combobox keyboard, on the field: the list moves under it and Enter takes a row. */ search(event) { const left = this.visible() const current = left.findIndex((item) => item.hasAttribute('data-active')) const move = (index) => { event.preventDefault() if (left.length > 0) { this.mark(left[(index + left.length) % left.length]) } } switch (event.key) { case 'ArrowDown': return move(current + 1) case 'ArrowUp': return move(current < 0 ? left.length - 1 : current - 1) case 'Home': return move(0) case 'End': return move(left.length - 1) case 'Enter': event.preventDefault() if (left[current] && left[current].getAttribute('aria-disabled') !== 'true') { left[current].click() } return case 'Escape': // The browser's own light dismiss closes the popover, and the sheet's Escape the // sheet; this only says where the focus goes after it. this.returnFocus = true return case 'Tab': this.returnFocus = false this.close() } }, 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()) }, }) document.addEventListener('alpine:init', () => { window.Alpine.data('materialMenu', menu) /** * `` reads and writes `open` from the scope around it, and in a menu's scope * `open` is a method. This scope sits between the two and passes the sheet's `open` through to * the menu's `sheetOpen`: a getter and setter pair, which Alpine calls with the scope that * asked, so `this` reaches the menu from inside the sheet as well. */ window.Alpine.data('materialMenuSheet', () => ({ get open() { return this.sheetOpen }, set open(value) { this.sheetOpen = Boolean(value) }, })) window.Alpine.data('materialSubmenu', () => { const base = menu() return { ...base, hoverTimer: null, inline: false, expanded: false, /** In the sheet of a `sheet-at-compact` menu the submenu opens in place, under its item. */ init() { this.inline = this.$el.closest('[data-menu-sheet]') !== null base.init.call(this) }, /** The item is the menu button, and the server named it: nothing has to be moved. */ control() { return this.$refs.trigger }, anchor() {}, isOpen() { return this.inline ? this.expanded : base.isOpen.call(this) }, /** * The sheet's copy of a submenu is rendered with the popover copy's id; it takes one of * its own, and takes it again after a morph puts the rendered one back. */ label() { const list = this.$refs.menu if (this.inline && !list.id.endsWith('-sheet')) { list.id = `${list.id}-sheet` } base.label.call(this) }, /** In place, the item's `aria-expanded` is what shows the list (menu.css). */ open(focus = 'first') { if (!this.inline) { base.open.call(this, focus) return } this.expanded = true this.label() if (focus !== false) { this.focusItem(focus) } }, close() { if (!this.inline) { base.close.call(this) return } this.expanded = false this.control()?.setAttribute('aria-expanded', 'false') }, navigate(event) { // APG: Left closes a submenu and puts the focus back on the item that opened it. // Escape does the same through the browser's own light dismiss, which the `toggle` // listener follows with the focus — or, opened in place in a sheet, where nothing // light-dismisses it, here, and no further: the sheet stays open. if (event.key === 'ArrowLeft' || (this.inline && event.key === 'Escape')) { event.preventDefault() this.returnFocus = true this.close() this.control()?.focus() return } base.navigate.call(this, event) }, /** * Hover opens a submenu only where hovering means something, and never on a first tap, * nor in a sheet, where the list would jump open under the pointer. */ fine(event) { return !this.inline && (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches }, hover(event) { if (!this.fine(event)) { return } clearTimeout(this.hoverTimer) this.hoverTimer = setTimeout(() => this.open(false), HOVER_OPEN_MS) }, /** * The submenu is a DOM child of the item's wrapper, so crossing into it is not a leave; * a pointer that really left closes it, unless the keyboard has since taken it over. */ unhover() { clearTimeout(this.hoverTimer) if (!this.fine()) { return } this.hoverTimer = setTimeout(() => { if (this.$el.contains(document.activeElement)) { return } this.returnFocus = false this.close() }, HOVER_CLOSE_MS) }, destroy() { clearTimeout(this.hoverTimer) base.destroy.call(this) }, } }) })