Merge branch 'worktree-agent-a0d44b9ad5ca7814d'
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* `materialFab`: `<x-fab collapse-on-scroll>` — M3's extended FAB that collapses to a FAB while the
|
||||
* page scrolls down and extends again on the way back up, or once the page is at the top.
|
||||
*
|
||||
* Only the flag lives here. The morph itself is CSS (resources/css/components/actions.css), which
|
||||
* is how it stays on the spatial spring and how reduced motion makes it instant without a second
|
||||
* path through this file.
|
||||
*
|
||||
* It watches the window, which is what a FAB pinned to the corner of the page scrolls against. A
|
||||
* FAB inside a scrolling pane of its own is not this.
|
||||
*/
|
||||
|
||||
// Scrolling is noisy — a wheel's own wobble, a rubber-band bounce at either end — and a FAB that
|
||||
// flipped on every pixel would never be still. A move has to be worth this much to count as a
|
||||
// direction, and one worth less is kept and added to the next.
|
||||
const STEP_PX = 8
|
||||
|
||||
// Within this much of the top the FAB is extended whichever way the page was last going: M3
|
||||
// re-expands it "at the bottom of the view", which on a web page is where the page begins.
|
||||
const TOP_PX = 24
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialFab', () => ({
|
||||
collapsed: false,
|
||||
lastY: 0,
|
||||
ticking: false,
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
this.lastY = Math.max(window.scrollY, 0)
|
||||
this.listen(window, 'scroll', () => this.queue(), { passive: true })
|
||||
},
|
||||
|
||||
/** One reading a frame: `scroll` fires far more often than anything can be drawn. */
|
||||
queue() {
|
||||
if (this.ticking) {
|
||||
return
|
||||
}
|
||||
|
||||
this.ticking = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.ticking = false
|
||||
this.measure()
|
||||
})
|
||||
},
|
||||
|
||||
measure() {
|
||||
const y = Math.max(window.scrollY, 0)
|
||||
const moved = y - this.lastY
|
||||
|
||||
if (y <= TOP_PX) {
|
||||
this.collapsed = false
|
||||
} else if (moved > STEP_PX) {
|
||||
this.collapsed = true
|
||||
} else if (moved < -STEP_PX) {
|
||||
this.collapsed = false
|
||||
}
|
||||
|
||||
if (Math.abs(moved) > STEP_PX || y <= TOP_PX) {
|
||||
this.lastY = y
|
||||
}
|
||||
},
|
||||
|
||||
listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler, options))
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import './theme.js'
|
||||
import './figure.js'
|
||||
import './tooltip.js'
|
||||
import './menu.js'
|
||||
import './fab.js'
|
||||
import './snackbar.js'
|
||||
import './rich-tooltip.js'
|
||||
import './progress.js'
|
||||
|
||||
+416
-198
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* `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>`.
|
||||
*
|
||||
* 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,
|
||||
@@ -12,6 +13,19 @@
|
||||
* 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 popover 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: `$refs.filter` is what turns them on.
|
||||
*/
|
||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||
|
||||
@@ -21,244 +35,448 @@ const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradi
|
||||
// click.
|
||||
const REOPEN_GUARD_MS = 250
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
anchored: null,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
// 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
|
||||
|
||||
init() {
|
||||
const menu = this.$refs.menu
|
||||
const menu = () => ({
|
||||
closedAt: -Infinity,
|
||||
anchored: null,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
|
||||
this.label()
|
||||
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()
|
||||
})
|
||||
|
||||
// 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())
|
||||
|
||||
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)
|
||||
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
|
||||
if (event.newState === 'closed') {
|
||||
this.closedAt = performance.now()
|
||||
}
|
||||
})
|
||||
this.listen(menu, 'toggle', (event) => {
|
||||
const opened = event.newState === 'open'
|
||||
|
||||
this.listen(menu, 'toggle', (event) => {
|
||||
const opened = event.newState === 'open'
|
||||
this.control()?.setAttribute('aria-expanded', String(opened))
|
||||
|
||||
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) {
|
||||
if (opened) {
|
||||
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(', '))
|
||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
|
||||
this.anchored = name
|
||||
this.focusWasInside = false
|
||||
|
||||
if (rendered !== '') {
|
||||
trigger.style.removeProperty('anchor-name')
|
||||
// A filtered menu opens on the whole list again: the query belonged to that visit.
|
||||
if (this.$refs.filter) {
|
||||
this.$refs.filter.value = ''
|
||||
this.refine()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Writes only what differs: the observer that calls this watches these same attributes. */
|
||||
label() {
|
||||
const control = this.control()
|
||||
|
||||
if (!control) {
|
||||
return
|
||||
// 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
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
|
||||
control() {
|
||||
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
||||
},
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (control.getAttribute(name) !== value) {
|
||||
control.setAttribute(name, value)
|
||||
}
|
||||
/**
|
||||
* 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')
|
||||
},
|
||||
isOpen() {
|
||||
return this.$refs.menu.matches(':popover-open')
|
||||
},
|
||||
|
||||
open(focus = 'first') {
|
||||
this.label()
|
||||
this.anchor()
|
||||
open(focus = 'first') {
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.$refs.menu.showPopover()
|
||||
this.returnFocus = true
|
||||
}
|
||||
if (!this.isOpen()) {
|
||||
this.$refs.menu.showPopover()
|
||||
this.returnFocus = true
|
||||
}
|
||||
|
||||
this.control()?.setAttribute('aria-expanded', '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.$refs.filter) {
|
||||
this.$refs.filter.focus()
|
||||
this.$refs.filter.select()
|
||||
this.mark(this.visible()[0] ?? null)
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.isOpen()) {
|
||||
this.$refs.menu.hidePopover()
|
||||
}
|
||||
close() {
|
||||
if (this.isOpen()) {
|
||||
this.$refs.menu.hidePopover()
|
||||
}
|
||||
|
||||
this.control()?.setAttribute('aria-expanded', 'false')
|
||||
},
|
||||
this.control()?.setAttribute('aria-expanded', 'false')
|
||||
},
|
||||
|
||||
toggle(focus = 'first') {
|
||||
if (this.isOpen()) {
|
||||
toggle(focus = 'first') {
|
||||
if (this.isOpen()) {
|
||||
this.close()
|
||||
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
|
||||
this.open(focus)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Every item of *this* menu, 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 popover of its own nested in this one, and its rows are its: the
|
||||
* nearest popover around a row says which menu the arrow keys should find it in.
|
||||
*/
|
||||
items() {
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover]') === this.$refs.menu)
|
||||
},
|
||||
|
||||
/** 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()
|
||||
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
|
||||
this.open(focus)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Every item, 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.
|
||||
*/
|
||||
items() {
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)]
|
||||
},
|
||||
return
|
||||
}
|
||||
|
||||
/** 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()
|
||||
// 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))
|
||||
|
||||
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) => {
|
||||
if (match) {
|
||||
event.preventDefault()
|
||||
this.reach(items[(index + items.length) % items.length])
|
||||
this.reach(match)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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':
|
||||
/**
|
||||
* `<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() {
|
||||
const query = this.$refs.filter.value.trim().toLowerCase()
|
||||
|
||||
for (const item of this.items()) {
|
||||
item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
for (const rule of this.$refs.menu.querySelectorAll('[role="separator"]')) {
|
||||
rule.hidden = query !== ''
|
||||
}
|
||||
|
||||
for (const group of this.$refs.menu.querySelectorAll('[role="group"]')) {
|
||||
group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden)
|
||||
}
|
||||
|
||||
const left = this.visible()
|
||||
|
||||
this.$refs.empty.hidden = left.length > 0
|
||||
this.mark(left[0] ?? null)
|
||||
},
|
||||
|
||||
/** The rows a query has left, in the order they are read. */
|
||||
visible() {
|
||||
return this.items().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.
|
||||
*/
|
||||
mark(item) {
|
||||
for (const each of this.items()) {
|
||||
if (each !== item) {
|
||||
each.removeAttribute('data-active')
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
this.$refs.filter.removeAttribute('aria-activedescendant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
item.id ||= `${this.$refs.menu.id}-item-${this.items().indexOf(item)}`
|
||||
item.setAttribute('data-active', '')
|
||||
this.$refs.filter.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; 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)
|
||||
|
||||
window.Alpine.data('materialSubmenu', () => {
|
||||
const base = menu()
|
||||
|
||||
return {
|
||||
...base,
|
||||
hoverTimer: null,
|
||||
|
||||
/** The item is the menu button, and the server named it: nothing has to be moved. */
|
||||
control() {
|
||||
return this.$refs.trigger
|
||||
},
|
||||
|
||||
anchor() {},
|
||||
|
||||
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.
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
this.returnFocus = true
|
||||
this.close()
|
||||
this.control()?.focus()
|
||||
|
||||
return
|
||||
case 'Tab':
|
||||
}
|
||||
|
||||
base.navigate.call(this, event)
|
||||
},
|
||||
|
||||
/** Hover opens a submenu only where hovering means something, and never on a first tap. */
|
||||
fine(event) {
|
||||
return (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)
|
||||
},
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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())
|
||||
},
|
||||
}))
|
||||
destroy() {
|
||||
clearTimeout(this.hoverTimer)
|
||||
base.destroy.call(this)
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||
* takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky
|
||||
* toast replaces it. Dismissing it, or pressing its action, lets it go.
|
||||
*
|
||||
* Two keys are watched on the whole document: Escape dismisses the snackbar while the focus is in
|
||||
* it, and Alt+G moves the focus to a snackbar that carries an action from wherever the page had it
|
||||
* — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus
|
||||
* on its own and a keyboard would otherwise have no way to reach the action.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 4000
|
||||
|
||||
@@ -35,25 +40,40 @@ document.addEventListener('alpine:init', () => {
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
startedAt: 0,
|
||||
escape: null,
|
||||
keys: null,
|
||||
|
||||
init() {
|
||||
host = this
|
||||
pending.splice(0).forEach((detail) => this.add(detail))
|
||||
|
||||
// M3: Esc dismisses the focused snackbar. Only the focused one — a key pressed
|
||||
// anywhere else on the page belongs to whatever has the focus there.
|
||||
this.escape = (event) => {
|
||||
this.keys = (event) => {
|
||||
// M3: Esc dismisses the focused snackbar. Only the focused one — a key pressed
|
||||
// anywhere else on the page belongs to whatever has the focus there.
|
||||
if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) {
|
||||
this.dismiss()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// M3 asks the web for a documented shortcut that moves the focus to a snackbar
|
||||
// carrying an action, and suggests Alt+G: a snackbar never takes the focus by
|
||||
// itself, so without one the keyboard cannot reach the action at all. `event.code`
|
||||
// rather than `event.key`, which Alt rewrites to another character on some layouts.
|
||||
if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyG') {
|
||||
const action = this.$el.querySelector('[data-toast-action]')
|
||||
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
action.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.escape)
|
||||
document.addEventListener('keydown', this.keys)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('keydown', this.escape)
|
||||
document.removeEventListener('keydown', this.keys)
|
||||
document.documentElement.style.removeProperty('--material-snackbar-height')
|
||||
|
||||
if (host === this) {
|
||||
|
||||
Reference in New Issue
Block a user