@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.
- package/CHANGELOG.md +18 -0
- package/README.md +103 -128
- package/dist/index.d.ts +118 -35
- package/dist/index.js +911 -424
- package/package.json +3 -2
- package/src/Dropdown/Dropdown.test.tsx +361 -186
- package/src/Dropdown/Dropdown.tsx +353 -349
- package/src/Dropdown/DropdownRenderer.tsx +118 -0
- package/src/Dropdown/DropdownStore.ts +147 -0
- package/src/Dropdown/index.ts +1 -0
- package/src/Tooltip/Tooltip.test.tsx +221 -212
- package/src/Tooltip/Tooltip.tsx +274 -201
- package/src/Tooltip/TooltipRenderer.tsx +137 -0
- package/src/Tooltip/TooltipStore.ts +142 -0
- package/src/Tooltip/index.ts +2 -1
- package/src/index.ts +2 -2
- package/src/useCloseCleanup.ts +60 -0
- package/src/useSafePolygon.ts +144 -0
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -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 "./
|
|
14
|
+
import { useTooltipStore, type TooltipPositionConfig } from "./TooltipStore";
|
|
17
15
|
|
|
18
16
|
/* ------------------------------------------------------------------ */
|
|
19
|
-
/*
|
|
20
|
-
/* until the user actually interacts with the trigger. */
|
|
17
|
+
/* Context — connects Trigger to Portal content */
|
|
21
18
|
/* ------------------------------------------------------------------ */
|
|
22
19
|
|
|
23
|
-
interface
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
35
|
+
const TooltipCtx = createContext<TooltipContext | null>(null);
|
|
32
36
|
|
|
33
37
|
/* ------------------------------------------------------------------ */
|
|
34
|
-
/*
|
|
38
|
+
/* Root — context provider */
|
|
35
39
|
/* ------------------------------------------------------------------ */
|
|
36
40
|
|
|
37
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Controlled
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
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 —
|
|
140
|
+
/* Trigger — plain element, zero Base UI overhead */
|
|
111
141
|
/* ------------------------------------------------------------------ */
|
|
112
142
|
|
|
113
|
-
type TriggerProps =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
146
|
-
|
|
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
|
-
|
|
153
|
-
if (
|
|
154
|
-
shouldRefocusRef.current = false;
|
|
155
|
-
el.focus();
|
|
156
|
-
}
|
|
159
|
+
triggerRef.current = el;
|
|
160
|
+
if (ctx) ctx.triggerRef.current = el;
|
|
157
161
|
},
|
|
158
|
-
[
|
|
162
|
+
[ctx],
|
|
159
163
|
);
|
|
160
164
|
|
|
161
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
}
|
|
235
|
+
}, [ctx]);
|
|
227
236
|
|
|
228
|
-
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 —
|
|
310
|
+
/* Portal — captures children, does not render them */
|
|
243
311
|
/* ------------------------------------------------------------------ */
|
|
244
312
|
|
|
245
|
-
type PortalProps =
|
|
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
|
-
|
|
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
|
+
}
|