@zag-js/tooltip
Version:
Core logic for the tooltip widget implemented as a state machine
417 lines (416 loc) • 13.4 kB
JavaScript
// src/tooltip.machine.ts
import { createGuards, createMachine } from "@zag-js/core";
import { addDomEvent, getOverflowAncestors, isComposingEvent } from "@zag-js/dom-query";
import { trackFocusVisible } from "@zag-js/focus-visible";
import { getPlacement } from "@zag-js/popper";
import { ensureProps } from "@zag-js/utils";
import * as dom from "./tooltip.dom.mjs";
import { store } from "./tooltip.store.mjs";
var { and, not } = createGuards();
var machine = createMachine({
initialState: ({ prop }) => {
const open = prop("open") || prop("defaultOpen");
return open ? "open" : "closed";
},
props({ props }) {
ensureProps(props, ["id"]);
const closeOnClick = props.closeOnClick ?? true;
const closeOnPointerDown = props.closeOnPointerDown ?? closeOnClick;
return {
openDelay: 400,
closeDelay: 150,
closeOnEscape: true,
interactive: false,
closeOnScroll: true,
disabled: false,
...props,
closeOnPointerDown,
closeOnClick,
positioning: {
placement: "bottom",
...props.positioning
}
};
},
effects: ["trackFocusVisible", "trackStore"],
context: ({ bindable, prop, scope }) => ({
currentPlacement: bindable(() => ({ defaultValue: void 0 })),
hasPointerMoveOpened: bindable(() => ({ defaultValue: null })),
triggerValue: bindable(() => ({
defaultValue: prop("defaultTriggerValue") ?? null,
value: prop("triggerValue"),
onChange(value) {
const onTriggerValueChange = prop("onTriggerValueChange");
if (!onTriggerValueChange) return;
const triggerElement = dom.getActiveTriggerEl(scope, value);
onTriggerValueChange({ value, triggerElement });
}
}))
}),
watch({ track, action, prop }) {
track([() => prop("disabled")], () => {
action(["closeIfDisabled"]);
});
track([() => prop("open")], () => {
action(["toggleVisibility"]);
});
track([() => prop("triggerValue")], () => {
action(["repositionImmediate"]);
});
},
on: {
"triggerValue.set": {
actions: ["setTriggerValue", "repositionImmediate"]
}
},
states: {
closed: {
entry: ["clearGlobalId"],
on: {
"controlled.open": {
target: "open"
},
open: [
{
guard: "isOpenControlled",
actions: ["setTriggerValue", "invokeOnOpen"]
},
{
target: "open",
actions: ["setTriggerValue", "invokeOnOpen"]
}
],
"pointer.leave": {
actions: ["clearPointerMoveOpened"]
},
"pointer.move": [
{
guard: and("noVisibleTooltip", not("hasPointerMoveOpened")),
target: "opening",
actions: ["setTriggerValue"]
},
{
guard: not("hasPointerMoveOpened"),
target: "open",
actions: ["setPointerMoveOpened", "invokeOnOpen", "setTriggerValue"]
}
]
}
},
opening: {
effects: ["trackScroll", "trackPointerlockChange", "waitForOpenDelay"],
on: {
"after.openDelay": [
{
guard: "isOpenControlled",
actions: ["setPointerMoveOpened", "invokeOnOpen"]
},
{
target: "open",
actions: ["setPointerMoveOpened", "invokeOnOpen"]
}
],
"controlled.open": {
target: "open"
},
"controlled.close": {
target: "closed"
},
open: [
{
guard: "isOpenControlled",
actions: ["setTriggerValue", "invokeOnOpen"]
},
{
target: "open",
actions: ["setTriggerValue", "invokeOnOpen"]
}
],
"pointer.leave": [
{
guard: "isOpenControlled",
// We trigger toggleVisibility manually since the `ctx.open` has not changed yet (at this point)
actions: ["clearPointerMoveOpened", "invokeOnClose", "toggleVisibility"]
},
{
target: "closed",
actions: ["clearPointerMoveOpened", "invokeOnClose"]
}
],
close: [
{
guard: "isOpenControlled",
// We trigger toggleVisibility manually since the `ctx.open` has not changed yet (at this point)
actions: ["invokeOnClose", "toggleVisibility"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
]
}
},
open: {
effects: ["trackEscapeKey", "trackScroll", "trackPointerlockChange", "trackPositioning"],
entry: ["setGlobalId"],
on: {
"controlled.close": {
target: "closed"
},
close: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
"pointer.leave": [
{
guard: "isVisible",
target: "closing",
actions: ["clearPointerMoveOpened"]
},
// == group ==
{
guard: "isOpenControlled",
actions: ["clearPointerMoveOpened", "invokeOnClose"]
},
{
target: "closed",
actions: ["clearPointerMoveOpened", "invokeOnClose"]
}
],
"content.pointer.leave": {
guard: "isInteractive",
target: "closing"
},
"positioning.set": {
actions: ["reposition"]
},
"triggerValue.set": {
// Transition to closing (which cleans up trackPositioning) then immediately back to open
// This re-creates the positioning effect with the new trigger
target: "closing",
actions: ["setTriggerValue", "immediateReopen"]
}
}
},
closing: {
effects: ["trackPositioning", "waitForCloseDelay"],
on: {
"after.closeDelay": [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
"controlled.close": {
target: "closed"
},
"controlled.open": {
target: "open"
},
close: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closed",
actions: ["invokeOnClose"]
}
],
"pointer.move": [
{
guard: "isOpenControlled",
// We trigger toggleVisibility manually since the `ctx.open` has not changed yet (at this point)
actions: ["setPointerMoveOpened", "setTriggerValue", "invokeOnOpen", "toggleVisibility"]
},
{
target: "open",
actions: ["setPointerMoveOpened", "setTriggerValue", "invokeOnOpen"]
}
],
"triggerValue.set": {
target: "open",
actions: ["setTriggerValue", "repositionImmediate"]
},
reopen: {
target: "open"
},
"content.pointer.move": {
guard: "isInteractive",
target: "open"
},
"positioning.set": {
actions: ["reposition"]
}
}
}
},
implementations: {
guards: {
noVisibleTooltip: () => store.get("id") === null,
isVisible: ({ prop }) => prop("id") === store.get("id"),
isInteractive: ({ prop }) => !!prop("interactive"),
hasPointerMoveOpened: ({ context }) => !!context.get("hasPointerMoveOpened"),
isOpenControlled: ({ prop }) => prop("open") !== void 0
},
actions: {
setGlobalId: ({ prop }) => {
const prevId = store.get("id");
const isInstant = prevId !== null && prevId !== prop("id");
store.update({ id: prop("id"), prevId: isInstant ? prevId : null, instant: isInstant });
},
clearGlobalId: ({ prop }) => {
if (prop("id") === store.get("id")) {
store.update({ id: null, prevId: null, instant: false });
}
},
invokeOnOpen: ({ prop }) => {
prop("onOpenChange")?.({ open: true });
},
invokeOnClose: ({ prop }) => {
prop("onOpenChange")?.({ open: false });
},
closeIfDisabled: ({ prop, send }) => {
if (!prop("disabled")) return;
send({ type: "close", src: "disabled.change" });
},
reposition: ({ context, event, prop, scope }) => {
if (event.type !== "positioning.set") return;
const getPositionerEl2 = () => dom.getPositionerEl(scope);
const getTriggerEl = () => dom.getActiveTriggerEl(scope, context.get("triggerValue"));
getPlacement(getTriggerEl, getPositionerEl2, {
...prop("positioning"),
...event.options,
listeners: false,
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
repositionImmediate: ({ context, event, prop, scope }) => {
const triggerValue = event.value ?? context.get("triggerValue");
const getPositionerEl2 = () => dom.getPositionerEl(scope);
const getTriggerEl = () => dom.getActiveTriggerEl(scope, triggerValue);
return getPlacement(getTriggerEl, getPositionerEl2, {
...prop("positioning"),
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
toggleVisibility: ({ prop, event, send }) => {
queueMicrotask(() => {
send({
type: prop("open") ? "controlled.open" : "controlled.close",
previousEvent: event
});
});
},
setPointerMoveOpened: ({ context, event }) => {
const triggerId = event.triggerId ?? event.previousEvent?.triggerId;
context.set("hasPointerMoveOpened", triggerId ?? null);
},
clearPointerMoveOpened: ({ context }) => {
context.set("hasPointerMoveOpened", null);
},
setTriggerValue: ({ context, event }) => {
if (event.value === void 0) return;
context.set("triggerValue", event.value);
},
immediateReopen: ({ send }) => {
queueMicrotask(() => {
send({ type: "reopen" });
});
}
},
effects: {
trackFocusVisible: ({ scope }) => {
return trackFocusVisible({ root: scope.getRootNode?.() });
},
trackPositioning: ({ context, prop, scope }) => {
if (!context.get("currentPlacement")) {
context.set("currentPlacement", prop("positioning").placement);
}
const getPositionerEl2 = () => dom.getPositionerEl(scope);
const getTriggerEl = () => dom.getActiveTriggerEl(scope, context.get("triggerValue"));
return getPlacement(getTriggerEl, getPositionerEl2, {
...prop("positioning"),
defer: true,
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
trackPointerlockChange: ({ send, scope }) => {
const doc = scope.getDoc();
const onChange = () => send({ type: "close", src: "pointerlock:change" });
return addDomEvent(doc, "pointerlockchange", onChange, false);
},
trackScroll: ({ send, prop, scope, context }) => {
if (!prop("closeOnScroll")) return;
const triggerValue = context.get("triggerValue");
const triggerEl = dom.getActiveTriggerEl(scope, triggerValue);
if (!triggerEl) return;
const overflowParents = getOverflowAncestors(triggerEl);
const cleanups = overflowParents.map((overflowParent) => {
const onScroll = () => {
send({ type: "close", src: "scroll" });
};
return addDomEvent(overflowParent, "scroll", onScroll, {
passive: true,
capture: true
});
});
return () => {
cleanups.forEach((fn) => fn?.());
};
},
trackStore: ({ prop, send }) => {
let cleanup;
queueMicrotask(() => {
cleanup = store.subscribe(() => {
if (store.get("id") !== prop("id")) {
send({ type: "close", src: "id.change" });
}
});
});
return () => cleanup?.();
},
trackEscapeKey: ({ send, prop }) => {
if (!prop("closeOnEscape")) return;
const onKeyDown = (event) => {
if (isComposingEvent(event)) return;
if (event.key !== "Escape") return;
event.stopPropagation();
send({ type: "close", src: "keydown.escape" });
};
return addDomEvent(document, "keydown", onKeyDown, true);
},
waitForOpenDelay: ({ send, prop, event }) => {
const id = setTimeout(() => {
send({ type: "after.openDelay", previousEvent: event });
}, prop("openDelay"));
return () => clearTimeout(id);
},
waitForCloseDelay: ({ send, prop, event }) => {
const id = setTimeout(() => {
send({ type: "after.closeDelay", previousEvent: event });
}, prop("closeDelay"));
return () => clearTimeout(id);
}
}
}
});
export {
machine
};