<x-menu-item> renders data-md-menu-item, data-md-description and its aria-* state, with data-md-keep-open renamed from data-keep-open; menu-item.css draws SegmentedMenuTokens' 48px row, 16px sides, 12px gaps, 4/12px corners, the selected/current colours and the shared --md-menu-item-ink icon-class outranks except when disabled (ACT-11/19/27/28); a submenu's popover shares its container colour with the menu it opens from and menu.js follows the rename (plan step 36, actions). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
773 lines
28 KiB
JavaScript
773 lines
28 KiB
JavaScript
/**
|
|
* `materialMenu`: the behaviour of `<x-menu>` — WAI-ARIA's menu button pattern on a popover.
|
|
* `materialSubmenu`: the same pattern one level in, for `<x-menu-item submenu>`.
|
|
* `materialMenuSheet`: the `open` an `<x-menu sheet-at-compact>` 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.
|
|
*
|
|
* `<x-menu filter>` 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.
|
|
*
|
|
* `<x-menu sheet-at-compact>` has a second copy of its list in a modal `<x-bottom-sheet>`, which
|
|
* the server teleports to <body>, 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: [],
|
|
|
|
// `<x-menu sheet-at-compact>` 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-md-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)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* `<x-menu filter>`: 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-md-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)
|
|
|
|
/**
|
|
* `<x-bottom-sheet>` 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)
|
|
},
|
|
}
|
|
})
|
|
})
|