@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.
@@ -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;
@@ -1 +1,2 @@
1
- export { Tooltip } from './Tooltip';
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
+ }