@slithy/base-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,269 +7,344 @@ import {
7
7
  useContext,
8
8
  useEffect,
9
9
  useRef,
10
- useState,
11
10
  type ComponentPropsWithoutRef,
12
11
  type ReactElement,
13
12
  type ReactNode,
14
- type RefObject,
15
13
  } from "react";
16
- import "./Tooltip.css";
14
+ import { useTooltipStore, type TooltipPositionConfig } from "./TooltipStore";
17
15
 
18
16
  /* ------------------------------------------------------------------ */
19
- /* Deferred rendering avoids mounting Base UI hooks/positioning */
20
- /* until the user actually interacts with the trigger. */
17
+ /* Contextconnects Trigger to Portal content */
21
18
  /* ------------------------------------------------------------------ */
22
19
 
23
- interface DeferredState {
24
- activated: boolean;
25
- activate: () => void;
26
- isHoveringRef: RefObject<boolean>;
27
- shouldRefocusRef: RefObject<boolean>;
20
+ interface TooltipContext {
21
+ contentRef: React.RefObject<ReactNode | null>;
22
+ triggerRef: React.RefObject<HTMLElement | null>;
23
+ disabled: boolean;
28
24
  touchDisabled: boolean;
25
+ /** Whether the popup can be hovered without closing (hover card behavior) */
26
+ hoverable: boolean;
27
+ delay: number;
28
+ closeDelay: number;
29
+ /** Warm-up window in ms. If the previous tooltip closed within this window,
30
+ * the next tooltip opens instantly (no delay). @default 300 */
31
+ warmUpDelay: number;
32
+ positionConfig: TooltipPositionConfig;
29
33
  }
30
34
 
31
- const DeferredContext = createContext<DeferredState | null>(null);
35
+ const TooltipCtx = createContext<TooltipContext | null>(null);
32
36
 
33
37
  /* ------------------------------------------------------------------ */
34
- /* Provider (passthrough) */
38
+ /* Root — context provider */
35
39
  /* ------------------------------------------------------------------ */
36
40
 
37
- const Provider = BaseTooltip.Provider;
38
-
39
- /* ------------------------------------------------------------------ */
40
- /* Root — owns the activation latch */
41
- /* ------------------------------------------------------------------ */
42
-
43
- type RootProps = ComponentPropsWithoutRef<typeof BaseTooltip.Root> & {
41
+ type RootProps = {
44
42
  children?: ReactNode;
45
- /** Block activation from touch interactions. Defaults to `true`. */
43
+ /** Controlled open state. */
44
+ open?: boolean;
45
+ /** Open on first render (uncontrolled). */
46
+ defaultOpen?: boolean;
47
+ disabled?: boolean;
48
+ /** Block activation from touch interactions. @default true */
46
49
  touchDisabled?: boolean;
47
- /** Unmount Base UI after close animation completes, returning to the
48
- * lightweight pre-activation state. Defaults to `false`. */
49
- unmountOnClose?: boolean;
50
+ /** Allow hovering into the popup without closing (hover card behavior). @default false */
51
+ hoverable?: boolean;
52
+ /** Delay before opening in ms. @default 600 */
53
+ delay?: number;
54
+ /** Delay before closing in ms. @default 300 */
55
+ closeDelay?: number;
56
+ /** Warm-up window in ms. If a tooltip closed within this window,
57
+ * the next one opens instantly. @default 300 */
58
+ warmUpDelay?: number;
59
+ /** Which side of the trigger to place the popup. @default "top" */
60
+ side?: TooltipPositionConfig["side"];
61
+ /** Distance between trigger and popup in pixels. @default 6 */
62
+ sideOffset?: number;
63
+ /** Alignment relative to the trigger. @default "center" */
64
+ align?: TooltipPositionConfig["align"];
65
+ /** Offset along the alignment axis in pixels. @default 0 */
66
+ alignOffset?: number;
67
+ /** Padding from viewport edges for collision detection. @default 5 */
68
+ collisionPadding?: TooltipPositionConfig["collisionPadding"];
50
69
  };
51
70
 
52
71
  function Root({
53
72
  children,
54
73
  open,
55
74
  defaultOpen,
56
- disabled,
75
+ disabled = false,
57
76
  touchDisabled = true,
58
- unmountOnClose = false,
59
- onOpenChangeComplete,
60
- ...props
77
+ hoverable = false,
78
+ delay = 600,
79
+ closeDelay = 300,
80
+ warmUpDelay = 300,
81
+ side,
82
+ sideOffset,
83
+ align,
84
+ alignOffset,
85
+ collisionPadding,
61
86
  }: RootProps) {
62
- const [wasActivated, setWasActivated] = useState(false);
63
- const isHoveringRef = useRef(false);
64
- const shouldRefocusRef = useRef(false);
65
-
66
- // One-way latch: once true, stays true so leave animations can play.
67
- // Controlled `open` or `defaultOpen` bypass the latch entirely.
68
- // When unmountOnClose is true, the latch resets after close completes.
69
- const activated = wasActivated || !!open || !!defaultOpen;
70
-
71
- const activate = useCallback(() => {
72
- if (!disabled) setWasActivated(true);
73
- }, [disabled]);
74
-
75
- const ctx: DeferredState = {
76
- activated,
77
- activate,
78
- isHoveringRef,
79
- shouldRefocusRef,
87
+ const contentRef = useRef<ReactNode | null>(null);
88
+ const triggerRef = useRef<HTMLElement | null>(null);
89
+ const positionConfig: TooltipPositionConfig = { side, sideOffset, align, alignOffset, collisionPadding };
90
+ const initializedRef = useRef(false);
91
+
92
+ // Controlled open: sync store with prop
93
+ useEffect(() => {
94
+ if (open === undefined) return;
95
+ const store = useTooltipStore.getState();
96
+ if (open && !store.open) {
97
+ const content = contentRef.current;
98
+ const anchor = triggerRef.current;
99
+ if (content && anchor) {
100
+ store.openTooltip(content, anchor, { positionConfig, hoverable, closeDelay });
101
+ }
102
+ } else if (!open && store.open && store.anchor === triggerRef.current) {
103
+ store.closeTooltip();
104
+ }
105
+ }, [open]);
106
+
107
+ // defaultOpen: open on first render
108
+ useEffect(() => {
109
+ if (initializedRef.current || defaultOpen === undefined || !defaultOpen) return;
110
+ initializedRef.current = true;
111
+ requestAnimationFrame(() => {
112
+ const content = contentRef.current;
113
+ const anchor = triggerRef.current;
114
+ if (content && anchor) {
115
+ useTooltipStore.getState().openTooltip(content, anchor, { positionConfig, hoverable, closeDelay });
116
+ }
117
+ });
118
+ }, [defaultOpen]);
119
+
120
+ const ctx: TooltipContext = {
121
+ contentRef,
122
+ triggerRef,
123
+ disabled,
80
124
  touchDisabled,
125
+ hoverable,
126
+ delay,
127
+ closeDelay,
128
+ warmUpDelay,
129
+ positionConfig,
81
130
  };
82
131
 
83
- if (!activated) {
84
- return (
85
- <DeferredContext.Provider value={ctx}>
86
- {children}
87
- </DeferredContext.Provider>
88
- );
89
- }
90
-
91
132
  return (
92
- <DeferredContext.Provider value={ctx}>
93
- <BaseTooltip.Root
94
- open={open}
95
- defaultOpen={defaultOpen}
96
- disabled={disabled}
97
- onOpenChangeComplete={(isOpen) => {
98
- onOpenChangeComplete?.(isOpen);
99
- if (!isOpen && unmountOnClose) setWasActivated(false);
100
- }}
101
- {...props}
102
- >
103
- {children}
104
- </BaseTooltip.Root>
105
- </DeferredContext.Provider>
133
+ <TooltipCtx.Provider value={ctx}>
134
+ {children}
135
+ </TooltipCtx.Provider>
106
136
  );
107
137
  }
108
138
 
109
139
  /* ------------------------------------------------------------------ */
110
- /* Trigger — lightweight pre-activation handlers, then Base UI */
140
+ /* Trigger — plain element, zero Base UI overhead */
111
141
  /* ------------------------------------------------------------------ */
112
142
 
113
- type TriggerProps = ComponentPropsWithoutRef<typeof BaseTooltip.Trigger>;
114
-
115
- // Base UI Trigger props that must not leak onto a raw <button>
116
- const BASE_UI_KEYS = new Set([
117
- "closeOnClick",
118
- "handle",
119
- "payload",
120
- "delay",
121
- "closeDelay",
122
- "render",
123
- ]);
124
-
125
- function pickHtmlProps(props: Record<string, unknown>) {
126
- const html: Record<string, unknown> = {};
127
- for (const key in props) {
128
- if (!BASE_UI_KEYS.has(key)) html[key] = props[key];
129
- }
130
- return html;
131
- }
143
+ type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
144
+ children?: ReactNode;
145
+ /** Replace the default `<button>` with a custom element. */
146
+ render?: ReactElement | ((props: Record<string, unknown>) => ReactElement);
147
+ };
132
148
 
133
- function Trigger({
134
- children,
135
- className,
136
- style,
137
- disabled,
138
- ...rest
139
- }: TriggerProps) {
140
- const deferred = useContext(DeferredContext);
141
- const triggerElRef = useRef<HTMLButtonElement | null>(null);
142
- const pendingRef = useRef(0);
149
+ function Trigger({ children, render, ...props }: TriggerProps) {
150
+ const ctx = useContext(TooltipCtx);
151
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
152
+ const openTimerRef = useRef(0);
153
+ const closeTimerRef = useRef(0);
143
154
  const lastPointerTypeRef = useRef("");
144
155
 
145
- // Ref callback: stores the element AND restores focus when the DOM
146
- // swaps from the pre-activation button to BaseTooltip.Trigger.
147
- // shouldRefocusRef lives in DeferredContext because Root's tree change
148
- // (wrapping in BaseTooltip.Root) causes Trigger to remount.
149
- const shouldRefocusRef = deferred?.shouldRefocusRef;
150
- const triggerRef = useCallback(
156
+ // Register trigger element with Root
157
+ const setTriggerRef = useCallback(
151
158
  (el: HTMLButtonElement | null) => {
152
- triggerElRef.current = el;
153
- if (shouldRefocusRef?.current && el) {
154
- shouldRefocusRef.current = false;
155
- el.focus();
156
- }
159
+ triggerRef.current = el;
160
+ if (ctx) ctx.triggerRef.current = el;
157
161
  },
158
- [shouldRefocusRef],
162
+ [ctx],
159
163
  );
160
164
 
161
- const activated = deferred?.activated ?? true;
162
-
163
- // After hover-activation, dispatch synthetic events to kick-start
164
- // Base UI's hover detection. Base UI uses:
165
- // - React onPointerEnter → sets pointerType
166
- // - native mouseenter listener → starts hover tracking
167
- // - React onMouseMove → triggers the restMs timer that opens the tooltip
165
+ // Clear pending timers on unmount
168
166
  useEffect(() => {
169
- if (!activated || !deferred?.isHoveringRef.current) return;
170
- const el = triggerElRef.current;
171
- if (!el) return;
172
- const frame = requestAnimationFrame(() => {
173
- if (!deferred.isHoveringRef.current) return;
174
- el.dispatchEvent(
175
- new PointerEvent("pointerenter", {
176
- bubbles: false,
177
- pointerType: "mouse",
178
- }),
179
- );
180
- el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
181
- el.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
182
- });
183
- return () => cancelAnimationFrame(frame);
184
- }, [activated]);
185
-
186
- if (deferred && !deferred.activated) {
187
- const { render: renderProp } = rest;
188
- const htmlProps = pickHtmlProps(rest);
189
-
190
- const preProps = {
191
- ...htmlProps,
192
- ref: triggerRef,
193
- className: typeof className === "function" ? undefined : className,
194
- style: typeof style === "function" ? undefined : style,
195
- disabled,
196
- onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {
197
- lastPointerTypeRef.current = e.pointerType;
198
- },
199
- onMouseEnter: () => {
200
- if (deferred.touchDisabled && lastPointerTypeRef.current === "touch")
201
- return;
202
- deferred.isHoveringRef.current = true;
203
- pendingRef.current = requestAnimationFrame(() => {
204
- if (deferred.isHoveringRef.current) deferred.activate();
205
- });
206
- },
207
- onMouseLeave: () => {
208
- deferred.isHoveringRef.current = false;
209
- cancelAnimationFrame(pendingRef.current);
210
- },
211
- onFocus: () => {
212
- if (deferred.touchDisabled && lastPointerTypeRef.current === "touch")
213
- return;
214
- deferred.shouldRefocusRef.current = true;
215
- deferred.activate();
216
- },
167
+ return () => {
168
+ clearTimeout(openTimerRef.current);
169
+ clearTimeout(closeTimerRef.current);
217
170
  };
218
-
219
- if (isValidElement(renderProp)) {
220
- return cloneElement(renderProp, preProps, children) as ReactElement;
171
+ }, []);
172
+
173
+ const storeState = useTooltipStore((s) => ({
174
+ open: s.open,
175
+ anchor: s.anchor,
176
+ popupId: s.popupId,
177
+ }));
178
+ const isActive = storeState.open && storeState.anchor === triggerRef.current;
179
+
180
+ const scheduleOpen = useCallback(() => {
181
+ if (ctx?.disabled || !triggerRef.current) return;
182
+
183
+ // Cancel any pending close
184
+ clearTimeout(closeTimerRef.current);
185
+
186
+ const content = ctx?.contentRef.current;
187
+ if (!content) return;
188
+
189
+ const store = useTooltipStore.getState();
190
+
191
+ // If already showing this tooltip, do nothing
192
+ if (store.open && store.anchor === triggerRef.current) return;
193
+
194
+ // Instant switch: if another tooltip is open, or one closed recently (warm-up)
195
+ const isSwitch = store.open && store.anchor !== triggerRef.current;
196
+ const elapsed = Date.now() - store.lastCloseTime;
197
+ const isWarmUp = !isSwitch && elapsed < (ctx?.warmUpDelay ?? 300);
198
+ const effectiveDelay = isSwitch || isWarmUp ? 0 : (ctx?.delay ?? 600);
199
+
200
+ const openOptions = { positionConfig: ctx?.positionConfig, hoverable: ctx?.hoverable, closeDelay: ctx?.closeDelay };
201
+
202
+ if (effectiveDelay === 0) {
203
+ store.openTooltip(content, triggerRef.current!, openOptions);
204
+ } else {
205
+ openTimerRef.current = window.setTimeout(() => {
206
+ const currentContent = ctx?.contentRef.current;
207
+ if (currentContent && triggerRef.current) {
208
+ useTooltipStore.getState().openTooltip(currentContent, triggerRef.current, openOptions);
209
+ }
210
+ }, effectiveDelay);
221
211
  }
222
- if (typeof renderProp === "function") {
223
- return renderProp({ ...preProps, children } as Parameters<typeof renderProp>[0], {} as Parameters<typeof renderProp>[1]);
212
+ }, [ctx]);
213
+
214
+ const scheduleClose = useCallback(() => {
215
+ clearTimeout(openTimerRef.current);
216
+
217
+ // When hoverable, the renderer's safe polygon and popup hover
218
+ // handlers own the close lifecycle — trigger doesn't close.
219
+ if (ctx?.hoverable) return;
220
+
221
+ const store = useTooltipStore.getState();
222
+ if (!store.open || store.anchor !== triggerRef.current) return;
223
+
224
+ const closeDelay = ctx?.closeDelay ?? 300;
225
+ if (closeDelay === 0) {
226
+ store.closeTooltip();
227
+ } else {
228
+ closeTimerRef.current = window.setTimeout(() => {
229
+ const s = useTooltipStore.getState();
230
+ if (s.open && s.anchor === triggerRef.current) {
231
+ s.closeTooltip();
232
+ }
233
+ }, closeDelay);
224
234
  }
225
- return <button {...preProps}>{children}</button>;
226
- }
235
+ }, [ctx]);
227
236
 
228
- return (
229
- <BaseTooltip.Trigger
230
- ref={triggerRef}
231
- className={className}
232
- style={style}
233
- disabled={disabled}
234
- {...rest}
235
- >
236
- {children}
237
- </BaseTooltip.Trigger>
237
+ const handlePointerDown = useCallback(
238
+ (e: React.PointerEvent<HTMLButtonElement>) => {
239
+ lastPointerTypeRef.current = e.pointerType;
240
+ props.onPointerDown?.(e);
241
+ },
242
+ [props.onPointerDown],
243
+ );
244
+
245
+ const handlePointerEnter = useCallback(
246
+ (e: React.PointerEvent<HTMLButtonElement>) => {
247
+ props.onPointerEnter?.(e);
248
+ if (e.defaultPrevented) return;
249
+ if (ctx?.touchDisabled && lastPointerTypeRef.current === "touch") return;
250
+ scheduleOpen();
251
+ },
252
+ [ctx, props.onPointerEnter, scheduleOpen],
253
+ );
254
+
255
+ const handlePointerLeave = useCallback(
256
+ (e: React.PointerEvent<HTMLButtonElement>) => {
257
+ props.onPointerLeave?.(e);
258
+ if (e.defaultPrevented) return;
259
+ scheduleClose();
260
+ },
261
+ [props.onPointerLeave, scheduleClose],
262
+ );
263
+
264
+ const handleFocus = useCallback(
265
+ (e: React.FocusEvent<HTMLButtonElement>) => {
266
+ props.onFocus?.(e);
267
+ if (e.defaultPrevented) return;
268
+ if (ctx?.touchDisabled && lastPointerTypeRef.current === "touch") return;
269
+ scheduleOpen();
270
+ },
271
+ [ctx, props.onFocus, scheduleOpen],
272
+ );
273
+
274
+ const handleBlur = useCallback(
275
+ (e: React.FocusEvent<HTMLButtonElement>) => {
276
+ props.onBlur?.(e);
277
+ if (e.defaultPrevented) return;
278
+ // Close immediately on blur (no delay)
279
+ clearTimeout(openTimerRef.current);
280
+ const store = useTooltipStore.getState();
281
+ if (store.open && store.anchor === triggerRef.current) {
282
+ store.closeTooltip();
283
+ }
284
+ },
285
+ [props.onBlur],
238
286
  );
287
+
288
+ const triggerProps = {
289
+ ref: setTriggerRef,
290
+ type: "button" as const,
291
+ "aria-describedby": isActive && storeState.popupId ? storeState.popupId : undefined,
292
+ onPointerDown: handlePointerDown,
293
+ onPointerEnter: handlePointerEnter,
294
+ onPointerLeave: handlePointerLeave,
295
+ onFocus: handleFocus,
296
+ onBlur: handleBlur,
297
+ ...props,
298
+ };
299
+
300
+ if (isValidElement(render)) {
301
+ return cloneElement(render, triggerProps, children) as ReactElement;
302
+ }
303
+ if (typeof render === "function") {
304
+ return render({ ...triggerProps, children });
305
+ }
306
+ return <button {...triggerProps}>{children}</button>;
239
307
  }
240
308
 
241
309
  /* ------------------------------------------------------------------ */
242
- /* Portal — returns null pre-activation */
310
+ /* Portal — captures children, does not render them */
243
311
  /* ------------------------------------------------------------------ */
244
312
 
245
- type PortalProps = ComponentPropsWithoutRef<typeof BaseTooltip.Portal>;
313
+ type PortalProps = {
314
+ children?: ReactNode;
315
+ };
316
+
317
+ function Portal({ children }: PortalProps) {
318
+ const ctx = useContext(TooltipCtx);
319
+ const storeAnchor = useTooltipStore((s) => s.anchor);
320
+
321
+ if (ctx) {
322
+ ctx.contentRef.current = children;
323
+ }
324
+
325
+ // If this tooltip is currently active, push content updates to the store
326
+ // so dynamic content reflects immediately in the open popup.
327
+ const isActive = !!(ctx && storeAnchor && storeAnchor === ctx.triggerRef.current);
328
+ useEffect(() => {
329
+ if (isActive) {
330
+ useTooltipStore.getState().updateContent(children);
331
+ }
332
+ });
246
333
 
247
- function Portal(props: PortalProps) {
248
- const deferred = useContext(DeferredContext);
249
- if (deferred && !deferred.activated) return null;
250
- return <BaseTooltip.Portal {...props} />;
334
+ return null;
251
335
  }
252
336
 
253
337
  /* ------------------------------------------------------------------ */
254
338
  /* Positioner / Popup / Arrow — default class names */
255
339
  /* ------------------------------------------------------------------ */
256
340
 
257
- type PositionerProps = ComponentPropsWithoutRef<typeof BaseTooltip.Positioner>;
258
-
259
- function Positioner({ className, ...props }: PositionerProps) {
260
- return (
261
- <BaseTooltip.Positioner
262
- className={className ?? "slithy-tooltip-positioner"}
263
- {...props}
264
- />
265
- );
266
- }
267
-
268
341
  type PopupProps = ComponentPropsWithoutRef<typeof BaseTooltip.Popup>;
269
342
 
270
- function Popup({ className, ...props }: PopupProps) {
343
+ function Popup({ className, id, ...props }: PopupProps) {
344
+ const popupId = useTooltipStore((s) => s.popupId);
271
345
  return (
272
346
  <BaseTooltip.Popup
347
+ id={id ?? popupId ?? undefined}
273
348
  className={className ?? "slithy-tooltip-popup"}
274
349
  {...props}
275
350
  />
@@ -292,11 +367,9 @@ function Arrow({ className, ...props }: ArrowProps) {
292
367
  /* ------------------------------------------------------------------ */
293
368
 
294
369
  export const Tooltip = {
295
- Provider,
296
370
  Root,
297
371
  Trigger,
298
372
  Portal,
299
- Positioner,
300
373
  Popup,
301
374
  Arrow,
302
375
  };
@@ -0,0 +1,137 @@
1
+ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
2
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
3
+ import { useSafePolygon } from "../useSafePolygon";
4
+ import { useTooltipStore } from "./TooltipStore";
5
+ import "./Tooltip.css";
6
+
7
+ /**
8
+ * Singleton renderer — mount once at the app root.
9
+ * Subscribes to the global TooltipStore and renders the active tooltip
10
+ * using Base UI's Tooltip.Root + Tooltip.Positioner.
11
+ *
12
+ * Tooltip.Root stays mounted so CSS transitions can play on both open and close.
13
+ */
14
+ export function TooltipRenderer() {
15
+ const popupId = useId();
16
+
17
+ // Register the popup id so triggers can reference it for aria-describedby
18
+ useEffect(() => {
19
+ useTooltipStore.getState().setPopupId(popupId);
20
+ }, [popupId]);
21
+
22
+ const open = useTooltipStore((s) => s.open);
23
+ const content = useTooltipStore((s) => s.content);
24
+ const anchor = useTooltipStore((s) => s.anchor);
25
+ const positionConfig = useTooltipStore((s) => s.positionConfig);
26
+
27
+ // Keep content/anchor alive during close animation so the popup
28
+ // doesn't disappear before the transition finishes.
29
+ const lastContentRef = useRef(content);
30
+ const lastAnchorRef = useRef(anchor);
31
+ if (content) lastContentRef.current = content;
32
+ if (anchor) lastAnchorRef.current = anchor;
33
+
34
+ const activeContent = content ?? lastContentRef.current;
35
+ const activeAnchor = anchor ?? lastAnchorRef.current;
36
+
37
+ // Track whether we've ever had content (don't mount Tooltip.Root until first open)
38
+ const [hasOpened, setHasOpened] = useState(false);
39
+ // Defer the first open by one frame so Tooltip.Root mounts with open={false},
40
+ // then transitions to open={true} — enabling the starting-style animation.
41
+ const [deferredOpen, setDeferredOpen] = useState(false);
42
+ useEffect(() => {
43
+ if (open && !hasOpened) {
44
+ setHasOpened(true);
45
+ requestAnimationFrame(() => setDeferredOpen(true));
46
+ } else {
47
+ setDeferredOpen(open);
48
+ }
49
+ }, [open, hasOpened]);
50
+
51
+ // Dismiss on Escape key
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ const handleKeyDown = (e: KeyboardEvent) => {
55
+ if (e.key === "Escape") {
56
+ useTooltipStore.getState().closeTooltip();
57
+ }
58
+ };
59
+ document.addEventListener("keydown", handleKeyDown);
60
+ return () => document.removeEventListener("keydown", handleKeyDown);
61
+ }, [open]);
62
+
63
+ // Hoverable popup support — keep open while pointer is in the popup
64
+ const hoverable = useTooltipStore((s) => s.hoverable);
65
+ const storeCloseDelay = useTooltipStore((s) => s.closeDelay);
66
+ const popupRef = useRef<HTMLDivElement | null>(null);
67
+ const popupCloseTimerRef = useRef(0);
68
+
69
+ // Safe polygon: keep open while pointer moves from trigger toward popup
70
+ const handleSafePolygonClose = useCallback(() => {
71
+ useTooltipStore.getState().closeTooltip();
72
+ }, []);
73
+
74
+ useSafePolygon({
75
+ enabled: open && hoverable,
76
+ anchor: anchor,
77
+ popupRef,
78
+ onClose: handleSafePolygonClose,
79
+ });
80
+
81
+ const handlePopupPointerEnter = useCallback(() => {
82
+ if (!hoverable) return;
83
+ clearTimeout(popupCloseTimerRef.current);
84
+ }, [hoverable]);
85
+
86
+ const handlePopupPointerLeave = useCallback(() => {
87
+ if (!hoverable) return;
88
+ popupCloseTimerRef.current = window.setTimeout(() => {
89
+ useTooltipStore.getState().closeTooltip();
90
+ }, storeCloseDelay);
91
+ }, [hoverable, storeCloseDelay]);
92
+
93
+ // Clear popup close timer on unmount or when tooltip closes
94
+ useEffect(() => {
95
+ if (!open) clearTimeout(popupCloseTimerRef.current);
96
+ return () => clearTimeout(popupCloseTimerRef.current);
97
+ }, [open]);
98
+
99
+ // Clear stale content after close animation completes
100
+ const handleOpenChangeComplete = (isOpen: boolean) => {
101
+ if (!isOpen) {
102
+ lastContentRef.current = null;
103
+ lastAnchorRef.current = null;
104
+ }
105
+ };
106
+
107
+ if (!hasOpened) return null;
108
+
109
+ return (
110
+ <BaseTooltip.Root
111
+ open={deferredOpen}
112
+ onOpenChange={(isOpen) => {
113
+ if (!isOpen) {
114
+ useTooltipStore.getState().closeTooltip();
115
+ }
116
+ }}
117
+ onOpenChangeComplete={handleOpenChangeComplete}
118
+ >
119
+ <BaseTooltip.Portal>
120
+ <BaseTooltip.Positioner
121
+ ref={popupRef}
122
+ anchor={activeAnchor}
123
+ className="slithy-tooltip-positioner"
124
+ side={positionConfig.side ?? "top"}
125
+ sideOffset={positionConfig.sideOffset ?? 6}
126
+ align={positionConfig.align}
127
+ alignOffset={positionConfig.alignOffset}
128
+ collisionPadding={positionConfig.collisionPadding}
129
+ onPointerEnter={handlePopupPointerEnter}
130
+ onPointerLeave={handlePopupPointerLeave}
131
+ >
132
+ {activeContent}
133
+ </BaseTooltip.Positioner>
134
+ </BaseTooltip.Portal>
135
+ </BaseTooltip.Root>
136
+ );
137
+ }