Files
livewire-material/resources/js/rich-tooltip.js
T
Andreas Reinhold / reiniandClaude Opus 5 b62c83f244 Show no tooltip on the control a sheet or dialog focuses as it opens
A modal side sheet, bottom sheet and the modal rail move the focus to
their first control as they open (x-trap), and `<x-modal>` does in
`showModal()`. On a page loaded with the sheet already open (a
`wire:model` set from `?workout=` in the URL) the browsers count that
focus as `:focus-visible`, and so they do for a sheet or dialog opened
from the keyboard, so tooltip.js showed the close button's "Close"
tooltip the moment the layer appeared, over its title, with no keyboard
use. Confirmed in Chrome, Firefox and Safari for the deep-linked sheet
and a full-screen dialog opened with Enter; a deep-linked `<x-modal>`'s
own focus was not counted as keyboard focus. M3 lets a tooltip appear
on hover or focus and says nothing that asks for one on a focus the
component moves itself.

layers.js gains `openingFocus(event)`: a focus is a modal layer's own
when the nearest open dialog, `aria-modal` panel or modal rail around
its target is entered from outside it, or from nowhere, and no Tab is
moving it (a Tab moves the focus while its keydown is handled, by the
browser or by focus-trap wrapping round the panel). tooltip.js and
rich-tooltip.js leave such a focus out; focus moved within the layer,
a Tab onto the control and a focus returned to it from a menu inside
still show the tooltip. The docblocks, the skill and UPGRADE.md say so.

A browser test loads a page with a modal sheet open at 393px, opens a
full-screen dialog and a dialog whose first control is a rich tooltip's
trigger from the keyboard, and checks that no tooltip is up on the
focused control, then that Tab wrapping round to the close button shows
its tooltip; it fails without the change in Chrome, Firefox and Safari,
each part on its own.

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

111 lines
4.7 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 (not the focus a sheet or dialog moves to it as it
* opens, layers.js), 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.
*/
import { openingFocus } from './layers.js'
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) => !openingFocus(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)
},
}))
})