/** * `materialRichTooltip`: shows an ``. * * 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) }, })) })