UNPKG

bits-ui

Version:

The headless components for Svelte.

682 lines (681 loc) 23.7 kB
import { onMountEffect, attachRef, DOMContext, simpleBox, boxWith, } from "svelte-toolbelt"; import { on } from "svelte/events"; import { Context, watch } from "runed"; import { isElement, isFocusVisible } from "../../internal/is.js"; import { createBitsAttrs, boolToEmptyStrOrUndef, getDataTransitionAttrs, } from "../../internal/attrs.js"; import { TimeoutFn } from "../../internal/timeout-fn.js"; import { SafePolygon } from "../../internal/safe-polygon.svelte.js"; import { PresenceManager } from "../../internal/presence-manager.svelte.js"; export const tooltipAttrs = createBitsAttrs({ component: "tooltip", parts: ["content", "trigger"], }); const TooltipProviderContext = new Context("Tooltip.Provider"); const TooltipRootContext = new Context("Tooltip.Root"); class TooltipTriggerRegistryState { triggers = $state(new Map()); activeTriggerId = $state(null); activeTriggerNode = $derived.by(() => { const activeTriggerId = this.activeTriggerId; if (activeTriggerId === null) return null; return this.triggers.get(activeTriggerId)?.node ?? null; }); activePayload = $derived.by(() => { const activeTriggerId = this.activeTriggerId; if (activeTriggerId === null) return null; return this.triggers.get(activeTriggerId)?.payload ?? null; }); register = (record) => { const next = new Map(this.triggers); next.set(record.id, record); this.triggers = next; this.#coerceActiveTrigger(); }; update = (record) => { const next = new Map(this.triggers); next.set(record.id, record); this.triggers = next; this.#coerceActiveTrigger(); }; unregister = (id) => { if (!this.triggers.has(id)) return; const next = new Map(this.triggers); next.delete(id); this.triggers = next; if (this.activeTriggerId === id) { this.activeTriggerId = null; } }; setActiveTrigger = (id) => { if (id === null) { this.activeTriggerId = null; return; } if (!this.triggers.has(id)) { this.activeTriggerId = null; return; } this.activeTriggerId = id; }; get = (id) => { return this.triggers.get(id); }; has = (id) => { return this.triggers.has(id); }; getFirstTriggerId = () => { const firstEntry = this.triggers.entries().next(); if (firstEntry.done) return null; return firstEntry.value[0]; }; #coerceActiveTrigger = () => { const activeTriggerId = this.activeTriggerId; if (activeTriggerId === null) return; if (!this.triggers.has(activeTriggerId)) { this.activeTriggerId = null; } }; } class TooltipTetherState { registry = new TooltipTriggerRegistryState(); root = $state(null); } // oxlint-disable-next-line no-unused-vars export class TooltipTether { #state = new TooltipTetherState(); get state() { return this.#state; } open(triggerId) { if (!this.#state.registry.has(triggerId)) { return; } this.#state.registry.setActiveTrigger(triggerId); this.#state.root?.setActiveTrigger(triggerId); this.#state.root?.handleOpen(); } close() { this.#state.root?.handleClose(); } get isOpen() { return this.#state.root?.opts.open.current ?? false; } } export function createTooltipTether() { return new TooltipTether(); } export class TooltipProviderState { static create(opts) { return TooltipProviderContext.set(new TooltipProviderState(opts)); } opts; isOpenDelayed = $state(true); isPointerInTransit = simpleBox(false); #timerFn; #openTooltip = $state(null); constructor(opts) { this.opts = opts; this.#timerFn = new TimeoutFn(() => { this.isOpenDelayed = true; }, this.opts.skipDelayDuration.current); onMountEffect(() => on(window, "scroll", (e) => { const activeTooltip = this.#openTooltip; if (!activeTooltip) return; const triggerNode = activeTooltip.triggerNode; if (!triggerNode) return; const target = e.target; if (!(target instanceof Element || target instanceof Document)) return; if (target.contains(triggerNode)) { activeTooltip.handleClose(); } })); } #startTimer = () => { const skipDuration = this.opts.skipDelayDuration.current; if (skipDuration === 0) { // no grace period — reset immediately so next trigger waits the full delay this.isOpenDelayed = true; return; } else { this.#timerFn.start(); } }; #clearTimer = () => { this.#timerFn.stop(); }; onOpen = (tooltip) => { if (this.#openTooltip && this.#openTooltip !== tooltip) { this.#openTooltip.handleClose(); } this.#clearTimer(); this.isOpenDelayed = false; this.#openTooltip = tooltip; }; onClose = (tooltip) => { if (this.#openTooltip === tooltip) { this.#openTooltip = null; this.#startTimer(); } }; isTooltipOpen = (tooltip) => { return this.#openTooltip === tooltip; }; } export class TooltipRootState { static create(opts) { return TooltipRootContext.set(new TooltipRootState(opts, TooltipProviderContext.get())); } opts; provider; delayDuration = $derived.by(() => this.opts.delayDuration.current ?? this.provider.opts.delayDuration.current); disableHoverableContent = $derived.by(() => this.opts.disableHoverableContent.current ?? this.provider.opts.disableHoverableContent.current); disableCloseOnTriggerClick = $derived.by(() => this.opts.disableCloseOnTriggerClick.current ?? this.provider.opts.disableCloseOnTriggerClick.current); disabled = $derived.by(() => this.opts.disabled.current ?? this.provider.opts.disabled.current); ignoreNonKeyboardFocus = $derived.by(() => this.opts.ignoreNonKeyboardFocus.current ?? this.provider.opts.ignoreNonKeyboardFocus.current); registry; tether; contentNode = $state(null); contentPresence; #wasOpenDelayed = $state(false); #timerFn; stateAttr = $derived.by(() => { if (!this.opts.open.current) return "closed"; return this.#wasOpenDelayed ? "delayed-open" : "instant-open"; }); constructor(opts, provider) { this.opts = opts; this.provider = provider; this.tether = opts.tether.current?.state ?? null; this.registry = this.tether?.registry ?? new TooltipTriggerRegistryState(); this.#timerFn = new TimeoutFn(() => { this.#wasOpenDelayed = true; this.opts.open.current = true; }, this.delayDuration ?? 0); if (this.tether) { this.tether.root = this; onMountEffect(() => { return () => { if (this.tether?.root === this) { this.tether.root = null; } }; }); } this.contentPresence = new PresenceManager({ open: this.opts.open, ref: boxWith(() => this.contentNode), onComplete: () => { this.opts.onOpenChangeComplete.current(this.opts.open.current); }, }); watch(() => this.delayDuration, () => { if (this.delayDuration === undefined) return; this.#timerFn = new TimeoutFn(() => { this.#wasOpenDelayed = true; this.opts.open.current = true; }, this.delayDuration); }); watch(() => this.opts.open.current, (isOpen) => { if (isOpen) { this.ensureActiveTrigger(); this.provider.onOpen(this); } else { this.provider.onClose(this); } }, { lazy: true }); watch(() => this.opts.triggerId.current, (triggerId) => { if (triggerId === this.registry.activeTriggerId) return; this.registry.setActiveTrigger(triggerId); }); watch(() => this.registry.activeTriggerId, (activeTriggerId) => { if (this.opts.triggerId.current === activeTriggerId) return; this.opts.triggerId.current = activeTriggerId; }); } handleOpen = () => { this.#timerFn.stop(); this.#wasOpenDelayed = false; this.ensureActiveTrigger(); this.opts.open.current = true; }; handleClose = () => { this.#timerFn.stop(); this.opts.open.current = false; }; #handleDelayedOpen = () => { this.#timerFn.stop(); const shouldSkipDelay = !this.provider.isOpenDelayed; const delayDuration = this.delayDuration ?? 0; // if no delay needed (either skip delay active or delay is 0), open immediately if (shouldSkipDelay || delayDuration === 0) { this.#wasOpenDelayed = false; this.opts.open.current = true; } else { // use timer for actual delays this.#timerFn.start(); } }; onTriggerEnter = (triggerId) => { this.setActiveTrigger(triggerId); this.#handleDelayedOpen(); }; onTriggerLeave = () => { if (this.disableHoverableContent) { this.handleClose(); } else { this.#timerFn.stop(); } }; ensureActiveTrigger = () => { if (this.registry.activeTriggerId !== null && this.registry.has(this.registry.activeTriggerId)) { return; } if (this.opts.triggerId.current !== null && this.registry.has(this.opts.triggerId.current)) { this.registry.setActiveTrigger(this.opts.triggerId.current); return; } const firstTriggerId = this.registry.getFirstTriggerId(); this.registry.setActiveTrigger(firstTriggerId); }; setActiveTrigger = (triggerId) => { this.registry.setActiveTrigger(triggerId); }; registerTrigger = (trigger) => { this.registry.register(trigger); if (trigger.disabled && this.registry.activeTriggerId === trigger.id && this.opts.open.current) { this.handleClose(); } }; updateTrigger = (trigger) => { this.registry.update(trigger); if (trigger.disabled && this.registry.activeTriggerId === trigger.id && this.opts.open.current) { this.handleClose(); } }; unregisterTrigger = (id) => { const isActive = this.registry.activeTriggerId === id; this.registry.unregister(id); if (isActive && this.opts.open.current) { this.handleClose(); } }; isActiveTrigger = (triggerId) => { return this.registry.activeTriggerId === triggerId; }; get triggerNode() { return this.registry.activeTriggerNode; } get activePayload() { return this.registry.activePayload; } get activeTriggerId() { return this.registry.activeTriggerId; } } export class TooltipTriggerState { static create(opts) { if (opts.tether.current) { return new TooltipTriggerState(opts, null, opts.tether.current.state); } return new TooltipTriggerState(opts, TooltipRootContext.get(), null); } opts; root; tether; attachment; #isPointerDown = simpleBox(false); #hasPointerMoveOpened = $state(false); domContext; #transitCheckTimeout = null; #mounted = false; #lastRegisteredId = null; constructor(opts, root, tether) { this.opts = opts; this.root = root; this.tether = tether; this.domContext = new DOMContext(opts.ref); this.attachment = attachRef(this.opts.ref, (v) => this.#register(v)); watch(() => this.opts.id.current, () => { this.#register(this.opts.ref.current); }); watch(() => this.opts.payload.current, () => { this.#register(this.opts.ref.current); }); watch(() => this.opts.disabled.current, () => { this.#register(this.opts.ref.current); }); onMountEffect(() => { this.#mounted = true; this.#register(this.opts.ref.current); return () => { const root = this.#getRoot(); const id = this.#lastRegisteredId; if (id) { if (this.tether) { this.tether.registry.unregister(id); } else { root?.unregisterTrigger(id); } } this.#lastRegisteredId = null; this.#mounted = false; }; }); } #getRoot = () => { return this.tether?.root ?? this.root; }; #isDisabled = () => { const root = this.#getRoot(); return this.opts.disabled.current || Boolean(root?.disabled); }; #register = (node) => { if (!this.#mounted) return; const id = this.opts.id.current; const payload = this.opts.payload.current; const disabled = this.opts.disabled.current; if (this.#lastRegisteredId && this.#lastRegisteredId !== id) { const root = this.#getRoot(); if (this.tether) { this.tether.registry.unregister(this.#lastRegisteredId); } else { root?.unregisterTrigger(this.#lastRegisteredId); } } const triggerRecord = { id, node, payload, disabled, }; const root = this.#getRoot(); if (this.tether) { if (this.tether.registry.has(id)) { this.tether.registry.update(triggerRecord); } else { this.tether.registry.register(triggerRecord); } if (disabled && this.tether.registry.activeTriggerId === id && root?.opts.open.current) { root.handleClose(); } } else { if (root?.registry.has(id)) { root.updateTrigger(triggerRecord); } else { root?.registerTrigger(triggerRecord); } } this.#lastRegisteredId = id; }; #clearTransitCheck = () => { if (this.#transitCheckTimeout !== null) { clearTimeout(this.#transitCheckTimeout); this.#transitCheckTimeout = null; } }; handlePointerUp = () => { this.#isPointerDown.current = false; }; #onpointerup = () => { if (this.#isDisabled()) return; this.#isPointerDown.current = false; }; #onpointerdown = () => { if (this.#isDisabled()) return; this.#isPointerDown.current = true; this.domContext.getDocument().addEventListener("pointerup", () => { this.handlePointerUp(); }, { once: true }); }; #onpointerenter = (e) => { const root = this.#getRoot(); if (!root) return; if (this.#isDisabled()) { if (root.opts.open.current) { root.handleClose(); } return; } if (e.pointerType === "touch") return; // if in transit, wait briefly to see if user is actually heading to old content or staying here if (root.provider.isPointerInTransit.current) { this.#clearTransitCheck(); this.#transitCheckTimeout = window.setTimeout(() => { // if still in transit after delay, user is likely staying on this trigger if (root.provider.isPointerInTransit.current) { root.provider.isPointerInTransit.current = false; root.onTriggerEnter(this.opts.id.current); this.#hasPointerMoveOpened = true; } }, 250); return; } root.onTriggerEnter(this.opts.id.current); this.#hasPointerMoveOpened = true; }; #onpointermove = (e) => { const root = this.#getRoot(); if (!root) return; if (this.#isDisabled()) { if (root.opts.open.current) { root.handleClose(); } return; } if (e.pointerType === "touch") return; if (this.#hasPointerMoveOpened) return; // moving within trigger means we're definitely not in transit anymore this.#clearTransitCheck(); root.provider.isPointerInTransit.current = false; root.onTriggerEnter(this.opts.id.current); this.#hasPointerMoveOpened = true; }; #onpointerleave = (e) => { const root = this.#getRoot(); if (!root) return; if (this.#isDisabled()) return; this.#clearTransitCheck(); if (!root.isActiveTrigger(this.opts.id.current)) { this.#hasPointerMoveOpened = false; return; } const relatedTarget = e.relatedTarget; // when moving to a sibling trigger and skip delay is active, don't close — // the sibling's enter handler will switch the active trigger instantly. // if skipDelayDuration is 0 there's no grace period, so close now and let // the sibling wait through the full delay (and re-animate). if (isElement(relatedTarget)) { for (const record of root.registry.triggers.values()) { if (record.node !== relatedTarget) continue; if (root.provider.opts.skipDelayDuration.current > 0) { this.#hasPointerMoveOpened = false; return; } root.handleClose(); this.#hasPointerMoveOpened = false; return; } } root.onTriggerLeave(); this.#hasPointerMoveOpened = false; }; #onfocus = (e) => { const root = this.#getRoot(); if (!root) return; if (this.#isPointerDown.current) return; if (this.#isDisabled()) { if (root.opts.open.current) { root.handleClose(); } return; } if (root.ignoreNonKeyboardFocus && !isFocusVisible(e.currentTarget)) return; root.setActiveTrigger(this.opts.id.current); root.handleOpen(); }; #onblur = () => { const root = this.#getRoot(); if (!root || this.#isDisabled()) return; root.handleClose(); }; #onclick = () => { const root = this.#getRoot(); if (!root || root.disableCloseOnTriggerClick || this.#isDisabled()) return; root.handleClose(); }; props = $derived.by(() => { const root = this.#getRoot(); const isOpenForTrigger = Boolean(root?.opts.open.current && root.isActiveTrigger(this.opts.id.current)); const isDisabled = this.#isDisabled(); return { id: this.opts.id.current, "aria-describedby": isOpenForTrigger ? root?.contentNode?.id : undefined, "data-state": isOpenForTrigger ? root?.stateAttr : "closed", "data-disabled": boolToEmptyStrOrUndef(isDisabled), "data-delay-duration": `${root?.delayDuration ?? 0}`, [tooltipAttrs.trigger]: "", tabindex: isDisabled ? undefined : this.opts.tabindex.current, disabled: this.opts.disabled.current, onpointerup: this.#onpointerup, onpointerdown: this.#onpointerdown, onpointerenter: this.#onpointerenter, onpointermove: this.#onpointermove, onpointerleave: this.#onpointerleave, onfocus: this.#onfocus, onblur: this.#onblur, onclick: this.#onclick, ...this.attachment, }; }); } export class TooltipContentState { static create(opts) { return new TooltipContentState(opts, TooltipRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v)); new SafePolygon({ triggerNode: () => this.root.triggerNode, contentNode: () => this.root.contentNode, enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent, transitIntentTimeout: 180, ignoredTargets: () => { // only skip closing for sibling triggers when there's a skip-delay grace period; // with skipDelayDuration=0 the close+reopen is intentional (full delay + re-animation) if (this.root.provider.opts.skipDelayDuration.current === 0) return []; const nodes = []; const activeTriggerNode = this.root.triggerNode; for (const record of this.root.registry.triggers.values()) { if (record.node && record.node !== activeTriggerNode) { nodes.push(record.node); } } return nodes; }, onPointerExit: () => { if (this.root.provider.isTooltipOpen(this.root)) { this.root.handleClose(); } }, }); } onInteractOutside = (e) => { if (isElement(e.target) && this.root.triggerNode?.contains(e.target) && this.root.disableCloseOnTriggerClick) { e.preventDefault(); return; } this.opts.onInteractOutside.current(e); if (e.defaultPrevented) return; this.root.handleClose(); }; onEscapeKeydown = (e) => { this.opts.onEscapeKeydown.current?.(e); if (e.defaultPrevented) return; this.root.handleClose(); }; onOpenAutoFocus = (e) => { e.preventDefault(); }; onCloseAutoFocus = (e) => { e.preventDefault(); }; get shouldRender() { return this.root.contentPresence.shouldRender; } snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); props = $derived.by(() => ({ id: this.opts.id.current, "data-state": this.root.stateAttr, "data-disabled": boolToEmptyStrOrUndef(this.root.disabled), ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), style: { outline: "none", }, [tooltipAttrs.content]: "", ...this.attachment, })); popperProps = { onInteractOutside: this.onInteractOutside, onEscapeKeydown: this.onEscapeKeydown, onOpenAutoFocus: this.onOpenAutoFocus, onCloseAutoFocus: this.onCloseAutoFocus, }; }