UNPKG

@zag-js/tooltip

Version:

Core logic for the tooltip widget implemented as a state machine

521 lines (516 loc) • 15.9 kB
import { createAnatomy } from '@zag-js/anatomy'; import { addDomEvent, getOverflowAncestors, isComposingEvent, dataAttr, isLeftClick } from '@zag-js/dom-query'; import { trackFocusVisible, isFocusVisible } from '@zag-js/focus-visible'; import { getPlacement, getPlacementStyles } from '@zag-js/popper'; import { createStore, createSplitProps } from '@zag-js/utils'; import { createGuards, createMachine } from '@zag-js/core'; import { createProps } from '@zag-js/types'; // src/tooltip.anatomy.ts var anatomy = createAnatomy("tooltip").parts("trigger", "arrow", "arrowTip", "positioner", "content"); var parts = anatomy.build(); // src/tooltip.dom.ts var getTriggerId = (scope) => scope.ids?.trigger ?? `tooltip:${scope.id}:trigger`; var getContentId = (scope) => scope.ids?.content ?? `tooltip:${scope.id}:content`; var getArrowId = (scope) => scope.ids?.arrow ?? `tooltip:${scope.id}:arrow`; var getPositionerId = (scope) => scope.ids?.positioner ?? `tooltip:${scope.id}:popper`; var getTriggerEl = (scope) => scope.getById(getTriggerId(scope)); var getPositionerEl = (scope) => scope.getById(getPositionerId(scope)); var store = createStore({ id: null }); // src/tooltip.connect.ts function connect(service, normalize) { const { state, context, send, scope, prop, event: _event } = service; const id = prop("id"); const hasAriaLabel = !!prop("aria-label"); const open = state.matches("open", "closing"); const triggerId = getTriggerId(scope); const contentId = getContentId(scope); const disabled = prop("disabled"); const popperStyles = getPlacementStyles({ ...prop("positioning"), placement: context.get("currentPlacement") }); return { open, setOpen(nextOpen) { const open2 = state.matches("open", "closing"); if (open2 === nextOpen) return; send({ type: nextOpen ? "open" : "close" }); }, reposition(options = {}) { send({ type: "positioning.set", options }); }, getTriggerProps() { return normalize.button({ ...parts.trigger.attrs, id: triggerId, dir: prop("dir"), "data-expanded": dataAttr(open), "data-state": open ? "open" : "closed", "aria-describedby": open ? contentId : void 0, onClick(event) { if (event.defaultPrevented) return; if (disabled) return; if (!prop("closeOnClick")) return; send({ type: "close", src: "trigger.click" }); }, onFocus(event) { queueMicrotask(() => { if (event.defaultPrevented) return; if (disabled) return; if (_event.src === "trigger.pointerdown") return; if (!isFocusVisible()) return; send({ type: "open", src: "trigger.focus" }); }); }, onBlur(event) { if (event.defaultPrevented) return; if (disabled) return; if (id === store.get("id")) { send({ type: "close", src: "trigger.blur" }); } }, onPointerDown(event) { if (event.defaultPrevented) return; if (disabled) return; if (!isLeftClick(event)) return; if (!prop("closeOnPointerDown")) return; if (id === store.get("id")) { send({ type: "close", src: "trigger.pointerdown" }); } }, onPointerMove(event) { if (event.defaultPrevented) return; if (disabled) return; if (event.pointerType === "touch") return; send({ type: "pointer.move" }); }, onPointerOver(event) { if (event.defaultPrevented) return; if (disabled) return; if (event.pointerType === "touch") return; send({ type: "pointer.move" }); }, onPointerLeave() { if (disabled) return; send({ type: "pointer.leave" }); }, onPointerCancel() { if (disabled) return; send({ type: "pointer.leave" }); } }); }, getArrowProps() { return normalize.element({ id: getArrowId(scope), ...parts.arrow.attrs, dir: prop("dir"), style: popperStyles.arrow }); }, getArrowTipProps() { return normalize.element({ ...parts.arrowTip.attrs, dir: prop("dir"), style: popperStyles.arrowTip }); }, getPositionerProps() { return normalize.element({ id: getPositionerId(scope), ...parts.positioner.attrs, dir: prop("dir"), style: popperStyles.floating }); }, getContentProps() { return normalize.element({ ...parts.content.attrs, dir: prop("dir"), hidden: !open, "data-state": open ? "open" : "closed", role: hasAriaLabel ? void 0 : "tooltip", id: hasAriaLabel ? void 0 : contentId, "data-placement": context.get("currentPlacement"), onPointerEnter() { send({ type: "content.pointer.move" }); }, onPointerLeave() { send({ type: "content.pointer.leave" }); }, style: { pointerEvents: prop("interactive") ? "auto" : "none" } }); } }; } var { and, not } = createGuards(); var machine = createMachine({ initialState: ({ prop }) => { const open = prop("open") || prop("defaultOpen"); return open ? "open" : "closed"; }, props({ props: props2 }) { const closeOnClick = props2.closeOnClick ?? true; const closeOnPointerDown = props2.closeOnPointerDown ?? closeOnClick; return { id: "x", openDelay: 400, closeDelay: 150, closeOnEscape: true, interactive: false, closeOnScroll: true, disabled: false, ...props2, closeOnPointerDown, closeOnClick, positioning: { placement: "bottom", ...props2.positioning } }; }, effects: ["trackFocusVisible", "trackStore"], context: ({ bindable }) => ({ currentPlacement: bindable(() => ({ defaultValue: void 0 })), hasPointerMoveOpened: bindable(() => ({ defaultValue: false })) }), watch({ track, action, prop }) { track([() => prop("disabled")], () => { action(["closeIfDisabled"]); }); track([() => prop("open")], () => { action(["toggleVisibility"]); }); }, states: { closed: { entry: ["clearGlobalId"], on: { "controlled.open": { target: "open" }, open: [ { guard: "isOpenControlled", actions: ["invokeOnOpen"] }, { target: "open", actions: ["invokeOnOpen"] } ], "pointer.leave": { actions: ["clearPointerMoveOpened"] }, "pointer.move": [ { guard: and("noVisibleTooltip", not("hasPointerMoveOpened")), target: "opening" }, { guard: not("hasPointerMoveOpened"), target: "open", actions: ["setPointerMoveOpened", "invokeOnOpen"] } ] } }, 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: ["invokeOnOpen"] }, { target: "open", actions: ["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"] } } }, 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", "invokeOnOpen", "toggleVisibility"] }, { target: "open", actions: ["setPointerMoveOpened", "invokeOnOpen"] } ], "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 }) => { store.set("id", prop("id")); }, clearGlobalId: ({ prop }) => { if (prop("id") === store.get("id")) { store.set("id", null); } }, 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 = () => getPositionerEl(scope); return getPlacement(getTriggerEl(scope), getPositionerEl2, { ...prop("positioning"), ...event.options, defer: true, listeners: false, onComplete(data) { context.set("currentPlacement", data.placement); } }); }, toggleVisibility: ({ prop, event, send }) => { queueMicrotask(() => { send({ type: prop("open") ? "controlled.open" : "controlled.close", previousEvent: event }); }); }, setPointerMoveOpened: ({ context }) => { context.set("hasPointerMoveOpened", true); }, clearPointerMoveOpened: ({ context }) => { context.set("hasPointerMoveOpened", false); } }, effects: { trackFocusVisible: ({ scope }) => { return trackFocusVisible({ root: scope.getRootNode?.() }); }, trackPositioning: ({ context, prop, scope }) => { if (!context.get("currentPlacement")) { context.set("currentPlacement", prop("positioning").placement); } const getPositionerEl2 = () => getPositionerEl(scope); return getPlacement(getTriggerEl(scope), 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 }) => { if (!prop("closeOnScroll")) return; const triggerEl = getTriggerEl(scope); 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 }) => { const id = setTimeout(() => { send({ type: "after.openDelay" }); }, prop("openDelay")); return () => clearTimeout(id); }, waitForCloseDelay: ({ send, prop }) => { const id = setTimeout(() => { send({ type: "after.closeDelay" }); }, prop("closeDelay")); return () => clearTimeout(id); } } } }); var props = createProps()([ "aria-label", "closeDelay", "closeOnEscape", "closeOnPointerDown", "closeOnScroll", "closeOnClick", "dir", "disabled", "getRootNode", "id", "ids", "interactive", "onOpenChange", "defaultOpen", "open", "openDelay", "positioning" ]); var splitProps = createSplitProps(props); export { anatomy, connect, machine, props, splitProps };