Merge branch 'worktree-agent-a6b15d4e17dad5970'
This commit is contained in:
+15
-4
@@ -179,14 +179,25 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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)].filter((item) => item.getAttribute('aria-disabled') !== 'true')
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)]
|
||||
},
|
||||
|
||||
/** 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()
|
||||
|
||||
;(which === 'last' ? items.at(-1) : items[0])?.focus()
|
||||
this.reach(which === 'last' ? items.at(-1) : items[0])
|
||||
},
|
||||
|
||||
reach(item) {
|
||||
item?.focus()
|
||||
item?.scrollIntoView({ block: 'nearest' })
|
||||
},
|
||||
|
||||
navigate(event) {
|
||||
@@ -195,7 +206,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
const move = (index) => {
|
||||
event.preventDefault()
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
this.reach(items[(index + items.length) % items.length])
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
@@ -226,7 +237,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (match) {
|
||||
event.preventDefault()
|
||||
match.focus()
|
||||
this.reach(match)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover,
|
||||
* at once on keyboard focus, and it stays while the pointer moves onto the bubble to reach its
|
||||
* actions. Persistent: a press on the trigger opens it as a light-dismiss popover.
|
||||
*
|
||||
* The trigger is pointed at the bubble with `aria-describedby`, so the explanation — the whole
|
||||
* point of a rich tooltip — is read out with the control rather than never at all; a persistent
|
||||
* one also says `aria-haspopup="dialog"` and whether it is open. The server cannot write any of
|
||||
* it: the trigger comes from a slot, and only script knows which element in it takes the focus.
|
||||
* A Livewire morph strips attributes the server did not render and gives the bubble a new id, so
|
||||
* they are written again whenever the trigger is reached.
|
||||
*/
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_GRACE_MS = 200
|
||||
@@ -17,6 +24,28 @@ document.addEventListener('alpine:init', () => {
|
||||
const wrapper = this.$el
|
||||
const open = () => bubble.matches(':popover-open')
|
||||
|
||||
const control = () => [...wrapper.querySelectorAll('button, a[href], [tabindex]')].find((element) => !bubble.contains(element))
|
||||
|
||||
const describe = () => {
|
||||
const trigger = control()
|
||||
|
||||
if (!trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
trigger.setAttribute('aria-describedby', bubble.id)
|
||||
|
||||
if (persistent) {
|
||||
trigger.setAttribute('aria-haspopup', 'dialog')
|
||||
trigger.setAttribute('aria-expanded', String(open()))
|
||||
}
|
||||
}
|
||||
|
||||
describe()
|
||||
bubble.addEventListener('toggle', describe)
|
||||
wrapper.addEventListener('focusin', describe)
|
||||
wrapper.addEventListener('pointerenter', describe)
|
||||
|
||||
if (persistent) {
|
||||
wrapper.addEventListener('click', (event) => {
|
||||
if (bubble.contains(event.target)) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
||||
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
||||
* action) and is replaced by the next.
|
||||
* action) and is replaced by the next. A snackbar carrying an action has no timeout at all —
|
||||
* M3's accessibility page forbids one — unless the caller writes a timeout out.
|
||||
*
|
||||
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
|
||||
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||
@@ -34,13 +35,27 @@ document.addEventListener('alpine:init', () => {
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
startedAt: 0,
|
||||
escape: 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) => {
|
||||
if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) {
|
||||
this.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.escape)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('keydown', this.escape)
|
||||
document.documentElement.style.removeProperty('--material-snackbar-height')
|
||||
|
||||
if (host === this) {
|
||||
host = null
|
||||
}
|
||||
@@ -52,12 +67,17 @@ document.addEventListener('alpine:init', () => {
|
||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||
const sticky = toast.sticky === true
|
||||
|
||||
// M3 forbids a snackbar with an action from auto-dismissing: it has to wait for the
|
||||
// person to read it and reach it. A timeout written out still wins, for a caller who
|
||||
// means it; a missing one no longer falls back to the default.
|
||||
const untimed = sticky || toast.timeout === 0 || toast.timeout === null || (toast.action != null && toast.timeout === undefined)
|
||||
|
||||
const entry = {
|
||||
id: ++sequence,
|
||||
type: toast.type ?? null,
|
||||
title: toast.title ?? '',
|
||||
description: toast.description ?? null,
|
||||
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
timeout: untimed ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
action: toast.action ?? null,
|
||||
sticky,
|
||||
}
|
||||
@@ -87,6 +107,7 @@ document.addEventListener('alpine:init', () => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
this.current = this.queue.shift() ?? this.sticky
|
||||
this.measure()
|
||||
|
||||
if (this.current?.timeout) {
|
||||
this.remaining = this.current.timeout
|
||||
@@ -94,6 +115,24 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Publishes the snackbar's height on <html> as `--material-snackbar-height`, and clears it
|
||||
* when nothing shows. M3 puts a snackbar above a FAB, never in front of or behind one, and
|
||||
* the FAB is somewhere else in the page: a variable is the only thing the two share.
|
||||
*/
|
||||
measure() {
|
||||
this.$nextTick(() => {
|
||||
const snackbar = this.$el.querySelector('[data-toast]')
|
||||
const root = document.documentElement.style
|
||||
|
||||
if (snackbar) {
|
||||
root.setProperty('--material-snackbar-height', `${Math.round(snackbar.getBoundingClientRect().height)}px`)
|
||||
} else {
|
||||
root.removeProperty('--material-snackbar-height')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
pause() {
|
||||
if (!this.current?.timeout || !this.timer) {
|
||||
return
|
||||
@@ -136,9 +175,5 @@ document.addEventListener('alpine:init', () => {
|
||||
window.dispatchEvent(new CustomEvent(action.event))
|
||||
}
|
||||
},
|
||||
|
||||
icon(type) {
|
||||
return ['success', 'error', 'warning', 'info'].includes(type)
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+37
-15
@@ -3,10 +3,15 @@
|
||||
*
|
||||
* The trigger is the popover's parent element (the button, or the standalone wrapper). Hover
|
||||
* counts only for a pointer that can hover, after a short delay so a pointer passing over a
|
||||
* toolbar does not flash every label; keyboard focus shows it at once. A press, leaving, blur
|
||||
* and Escape hide it.
|
||||
* toolbar does not flash every label; keyboard focus shows it at once. A press and Escape hide it
|
||||
* at once; leaving and blur let it stand for M3's 1.5s, so the words can still be read while the
|
||||
* pointer moves on. Only one tooltip is up at a time: showing one takes the last one down, which
|
||||
* the browser will not do for us because these are `popover="manual"`.
|
||||
*/
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_DELAY_MS = 1500
|
||||
|
||||
let visible = null
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialTooltip', () => ({
|
||||
@@ -22,25 +27,37 @@ document.addEventListener('alpine:init', () => {
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (tip.isConnected && !open()) {
|
||||
tip.showPopover()
|
||||
if (!tip.isConnected || open()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (visible && visible !== tip && visible.matches(':popover-open')) {
|
||||
visible.hidePopover()
|
||||
}
|
||||
|
||||
visible = tip
|
||||
tip.showPopover()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = (delay = 0) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
|
||||
if (visible === tip) {
|
||||
visible = null
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(this.timer)
|
||||
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
}
|
||||
|
||||
this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
this.listen(trigger, 'pointerleave', hide)
|
||||
this.listen(trigger, 'pointerdown', hide)
|
||||
this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(trigger, 'pointerdown', () => hide())
|
||||
this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
|
||||
this.listen(trigger, 'focusout', hide)
|
||||
this.listen(trigger, 'focusout', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
|
||||
},
|
||||
|
||||
@@ -51,6 +68,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
|
||||
if (visible === this.$el) {
|
||||
visible = null
|
||||
}
|
||||
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user