@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,118 @@
|
|
|
1
|
+
import { Menu } from "@base-ui/react/menu";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDropdownStore } from "./DropdownStore";
|
|
4
|
+
import "./Dropdown.css";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Singleton renderer — mount once at the app root.
|
|
8
|
+
* Subscribes to the global DropdownStore and renders the active dropdown
|
|
9
|
+
* using Base UI's Menu.Root + Menu.Positioner.
|
|
10
|
+
*
|
|
11
|
+
* Menu.Root stays mounted so CSS transitions can play on both open and close.
|
|
12
|
+
* Content and anchor are swapped when a new dropdown opens.
|
|
13
|
+
*/
|
|
14
|
+
export function DropdownRenderer() {
|
|
15
|
+
const open = useDropdownStore((s) => s.open);
|
|
16
|
+
const content = useDropdownStore((s) => s.content);
|
|
17
|
+
const anchor = useDropdownStore((s) => s.anchor);
|
|
18
|
+
const keyboardOpen = useDropdownStore((s) => s.keyboardOpen);
|
|
19
|
+
const generation = useDropdownStore((s) => s.openGeneration);
|
|
20
|
+
|
|
21
|
+
// Keep content/anchor alive during close animation so the popup
|
|
22
|
+
// doesn't disappear before the transition finishes.
|
|
23
|
+
const lastContentRef = useRef(content);
|
|
24
|
+
const lastAnchorRef = useRef(anchor);
|
|
25
|
+
if (content) lastContentRef.current = content;
|
|
26
|
+
if (anchor) lastAnchorRef.current = anchor;
|
|
27
|
+
|
|
28
|
+
const activeContent = content ?? lastContentRef.current;
|
|
29
|
+
const activeAnchor = anchor ?? lastAnchorRef.current;
|
|
30
|
+
|
|
31
|
+
// Track whether we've ever had content (don't mount Menu.Root until first open)
|
|
32
|
+
const [hasOpened, setHasOpened] = useState(false);
|
|
33
|
+
// Defer the first open by one frame so Menu.Root mounts with open={false},
|
|
34
|
+
// then transitions to open={true} — enabling the starting-style animation.
|
|
35
|
+
const [deferredOpen, setDeferredOpen] = useState(false);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (open && !hasOpened) {
|
|
38
|
+
setHasOpened(true);
|
|
39
|
+
requestAnimationFrame(() => setDeferredOpen(true));
|
|
40
|
+
} else {
|
|
41
|
+
setDeferredOpen(open);
|
|
42
|
+
}
|
|
43
|
+
}, [open, hasOpened]);
|
|
44
|
+
|
|
45
|
+
// Restore focus to the trigger when the menu closes.
|
|
46
|
+
// Use RAF to run after Menu.Root's own focus restoration.
|
|
47
|
+
const prevOpenRef = useRef(false);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (prevOpenRef.current && !open && lastAnchorRef.current) {
|
|
50
|
+
const el = lastAnchorRef.current;
|
|
51
|
+
requestAnimationFrame(() => el.focus());
|
|
52
|
+
}
|
|
53
|
+
prevOpenRef.current = open;
|
|
54
|
+
}, [open]);
|
|
55
|
+
|
|
56
|
+
const callbacks = useDropdownStore((s) => s.callbacks);
|
|
57
|
+
const menuConfig = useDropdownStore((s) => s.menuConfig);
|
|
58
|
+
const positionConfig = useDropdownStore((s) => s.positionConfig);
|
|
59
|
+
|
|
60
|
+
// Auto-dismiss when the trigger scrolls out of view (non-modal only).
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open || !anchor || menuConfig.modal !== false) return;
|
|
63
|
+
const observer = new IntersectionObserver(
|
|
64
|
+
([entry]) => {
|
|
65
|
+
if (!entry.isIntersecting) {
|
|
66
|
+
useDropdownStore.getState().closeDropdown();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{ threshold: 0 },
|
|
70
|
+
);
|
|
71
|
+
observer.observe(anchor);
|
|
72
|
+
return () => observer.disconnect();
|
|
73
|
+
}, [open, anchor, menuConfig.modal]);
|
|
74
|
+
const lastCallbacksRef = useRef(callbacks);
|
|
75
|
+
if (callbacks.onOpenChange) lastCallbacksRef.current = callbacks;
|
|
76
|
+
|
|
77
|
+
// Clear stale content after close animation completes
|
|
78
|
+
const handleOpenChangeComplete = (isOpen: boolean) => {
|
|
79
|
+
lastCallbacksRef.current.onOpenChangeComplete?.(isOpen);
|
|
80
|
+
if (!isOpen) {
|
|
81
|
+
lastContentRef.current = null;
|
|
82
|
+
lastAnchorRef.current = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (!hasOpened) return null;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Menu.Root
|
|
90
|
+
open={deferredOpen}
|
|
91
|
+
modal={menuConfig.modal}
|
|
92
|
+
loopFocus={menuConfig.loopFocus}
|
|
93
|
+
highlightItemOnHover={menuConfig.highlightItemOnHover}
|
|
94
|
+
orientation={menuConfig.orientation}
|
|
95
|
+
onOpenChange={(isOpen, eventDetails) => {
|
|
96
|
+
if (!isOpen && eventDetails.reason !== "trigger-hover") {
|
|
97
|
+
lastCallbacksRef.current.onOpenChange?.(false);
|
|
98
|
+
useDropdownStore.getState().closeDropdown(generation);
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
onOpenChangeComplete={handleOpenChangeComplete}
|
|
102
|
+
>
|
|
103
|
+
<Menu.Portal>
|
|
104
|
+
<Menu.Positioner
|
|
105
|
+
anchor={activeAnchor}
|
|
106
|
+
className="slithy-dropdown-positioner"
|
|
107
|
+
side={positionConfig.side}
|
|
108
|
+
sideOffset={positionConfig.sideOffset ?? 4}
|
|
109
|
+
align={positionConfig.align}
|
|
110
|
+
alignOffset={positionConfig.alignOffset}
|
|
111
|
+
collisionPadding={positionConfig.collisionPadding}
|
|
112
|
+
>
|
|
113
|
+
{activeContent}
|
|
114
|
+
</Menu.Positioner>
|
|
115
|
+
</Menu.Portal>
|
|
116
|
+
</Menu.Root>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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 DropdownCallbacks = {
|
|
11
|
+
onOpenChange?: (open: boolean) => void;
|
|
12
|
+
onOpenChangeComplete?: (open: boolean) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type DropdownMenuConfig = {
|
|
16
|
+
modal?: boolean;
|
|
17
|
+
loopFocus?: boolean;
|
|
18
|
+
highlightItemOnHover?: boolean;
|
|
19
|
+
orientation?: "vertical" | "horizontal";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DropdownPositionConfig = {
|
|
23
|
+
side?: "top" | "bottom" | "left" | "right" | "inline-end" | "inline-start";
|
|
24
|
+
sideOffset?: number;
|
|
25
|
+
align?: "start" | "center" | "end";
|
|
26
|
+
alignOffset?: number;
|
|
27
|
+
collisionPadding?: number | Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type DropdownStoreState = {
|
|
31
|
+
open: boolean;
|
|
32
|
+
content: ReactNode | null;
|
|
33
|
+
anchor: HTMLElement | null;
|
|
34
|
+
keyboardOpen: boolean;
|
|
35
|
+
callbacks: DropdownCallbacks;
|
|
36
|
+
menuConfig: DropdownMenuConfig;
|
|
37
|
+
positionConfig: DropdownPositionConfig;
|
|
38
|
+
/** Monotonically increasing — lets the renderer ignore stale dismiss events */
|
|
39
|
+
openGeneration: number;
|
|
40
|
+
openDropdown: (
|
|
41
|
+
content: ReactNode,
|
|
42
|
+
anchor: HTMLElement,
|
|
43
|
+
options?: { keyboard?: boolean; callbacks?: DropdownCallbacks; menuConfig?: DropdownMenuConfig; positionConfig?: DropdownPositionConfig },
|
|
44
|
+
) => void;
|
|
45
|
+
closeDropdown: (generation?: number) => void;
|
|
46
|
+
updateContent: (content: ReactNode) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
50
|
+
if (Object.is(a, b)) return true;
|
|
51
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
|
|
52
|
+
return false;
|
|
53
|
+
const keysA = Object.keys(a);
|
|
54
|
+
const keysB = Object.keys(b);
|
|
55
|
+
if (keysA.length !== keysB.length) return false;
|
|
56
|
+
return keysA.every((k) =>
|
|
57
|
+
Object.is(
|
|
58
|
+
(a as Record<string, unknown>)[k],
|
|
59
|
+
(b as Record<string, unknown>)[k],
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createDropdownStore() {
|
|
65
|
+
const listeners = new Set<Listener>();
|
|
66
|
+
|
|
67
|
+
function notify() {
|
|
68
|
+
listeners.forEach((l) => l());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let state: DropdownStoreState = {
|
|
72
|
+
open: false,
|
|
73
|
+
content: null,
|
|
74
|
+
anchor: null,
|
|
75
|
+
keyboardOpen: false,
|
|
76
|
+
callbacks: {},
|
|
77
|
+
menuConfig: {},
|
|
78
|
+
positionConfig: {},
|
|
79
|
+
openGeneration: 0,
|
|
80
|
+
|
|
81
|
+
openDropdown(content, anchor, options) {
|
|
82
|
+
state = {
|
|
83
|
+
...state,
|
|
84
|
+
open: true,
|
|
85
|
+
content,
|
|
86
|
+
anchor,
|
|
87
|
+
keyboardOpen: options?.keyboard ?? false,
|
|
88
|
+
callbacks: options?.callbacks ?? {},
|
|
89
|
+
menuConfig: options?.menuConfig ?? {},
|
|
90
|
+
positionConfig: options?.positionConfig ?? {},
|
|
91
|
+
openGeneration: state.openGeneration + 1,
|
|
92
|
+
};
|
|
93
|
+
notify();
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
closeDropdown(generation) {
|
|
97
|
+
// Ignore stale dismiss events from a previous menu instance
|
|
98
|
+
if (generation !== undefined && generation !== state.openGeneration) return;
|
|
99
|
+
state = {
|
|
100
|
+
...state,
|
|
101
|
+
open: false,
|
|
102
|
+
content: null,
|
|
103
|
+
anchor: null,
|
|
104
|
+
keyboardOpen: false,
|
|
105
|
+
};
|
|
106
|
+
notify();
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
updateContent(content) {
|
|
110
|
+
if (!state.open || state.content === content) return;
|
|
111
|
+
state = { ...state, content };
|
|
112
|
+
notify();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
getState: () => state,
|
|
118
|
+
subscribe: (listener: Listener) => {
|
|
119
|
+
listeners.add(listener);
|
|
120
|
+
return () => listeners.delete(listener);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const store = createDropdownStore();
|
|
126
|
+
|
|
127
|
+
export function useDropdownStore<T>(selector: (state: DropdownStoreState) => T): T {
|
|
128
|
+
const selectorRef = useRef(selector);
|
|
129
|
+
selectorRef.current = selector;
|
|
130
|
+
|
|
131
|
+
const prevRef = useRef<T | undefined>(undefined);
|
|
132
|
+
|
|
133
|
+
const getSnapshot = useCallback(() => {
|
|
134
|
+
const result = selectorRef.current(store.getState());
|
|
135
|
+
const prev = prevRef.current;
|
|
136
|
+
if (prev !== undefined && shallowEqual(prev, result)) {
|
|
137
|
+
return prev;
|
|
138
|
+
}
|
|
139
|
+
prevRef.current = result;
|
|
140
|
+
return result;
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Direct access for imperative use inside event handlers */
|
|
147
|
+
useDropdownStore.getState = store.getState;
|
package/src/Dropdown/index.ts
CHANGED