Merge branch 'worktree-agent-a6b15d4e17dad5970'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:17:26 +02:00
33 changed files with 603 additions and 165 deletions
+15 -4
View File
@@ -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)
}
}
},
+29
View File
@@ -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)) {
+41 -6
View File
@@ -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
View File
@@ -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())
},
}))