UNPKG

bits-ui

Version:

The headless components for Svelte.

316 lines (315 loc) 10.9 kB
import { box, onMountEffect, attachRef, DOMContext, } from "svelte-toolbelt"; import { on } from "svelte/events"; import { Context, watch } from "runed"; import { isElement, isFocusVisible } from "../../internal/is.js"; import { createBitsAttrs, getDataDisabled } from "../../internal/attrs.js"; import { TimeoutFn } from "../../internal/timeout-fn.js"; import { GraceArea } from "../../internal/grace-area.svelte.js"; import { OpenChangeComplete } from "../../internal/open-change-complete.js"; export const tooltipAttrs = createBitsAttrs({ component: "tooltip", parts: ["content", "trigger"], }); const TooltipProviderContext = new Context("Tooltip.Provider"); const TooltipRootContext = new Context("Tooltip.Root"); export class TooltipProviderState { static create(opts) { return TooltipProviderContext.set(new TooltipProviderState(opts)); } opts; isOpenDelayed = $state(true); isPointerInTransit = box(false); #timerFn; #openTooltip = $state(null); constructor(opts) { this.opts = opts; this.#timerFn = new TimeoutFn(() => { this.isOpenDelayed = true; }, this.opts.skipDelayDuration.current, { immediate: false }); } #startTimer = () => { const skipDuration = this.opts.skipDelayDuration.current; if (skipDuration === 0) { 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); contentNode = $state(null); triggerNode = $state(null); #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.#timerFn = new TimeoutFn(() => { this.#wasOpenDelayed = true; this.opts.open.current = true; }, this.delayDuration ?? 0, { immediate: false }); new OpenChangeComplete({ open: this.opts.open, ref: box.with(() => 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, { immediate: false }); }); watch(() => this.opts.open.current, (isOpen) => { if (isOpen) { this.provider.onOpen(this); } else { this.provider.onClose(this); } }); } handleOpen = () => { this.#timerFn.stop(); this.#wasOpenDelayed = false; 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) { // set wasOpenDelayed based on whether we actually had a delay this.#wasOpenDelayed = delayDuration > 0 && shouldSkipDelay; this.opts.open.current = true; } else { // use timer for actual delays this.#timerFn.start(); } }; onTriggerEnter = () => { this.#handleDelayedOpen(); }; onTriggerLeave = () => { if (this.disableHoverableContent) { this.handleClose(); } else { this.#timerFn.stop(); } }; } export class TooltipTriggerState { static create(opts) { return new TooltipTriggerState(opts, TooltipRootContext.get()); } opts; root; attachment; #isPointerDown = box(false); #hasPointerMoveOpened = $state(false); #isDisabled = $derived.by(() => this.opts.disabled.current || this.root.disabled); domContext; constructor(opts, root) { this.opts = opts; this.root = root; this.domContext = new DOMContext(opts.ref); this.attachment = attachRef(this.opts.ref, (v) => (this.root.triggerNode = v)); } 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 }); }; #onpointermove = (e) => { if (this.#isDisabled) return; if (e.pointerType === "touch") return; if (this.#hasPointerMoveOpened) return; if (this.root.provider.isPointerInTransit.current) return; this.root.onTriggerEnter(); this.#hasPointerMoveOpened = true; }; #onpointerleave = () => { if (this.#isDisabled) return; this.root.onTriggerLeave(); this.#hasPointerMoveOpened = false; }; #onfocus = (e) => { if (this.#isPointerDown.current || this.#isDisabled) return; if (this.root.ignoreNonKeyboardFocus && !isFocusVisible(e.currentTarget)) return; this.root.handleOpen(); }; #onblur = () => { if (this.#isDisabled) return; this.root.handleClose(); }; #onclick = () => { if (this.root.disableCloseOnTriggerClick || this.#isDisabled) return; this.root.handleClose(); }; props = $derived.by(() => ({ id: this.opts.id.current, "aria-describedby": this.root.opts.open.current ? this.root.contentNode?.id : undefined, "data-state": this.root.stateAttr, "data-disabled": getDataDisabled(this.#isDisabled), "data-delay-duration": `${this.root.delayDuration}`, [tooltipAttrs.trigger]: "", tabindex: this.#isDisabled ? undefined : 0, disabled: this.opts.disabled.current, onpointerup: this.#onpointerup, onpointerdown: this.#onpointerdown, 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 GraceArea({ triggerNode: () => this.root.triggerNode, contentNode: () => this.root.contentNode, enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent, onPointerExit: () => { if (this.root.provider.isTooltipOpen(this.root)) { this.root.handleClose(); } }, setIsPointerInTransit: (value) => { this.root.provider.isPointerInTransit.current = value; }, transitTimeout: this.root.provider.opts.skipDelayDuration.current, }); onMountEffect(() => on(window, "scroll", (e) => { const target = e.target; if (!target) return; if (target.contains(this.root.triggerNode)) { 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(); }; snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); props = $derived.by(() => ({ id: this.opts.id.current, "data-state": this.root.stateAttr, "data-disabled": getDataDisabled(this.root.disabled), style: { pointerEvents: "auto", outline: "none", }, [tooltipAttrs.content]: "", ...this.attachment, })); popperProps = { onInteractOutside: this.onInteractOutside, onEscapeKeydown: this.onEscapeKeydown, onOpenAutoFocus: this.onOpenAutoFocus, onCloseAutoFocus: this.onCloseAutoFocus, }; }