@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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useCallback, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/* Minimal store (self-contained, no external deps) */
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
7
|
+
|
|
8
|
+
type Listener = () => void;
|
|
9
|
+
|
|
10
|
+
export type TooltipPositionConfig = {
|
|
11
|
+
side?: "top" | "bottom" | "left" | "right" | "inline-end" | "inline-start";
|
|
12
|
+
sideOffset?: number;
|
|
13
|
+
align?: "start" | "center" | "end";
|
|
14
|
+
alignOffset?: number;
|
|
15
|
+
collisionPadding?: number | Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TooltipStoreState = {
|
|
19
|
+
open: boolean;
|
|
20
|
+
content: ReactNode | null;
|
|
21
|
+
anchor: HTMLElement | null;
|
|
22
|
+
positionConfig: TooltipPositionConfig;
|
|
23
|
+
/** Whether the popup can be hovered without closing */
|
|
24
|
+
hoverable: boolean;
|
|
25
|
+
/** Close delay in ms — used by the renderer for popup hover */
|
|
26
|
+
closeDelay: number;
|
|
27
|
+
/** Stable id for the popup element — set by the renderer, read by triggers for aria-describedby */
|
|
28
|
+
popupId: string | null;
|
|
29
|
+
/** Timestamp of last close — enables warm-up (instant open after recent close) */
|
|
30
|
+
lastCloseTime: number;
|
|
31
|
+
setPopupId: (id: string) => void;
|
|
32
|
+
openTooltip: (
|
|
33
|
+
content: ReactNode,
|
|
34
|
+
anchor: HTMLElement,
|
|
35
|
+
options?: { positionConfig?: TooltipPositionConfig; hoverable?: boolean; closeDelay?: number },
|
|
36
|
+
) => void;
|
|
37
|
+
closeTooltip: (options?: { skipWarmUp?: boolean }) => void;
|
|
38
|
+
updateContent: (content: ReactNode) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
42
|
+
if (Object.is(a, b)) return true;
|
|
43
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
|
|
44
|
+
return false;
|
|
45
|
+
const keysA = Object.keys(a);
|
|
46
|
+
const keysB = Object.keys(b);
|
|
47
|
+
if (keysA.length !== keysB.length) return false;
|
|
48
|
+
return keysA.every((k) =>
|
|
49
|
+
Object.is(
|
|
50
|
+
(a as Record<string, unknown>)[k],
|
|
51
|
+
(b as Record<string, unknown>)[k],
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createTooltipStore() {
|
|
57
|
+
const listeners = new Set<Listener>();
|
|
58
|
+
|
|
59
|
+
function notify() {
|
|
60
|
+
listeners.forEach((l) => l());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let state: TooltipStoreState = {
|
|
64
|
+
open: false,
|
|
65
|
+
content: null,
|
|
66
|
+
anchor: null,
|
|
67
|
+
positionConfig: {},
|
|
68
|
+
hoverable: false,
|
|
69
|
+
closeDelay: 300,
|
|
70
|
+
popupId: null,
|
|
71
|
+
lastCloseTime: 0,
|
|
72
|
+
|
|
73
|
+
setPopupId(id) {
|
|
74
|
+
if (state.popupId === id) return;
|
|
75
|
+
state = { ...state, popupId: id };
|
|
76
|
+
notify();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
openTooltip(content, anchor, options) {
|
|
80
|
+
state = {
|
|
81
|
+
...state,
|
|
82
|
+
open: true,
|
|
83
|
+
content,
|
|
84
|
+
anchor,
|
|
85
|
+
positionConfig: options?.positionConfig ?? {},
|
|
86
|
+
hoverable: options?.hoverable ?? false,
|
|
87
|
+
closeDelay: options?.closeDelay ?? 300,
|
|
88
|
+
};
|
|
89
|
+
notify();
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
closeTooltip(options) {
|
|
93
|
+
if (!state.open) return;
|
|
94
|
+
state = {
|
|
95
|
+
...state,
|
|
96
|
+
open: false,
|
|
97
|
+
content: null,
|
|
98
|
+
anchor: null,
|
|
99
|
+
lastCloseTime: options?.skipWarmUp ? 0 : Date.now(),
|
|
100
|
+
};
|
|
101
|
+
notify();
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
updateContent(content) {
|
|
105
|
+
if (!state.open || state.content === content) return;
|
|
106
|
+
state = { ...state, content };
|
|
107
|
+
notify();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
getState: () => state,
|
|
113
|
+
subscribe: (listener: Listener) => {
|
|
114
|
+
listeners.add(listener);
|
|
115
|
+
return () => listeners.delete(listener);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const store = createTooltipStore();
|
|
121
|
+
|
|
122
|
+
export function useTooltipStore<T>(selector: (state: TooltipStoreState) => T): T {
|
|
123
|
+
const selectorRef = useRef(selector);
|
|
124
|
+
selectorRef.current = selector;
|
|
125
|
+
|
|
126
|
+
const prevRef = useRef<T | undefined>(undefined);
|
|
127
|
+
|
|
128
|
+
const getSnapshot = useCallback(() => {
|
|
129
|
+
const result = selectorRef.current(store.getState());
|
|
130
|
+
const prev = prevRef.current;
|
|
131
|
+
if (prev !== undefined && shallowEqual(prev, result)) {
|
|
132
|
+
return prev;
|
|
133
|
+
}
|
|
134
|
+
prevRef.current = result;
|
|
135
|
+
return result;
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Direct access for imperative use inside event handlers */
|
|
142
|
+
useTooltipStore.getState = store.getState;
|
package/src/Tooltip/index.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { Tooltip } from
|
|
1
|
+
export { Tooltip } from "./Tooltip";
|
|
2
|
+
export { TooltipRenderer } from "./TooltipRenderer";
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { Dropdown } from './Dropdown';
|
|
2
|
-
export { Tooltip } from './Tooltip';
|
|
1
|
+
export { Dropdown, DropdownRenderer } from './Dropdown';
|
|
2
|
+
export { Tooltip, TooltipRenderer } from './Tooltip';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defensive safety net for singleton renderer cleanup.
|
|
5
|
+
*
|
|
6
|
+
* Base UI's `onOpenChangeComplete` is the primary cleanup mechanism and
|
|
7
|
+
* fires reliably in normal usage (verified empirically). This hook
|
|
8
|
+
* exists as insurance: if `onOpenChangeComplete` ever fails to fire
|
|
9
|
+
* (e.g., CSS transition edge cases, rapid open/close cycles, or future
|
|
10
|
+
* Base UI changes), the timeout guarantees stale content and anchor refs
|
|
11
|
+
* are cleared within 500ms, preventing ghost positioner elements in the DOM.
|
|
12
|
+
*
|
|
13
|
+
* The returned callback is idempotent — calling it from both
|
|
14
|
+
* `onOpenChangeComplete` and the timeout is safe; only the first
|
|
15
|
+
* invocation per close cycle runs the cleanup.
|
|
16
|
+
*
|
|
17
|
+
* Usage in renderers:
|
|
18
|
+
*
|
|
19
|
+
* const [onCloseComplete] = useCloseCleanup(open, () => {
|
|
20
|
+
* lastContentRef.current = null;
|
|
21
|
+
* lastAnchorRef.current = null;
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* <Root onOpenChangeComplete={(isOpen) => {
|
|
25
|
+
* if (!isOpen) onCloseComplete(); // primary path — fires first
|
|
26
|
+
* }} />
|
|
27
|
+
*
|
|
28
|
+
* // Safety net fires automatically after 500ms if primary never calls.
|
|
29
|
+
*/
|
|
30
|
+
export function useCloseCleanup(
|
|
31
|
+
open: boolean,
|
|
32
|
+
onDone: () => void,
|
|
33
|
+
): [cleanup: () => void] {
|
|
34
|
+
const onDoneRef = useRef(onDone);
|
|
35
|
+
onDoneRef.current = onDone;
|
|
36
|
+
|
|
37
|
+
// Track whether cleanup has been called for this close cycle.
|
|
38
|
+
const calledRef = useRef(false);
|
|
39
|
+
|
|
40
|
+
// Reset when a new open cycle starts.
|
|
41
|
+
if (open) {
|
|
42
|
+
calledRef.current = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Stable callback — safe to call from onOpenChangeComplete.
|
|
46
|
+
const runCleanup = useCallback(() => {
|
|
47
|
+
if (calledRef.current) return;
|
|
48
|
+
calledRef.current = true;
|
|
49
|
+
onDoneRef.current();
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// Safety net: guarantee cleanup after a generous timeout.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (open) return;
|
|
55
|
+
const timerId = setTimeout(runCleanup, 500);
|
|
56
|
+
return () => clearTimeout(timerId);
|
|
57
|
+
}, [open, runCleanup]);
|
|
58
|
+
|
|
59
|
+
return [runCleanup];
|
|
60
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks the pointer after it leaves the trigger and keeps the popup open
|
|
5
|
+
* while the pointer moves toward it (safe polygon / hover bridge).
|
|
6
|
+
*
|
|
7
|
+
* Creates a triangle from the pointer's exit point to the two far corners
|
|
8
|
+
* of the popup. As long as the pointer stays inside this triangle, the
|
|
9
|
+
* popup remains open. If the pointer enters the popup element, tracking
|
|
10
|
+
* stops (the popup's own hover handlers take over). If the pointer exits
|
|
11
|
+
* the triangle, `onClose` is called.
|
|
12
|
+
*
|
|
13
|
+
* Shared by TooltipRenderer, DropdownRenderer, and any future hover-based
|
|
14
|
+
* floating components.
|
|
15
|
+
*/
|
|
16
|
+
export function useSafePolygon({
|
|
17
|
+
enabled,
|
|
18
|
+
anchor,
|
|
19
|
+
popupRef,
|
|
20
|
+
onClose,
|
|
21
|
+
}: {
|
|
22
|
+
/** Whether safe polygon tracking is active (trigger was hovered, popup is open) */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** The trigger element */
|
|
25
|
+
anchor: HTMLElement | null;
|
|
26
|
+
/** Ref to the popup element — read inside effects so it works even when the popup mounts after the hook runs */
|
|
27
|
+
popupRef: RefObject<HTMLElement | null>;
|
|
28
|
+
/** Called when the pointer exits the safe polygon */
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const exitPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
32
|
+
|
|
33
|
+
// Capture the pointer position when leaving the trigger
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!enabled || !anchor) return;
|
|
36
|
+
|
|
37
|
+
const handlePointerLeave = (e: PointerEvent) => {
|
|
38
|
+
exitPointRef.current = { x: e.clientX, y: e.clientY };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
anchor.addEventListener("pointerleave", handlePointerLeave);
|
|
42
|
+
return () => anchor.removeEventListener("pointerleave", handlePointerLeave);
|
|
43
|
+
}, [enabled, anchor]);
|
|
44
|
+
|
|
45
|
+
// Track pointer movement and check against the safe polygon
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!enabled || !anchor) return;
|
|
48
|
+
|
|
49
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
50
|
+
const exit = exitPointRef.current;
|
|
51
|
+
if (!exit) return;
|
|
52
|
+
|
|
53
|
+
const popup = popupRef.current;
|
|
54
|
+
if (!popup) return;
|
|
55
|
+
|
|
56
|
+
// If the pointer entered the popup, stop tracking
|
|
57
|
+
const popupRect = popup.getBoundingClientRect();
|
|
58
|
+
if (
|
|
59
|
+
e.clientX >= popupRect.left &&
|
|
60
|
+
e.clientX <= popupRect.right &&
|
|
61
|
+
e.clientY >= popupRect.top &&
|
|
62
|
+
e.clientY <= popupRect.bottom
|
|
63
|
+
) {
|
|
64
|
+
exitPointRef.current = null;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build triangle from exit point to two corners of the popup
|
|
69
|
+
const triangle = getTriangle(exit, popupRect);
|
|
70
|
+
|
|
71
|
+
if (!pointInTriangle({ x: e.clientX, y: e.clientY }, triangle)) {
|
|
72
|
+
exitPointRef.current = null;
|
|
73
|
+
onClose();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
document.addEventListener("pointermove", handlePointerMove);
|
|
78
|
+
return () => document.removeEventListener("pointermove", handlePointerMove);
|
|
79
|
+
}, [enabled, anchor, popupRef, onClose]);
|
|
80
|
+
|
|
81
|
+
// Clear exit point when tracking stops
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!enabled) {
|
|
84
|
+
exitPointRef.current = null;
|
|
85
|
+
}
|
|
86
|
+
}, [enabled]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/* Geometry */
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
|
|
93
|
+
type Point = { x: number; y: number };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a triangle from the pointer exit point to the two far corners
|
|
97
|
+
* of the popup, creating a bridge zone the pointer can travel through.
|
|
98
|
+
*
|
|
99
|
+
* We pick corners based on which quadrant the popup is in relative to
|
|
100
|
+
* the exit point, ensuring the triangle covers the gap between them.
|
|
101
|
+
*/
|
|
102
|
+
function getTriangle(exit: Point, rect: DOMRect): [Point, Point, Point] {
|
|
103
|
+
const cx = rect.left + rect.width / 2;
|
|
104
|
+
const cy = rect.top + rect.height / 2;
|
|
105
|
+
|
|
106
|
+
// Determine primary axis — is the popup more horizontal or vertical?
|
|
107
|
+
const dx = cx - exit.x;
|
|
108
|
+
const dy = cy - exit.y;
|
|
109
|
+
|
|
110
|
+
if (Math.abs(dy) >= Math.abs(dx)) {
|
|
111
|
+
// Popup is above or below — use left and right edges
|
|
112
|
+
if (dy > 0) {
|
|
113
|
+
// Popup below
|
|
114
|
+
return [exit, { x: rect.left, y: rect.top }, { x: rect.right, y: rect.top }];
|
|
115
|
+
}
|
|
116
|
+
// Popup above
|
|
117
|
+
return [exit, { x: rect.left, y: rect.bottom }, { x: rect.right, y: rect.bottom }];
|
|
118
|
+
}
|
|
119
|
+
// Popup is to the left or right — use top and bottom edges
|
|
120
|
+
if (dx > 0) {
|
|
121
|
+
// Popup right
|
|
122
|
+
return [exit, { x: rect.left, y: rect.top }, { x: rect.left, y: rect.bottom }];
|
|
123
|
+
}
|
|
124
|
+
// Popup left
|
|
125
|
+
return [exit, { x: rect.right, y: rect.top }, { x: rect.right, y: rect.bottom }];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Point-in-triangle test using cross products (sign method).
|
|
130
|
+
*/
|
|
131
|
+
function pointInTriangle(p: Point, [a, b, c]: [Point, Point, Point]): boolean {
|
|
132
|
+
const d1 = sign(p, a, b);
|
|
133
|
+
const d2 = sign(p, b, c);
|
|
134
|
+
const d3 = sign(p, c, a);
|
|
135
|
+
|
|
136
|
+
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
|
|
137
|
+
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
|
|
138
|
+
|
|
139
|
+
return !(hasNeg && hasPos);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sign(p1: Point, p2: Point, p3: Point): number {
|
|
143
|
+
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
|
|
144
|
+
}
|