bits-ui
Version:
The headless components for Svelte.
682 lines (681 loc) • 23.7 kB
JavaScript
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,
};
}