Files
Andreas Reinhold / reiniandClaude Opus 5 684d60efc8 Close a persistent rich tooltip on a second press of its trigger
The bubble is a popover="auto" and the trigger is outside it, so the press
on the trigger light-dismissed the open bubble and the click that followed
opened it again: a second press never closed it, in any engine. Found by
the new exit test. A close the browser made within 250ms is now taken for
that press, as menu.js's REOPEN_GUARD_MS does, timed from beforetoggle
because toggle is queued past the click; the morph test waits past the
guard before pressing again, as the menu tests do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:13 +02:00

108 lines
4.5 KiB
JavaScript

/**
* `materialRichTooltip`: shows an `<x-rich-tooltip>`.
*
* Transient (the default): like a plain tooltip (tooltip.js) — after a short hover on a pointer
* that can hover, at once on keyboard focus, and standing for 1.5s once the pointer or the focus
* leaves, which M3 gives plain and rich tooltips alike (docs/reference/m3/
* components-actions-communication-containment.md § Tooltips, ACT-25). The bubble is inside the
* wrapper, so moving onto it to reach its actions, by pointer or by Tab, is no leave at all; Escape
* hides it at once. 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 = 1500
// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the press on the
// trigger light-dismisses the open bubble, and the click that follows would open it again, so a
// second press never closed it. A close the browser made this recently is taken as that press, as
// menu.js's REOPEN_GUARD_MS does; `beforetoggle` times it, because `toggle` is queued past the click.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialRichTooltip', (persistent = false) => ({
timer: null,
init() {
const bubble = this.$refs.bubble
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) {
let dismissedAt = -Infinity
let closingExplicitly = false
bubble.addEventListener('beforetoggle', (event) => {
if (event.newState === 'closed' && !closingExplicitly) {
dismissedAt = performance.now()
}
closingExplicitly = false
})
wrapper.addEventListener('click', (event) => {
if (bubble.contains(event.target)) {
return
}
if (open()) {
closingExplicitly = true
bubble.hidePopover()
} else if (performance.now() - dismissedAt > REOPEN_GUARD_MS) {
bubble.showPopover()
}
})
return
}
const show = (delay) => {
clearTimeout(this.timer)
this.timer = setTimeout(() => !open() && bubble.showPopover(), delay)
}
const hide = (delay = 0) => {
clearTimeout(this.timer)
this.timer = setTimeout(() => open() && bubble.hidePopover(), delay)
}
wrapper.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
wrapper.addEventListener('pointerleave', () => hide(LEAVE_GRACE_MS))
wrapper.addEventListener('focusin', (event) => event.target.matches(':focus-visible') && show(0))
wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && hide(LEAVE_GRACE_MS))
document.addEventListener('keydown', (event) => event.key === 'Escape' && hide())
},
destroy() {
clearTimeout(this.timer)
},
}))
})