@octavian-tocan/react-dropdown 1.0.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 +27 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/index.cjs +799 -0
- package/dist/index.d.cts +454 -0
- package/dist/index.d.ts +454 -0
- package/dist/index.js +779 -0
- package/package.json +97 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var React2 = require('react');
|
|
6
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
7
|
+
var reactDom = require('react-dom');
|
|
8
|
+
var react = require('motion/react');
|
|
9
|
+
|
|
10
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
11
|
+
|
|
12
|
+
var React2__default = /*#__PURE__*/_interopDefault(React2);
|
|
13
|
+
|
|
14
|
+
// src/DropdownRoot.tsx
|
|
15
|
+
var DropdownContext = React2.createContext(null);
|
|
16
|
+
function useDropdownContext() {
|
|
17
|
+
const context = React2.useContext(DropdownContext);
|
|
18
|
+
if (!context) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"useDropdownContext must be used within a DropdownProvider. Wrap your component with <DropdownRoot> to provide context."
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
function DropdownProvider({ children, value }) {
|
|
26
|
+
return /* @__PURE__ */ jsxRuntime.jsx(DropdownContext.Provider, { value, children });
|
|
27
|
+
}
|
|
28
|
+
function useKeyboardNavigation(items, getItemKey, onSelect, closeDropdown) {
|
|
29
|
+
const focusedIndexRef = React2.useRef(-1);
|
|
30
|
+
const [focusedIndex, setFocusedIndex] = React2.useState(-1);
|
|
31
|
+
const handleKeyDown = (event) => {
|
|
32
|
+
switch (event.key) {
|
|
33
|
+
case "ArrowDown":
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
focusedIndexRef.current = Math.min(focusedIndexRef.current + 1, items.length - 1);
|
|
36
|
+
setFocusedIndex(focusedIndexRef.current);
|
|
37
|
+
break;
|
|
38
|
+
case "ArrowUp":
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
focusedIndexRef.current = Math.max(focusedIndexRef.current - 1, 0);
|
|
41
|
+
setFocusedIndex(focusedIndexRef.current);
|
|
42
|
+
break;
|
|
43
|
+
case "Enter":
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
if (focusedIndexRef.current >= 0 && focusedIndexRef.current < items.length) {
|
|
46
|
+
onSelect(items[focusedIndexRef.current]);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
case "Escape":
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
closeDropdown();
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const resetFocus = () => {
|
|
56
|
+
focusedIndexRef.current = -1;
|
|
57
|
+
setFocusedIndex(-1);
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
handleKeyDown,
|
|
61
|
+
resetFocus,
|
|
62
|
+
focusedIndex
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function useClickOutside(dropdownRef, closeDropdown, isOpen) {
|
|
66
|
+
const closeDropdownRef = React2.useRef(closeDropdown);
|
|
67
|
+
closeDropdownRef.current = closeDropdown;
|
|
68
|
+
const handleClickOutside = React2.useCallback(
|
|
69
|
+
(event) => {
|
|
70
|
+
if (!dropdownRef.current) return;
|
|
71
|
+
const target = event.target;
|
|
72
|
+
if (!dropdownRef.current.contains(target)) {
|
|
73
|
+
closeDropdownRef.current();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[dropdownRef]
|
|
77
|
+
);
|
|
78
|
+
React2.useEffect(() => {
|
|
79
|
+
if (!isOpen) return;
|
|
80
|
+
document.addEventListener("mousedown", handleClickOutside, { passive: true });
|
|
81
|
+
document.addEventListener("touchstart", handleClickOutside, { passive: true });
|
|
82
|
+
return () => {
|
|
83
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
84
|
+
document.removeEventListener("touchstart", handleClickOutside);
|
|
85
|
+
};
|
|
86
|
+
}, [isOpen, handleClickOutside]);
|
|
87
|
+
}
|
|
88
|
+
function DropdownRoot({
|
|
89
|
+
children,
|
|
90
|
+
items,
|
|
91
|
+
selectedItem: initialSelectedItem = null,
|
|
92
|
+
onSelect,
|
|
93
|
+
getItemKey,
|
|
94
|
+
getItemDisplay,
|
|
95
|
+
filterItems,
|
|
96
|
+
disabled = false,
|
|
97
|
+
placeholder,
|
|
98
|
+
className = "",
|
|
99
|
+
dropdownPlacement = "bottom",
|
|
100
|
+
getItemDescription,
|
|
101
|
+
getItemIcon,
|
|
102
|
+
getItemSection,
|
|
103
|
+
getItemSeparator,
|
|
104
|
+
getItemDisabled,
|
|
105
|
+
getItemClassName,
|
|
106
|
+
closeOnSelect = true,
|
|
107
|
+
onOpenChange,
|
|
108
|
+
triggerRef,
|
|
109
|
+
usePortal = false,
|
|
110
|
+
"data-testid": testId = "dropdown-root"
|
|
111
|
+
}) {
|
|
112
|
+
const [isOpen, setIsOpen] = React2.useState(false);
|
|
113
|
+
const [selectedItem, setSelectedItem] = React2.useState(initialSelectedItem);
|
|
114
|
+
const [searchQuery, setSearchQuery] = React2.useState("");
|
|
115
|
+
const dropdownRef = React2.useRef(null);
|
|
116
|
+
const inputRef = React2.useRef(null);
|
|
117
|
+
const defaultFilter = React2.useCallback(
|
|
118
|
+
(items2, query) => {
|
|
119
|
+
if (!query.trim()) {
|
|
120
|
+
return items2;
|
|
121
|
+
}
|
|
122
|
+
const normalizedQuery = query.toLowerCase().trim();
|
|
123
|
+
return items2.filter((item) => getItemDisplay(item).toLowerCase().includes(normalizedQuery));
|
|
124
|
+
},
|
|
125
|
+
[getItemDisplay]
|
|
126
|
+
);
|
|
127
|
+
const filterFunction = filterItems || defaultFilter;
|
|
128
|
+
const filteredItems = React2.useMemo(() => {
|
|
129
|
+
return filterFunction(items, searchQuery);
|
|
130
|
+
}, [items, searchQuery, filterFunction]);
|
|
131
|
+
const openDropdown = React2.useCallback(() => {
|
|
132
|
+
if (disabled) return;
|
|
133
|
+
setIsOpen(true);
|
|
134
|
+
onOpenChange?.(true);
|
|
135
|
+
}, [disabled, onOpenChange]);
|
|
136
|
+
React2.useLayoutEffect(() => {
|
|
137
|
+
if (isOpen && inputRef.current) {
|
|
138
|
+
inputRef.current.focus();
|
|
139
|
+
}
|
|
140
|
+
}, [isOpen]);
|
|
141
|
+
const closeDropdown = React2.useCallback(() => {
|
|
142
|
+
setIsOpen(false);
|
|
143
|
+
setSearchQuery("");
|
|
144
|
+
onOpenChange?.(false);
|
|
145
|
+
}, [onOpenChange]);
|
|
146
|
+
const toggleDropdown = React2.useCallback(() => {
|
|
147
|
+
isOpen ? closeDropdown() : openDropdown();
|
|
148
|
+
}, [isOpen, openDropdown, closeDropdown]);
|
|
149
|
+
const handleSelect = React2.useCallback(
|
|
150
|
+
(item) => {
|
|
151
|
+
setSelectedItem(item);
|
|
152
|
+
onSelect(item);
|
|
153
|
+
if (closeOnSelect) {
|
|
154
|
+
closeDropdown();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
[onSelect, closeDropdown, closeOnSelect]
|
|
158
|
+
);
|
|
159
|
+
const handleSearchChange = React2.useCallback((query) => {
|
|
160
|
+
setSearchQuery(query);
|
|
161
|
+
}, []);
|
|
162
|
+
useClickOutside(dropdownRef, closeDropdown, isOpen);
|
|
163
|
+
const contextValue = React2.useMemo(
|
|
164
|
+
() => ({
|
|
165
|
+
isOpen,
|
|
166
|
+
setIsOpen,
|
|
167
|
+
selectedItem,
|
|
168
|
+
setSelectedItem,
|
|
169
|
+
searchQuery,
|
|
170
|
+
setSearchQuery: handleSearchChange,
|
|
171
|
+
items,
|
|
172
|
+
filteredItems,
|
|
173
|
+
getItemKey,
|
|
174
|
+
getItemDisplay,
|
|
175
|
+
filterItems: filterFunction,
|
|
176
|
+
onSelect: handleSelect,
|
|
177
|
+
disabled,
|
|
178
|
+
closeOnSelect,
|
|
179
|
+
closeDropdown,
|
|
180
|
+
toggleDropdown,
|
|
181
|
+
dropdownPlacement,
|
|
182
|
+
getItemDescription,
|
|
183
|
+
getItemIcon,
|
|
184
|
+
getItemSection,
|
|
185
|
+
getItemSeparator,
|
|
186
|
+
getItemDisabled,
|
|
187
|
+
getItemClassName,
|
|
188
|
+
triggerRef,
|
|
189
|
+
usePortal
|
|
190
|
+
}),
|
|
191
|
+
[
|
|
192
|
+
isOpen,
|
|
193
|
+
setIsOpen,
|
|
194
|
+
selectedItem,
|
|
195
|
+
setSelectedItem,
|
|
196
|
+
searchQuery,
|
|
197
|
+
handleSearchChange,
|
|
198
|
+
items,
|
|
199
|
+
filteredItems,
|
|
200
|
+
getItemKey,
|
|
201
|
+
getItemDisplay,
|
|
202
|
+
filterFunction,
|
|
203
|
+
handleSelect,
|
|
204
|
+
disabled,
|
|
205
|
+
closeOnSelect,
|
|
206
|
+
closeDropdown,
|
|
207
|
+
toggleDropdown,
|
|
208
|
+
dropdownPlacement,
|
|
209
|
+
getItemDescription,
|
|
210
|
+
getItemIcon,
|
|
211
|
+
getItemSection,
|
|
212
|
+
getItemSeparator,
|
|
213
|
+
getItemDisabled,
|
|
214
|
+
getItemClassName,
|
|
215
|
+
triggerRef,
|
|
216
|
+
usePortal
|
|
217
|
+
]
|
|
218
|
+
);
|
|
219
|
+
return /* @__PURE__ */ jsxRuntime.jsx(DropdownProvider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx("div", { ref: dropdownRef, className: `relative ${className}`, "data-testid": testId, children }) });
|
|
220
|
+
}
|
|
221
|
+
function DropdownTrigger({
|
|
222
|
+
displayValue,
|
|
223
|
+
placeholder = "Select an option",
|
|
224
|
+
className = "",
|
|
225
|
+
"data-testid": testId = "dropdown-trigger"
|
|
226
|
+
}) {
|
|
227
|
+
const { isOpen, toggleDropdown, disabled } = useDropdownContext();
|
|
228
|
+
const buildTriggerClassName = () => {
|
|
229
|
+
const baseClasses = "w-full flex items-center justify-between px-3 py-2 text-left border border-gray-300 rounded-lg bg-white transition-colors";
|
|
230
|
+
const interactiveClasses = "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#0B4F75] focus-visible:border-transparent hover:border-gray-400 cursor-pointer";
|
|
231
|
+
const disabledClasses = "opacity-50 cursor-not-allowed";
|
|
232
|
+
return `${baseClasses} ${disabled ? disabledClasses : interactiveClasses}`;
|
|
233
|
+
};
|
|
234
|
+
const buildTextClassName = () => {
|
|
235
|
+
return displayValue ? "text-gray-900" : "text-gray-400";
|
|
236
|
+
};
|
|
237
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
238
|
+
"button",
|
|
239
|
+
{
|
|
240
|
+
type: "button",
|
|
241
|
+
onClick: toggleDropdown,
|
|
242
|
+
disabled,
|
|
243
|
+
className: `${buildTriggerClassName()} ${className}`,
|
|
244
|
+
"aria-haspopup": "listbox",
|
|
245
|
+
"aria-expanded": isOpen,
|
|
246
|
+
"aria-label": displayValue ? void 0 : placeholder || "Select an option",
|
|
247
|
+
"data-testid": testId,
|
|
248
|
+
children: [
|
|
249
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: buildTextClassName(), children: displayValue || placeholder }),
|
|
250
|
+
/* @__PURE__ */ jsxRuntime.jsx(DropdownArrowIcon, { isOpen })
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
function DropdownArrowIcon({ isOpen }) {
|
|
256
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
257
|
+
"svg",
|
|
258
|
+
{
|
|
259
|
+
className: `size-4 transition-transform ${isOpen ? "rotate-180" : ""}`,
|
|
260
|
+
fill: "none",
|
|
261
|
+
stroke: "currentColor",
|
|
262
|
+
viewBox: "0 0 24 24",
|
|
263
|
+
"aria-hidden": "true",
|
|
264
|
+
children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M19 9l-7 7-7-7" })
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/design-tokens.ts
|
|
270
|
+
var ELEVATED_SHADOW = "0px 0px 0px 1px rgba(0,0,0,0.03), 0px 1px 1px 0px rgba(0,0,0,0.03), 0px 2px 2px 0px rgba(0,0,0,0.03), 0px 4px 4px 0px rgba(0,0,0,0.03), 0px 8px 8px 0px rgba(0,0,0,0.03), 0px 16px 16px 0px rgba(0,0,0,0.03)";
|
|
271
|
+
var EXIT_ANIMATION_DURATION_MS = 150;
|
|
272
|
+
function DropdownContent({
|
|
273
|
+
children,
|
|
274
|
+
className = "",
|
|
275
|
+
disableAnimation = false,
|
|
276
|
+
"data-testid": testId = "dropdown-content"
|
|
277
|
+
}) {
|
|
278
|
+
const { isOpen, dropdownPlacement, triggerRef, usePortal } = useDropdownContext();
|
|
279
|
+
const [shouldRender, setShouldRender] = React2.useState(isOpen);
|
|
280
|
+
const [isClosing, setIsClosing] = React2.useState(false);
|
|
281
|
+
const [portalPosition, setPortalPosition] = React2.useState({ top: 0, right: 0 });
|
|
282
|
+
const prevIsOpen = React2.useRef(isOpen);
|
|
283
|
+
React2.useEffect(() => {
|
|
284
|
+
if (usePortal && isOpen && triggerRef?.current) {
|
|
285
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
286
|
+
setPortalPosition({
|
|
287
|
+
top: rect.bottom + 4,
|
|
288
|
+
// 4px gap below trigger
|
|
289
|
+
right: window.innerWidth - rect.right
|
|
290
|
+
// Align dropdown's right edge with trigger's right edge
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}, [isOpen, usePortal, triggerRef]);
|
|
294
|
+
React2.useEffect(() => {
|
|
295
|
+
if (isOpen) {
|
|
296
|
+
setShouldRender(true);
|
|
297
|
+
setIsClosing(false);
|
|
298
|
+
} else if (prevIsOpen.current && !isOpen) {
|
|
299
|
+
if (disableAnimation) {
|
|
300
|
+
setShouldRender(false);
|
|
301
|
+
} else {
|
|
302
|
+
setIsClosing(true);
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
setShouldRender(false);
|
|
305
|
+
setIsClosing(false);
|
|
306
|
+
}, EXIT_ANIMATION_DURATION_MS);
|
|
307
|
+
return () => clearTimeout(timer);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
prevIsOpen.current = isOpen;
|
|
311
|
+
}, [isOpen, disableAnimation]);
|
|
312
|
+
const placementClass = dropdownPlacement === "top" ? "bottom-full mb-1" : "mt-1";
|
|
313
|
+
const flexDirClass = dropdownPlacement === "top" ? "flex-col-reverse" : "flex-col";
|
|
314
|
+
const transformOrigin = dropdownPlacement === "top" ? "bottom center" : "top center";
|
|
315
|
+
const variants = {
|
|
316
|
+
initial: {
|
|
317
|
+
opacity: 0,
|
|
318
|
+
scale: 0.95,
|
|
319
|
+
y: dropdownPlacement === "top" ? 8 : -8
|
|
320
|
+
},
|
|
321
|
+
animate: {
|
|
322
|
+
opacity: 1,
|
|
323
|
+
scale: 1,
|
|
324
|
+
y: 0,
|
|
325
|
+
boxShadow: ELEVATED_SHADOW
|
|
326
|
+
},
|
|
327
|
+
exit: {
|
|
328
|
+
opacity: 0,
|
|
329
|
+
scale: 0.95,
|
|
330
|
+
y: dropdownPlacement === "top" ? 8 : -8
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const content = /* @__PURE__ */ jsxRuntime.jsx(react.AnimatePresence, { children: shouldRender && /* @__PURE__ */ jsxRuntime.jsx(
|
|
334
|
+
react.motion.div,
|
|
335
|
+
{
|
|
336
|
+
className: `${usePortal ? "fixed" : "absolute"} z-50 ${usePortal ? "" : "w-full"} ${usePortal ? "" : "min-w-[320px]!"} ${usePortal ? "" : placementClass} bg-white border border-gray-200 rounded-lg flex ${flexDirClass} overflow-hidden ${className}`,
|
|
337
|
+
style: usePortal ? { top: portalPosition.top, right: portalPosition.right, transformOrigin } : { transformOrigin },
|
|
338
|
+
initial: disableAnimation ? false : "initial",
|
|
339
|
+
animate: "animate",
|
|
340
|
+
exit: disableAnimation ? void 0 : "exit",
|
|
341
|
+
variants,
|
|
342
|
+
transition: {
|
|
343
|
+
duration: 0.15,
|
|
344
|
+
ease: [0.16, 1, 0.3, 1]
|
|
345
|
+
// Custom ease for smooth feel
|
|
346
|
+
},
|
|
347
|
+
"data-testid": testId,
|
|
348
|
+
children
|
|
349
|
+
}
|
|
350
|
+
) });
|
|
351
|
+
if (usePortal && typeof document !== "undefined") {
|
|
352
|
+
return reactDom.createPortal(content, document.body);
|
|
353
|
+
}
|
|
354
|
+
return content;
|
|
355
|
+
}
|
|
356
|
+
var DropdownSearch = ({
|
|
357
|
+
value,
|
|
358
|
+
onChange,
|
|
359
|
+
inputRef,
|
|
360
|
+
placeholder = "Search...",
|
|
361
|
+
className = "",
|
|
362
|
+
"data-testid": testId = "dropdown-search"
|
|
363
|
+
}) => {
|
|
364
|
+
const { disabled } = useDropdownContext();
|
|
365
|
+
const handleKeyDown = (event) => {
|
|
366
|
+
if (event.key === "Tab") {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `px-2 py-1 border-b border-gray-200 shrink-0 ${className}`, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
371
|
+
"input",
|
|
372
|
+
{
|
|
373
|
+
ref: inputRef,
|
|
374
|
+
type: "text",
|
|
375
|
+
value,
|
|
376
|
+
onChange: (e) => onChange(e.target.value),
|
|
377
|
+
onKeyDown: handleKeyDown,
|
|
378
|
+
placeholder,
|
|
379
|
+
disabled,
|
|
380
|
+
className: "w-full px-2.5 py-1.5 text-sm border border-gray-300 rounded focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#0B4F75] focus-visible:border-transparent",
|
|
381
|
+
tabIndex: -1,
|
|
382
|
+
"data-testid": testId,
|
|
383
|
+
"aria-label": "Search options"
|
|
384
|
+
}
|
|
385
|
+
) });
|
|
386
|
+
};
|
|
387
|
+
var groupItemsBySection = (items, resolveSection) => {
|
|
388
|
+
if (!resolveSection) {
|
|
389
|
+
return { sections: [], ungrouped: items };
|
|
390
|
+
}
|
|
391
|
+
const sectionIndex = /* @__PURE__ */ new Map();
|
|
392
|
+
const sections = [];
|
|
393
|
+
const ungrouped = [];
|
|
394
|
+
items.forEach((item) => {
|
|
395
|
+
const section = resolveSection(item);
|
|
396
|
+
if (!section) {
|
|
397
|
+
ungrouped.push(item);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const existingIndex = sectionIndex.get(section.key);
|
|
401
|
+
if (existingIndex === void 0) {
|
|
402
|
+
sectionIndex.set(section.key, sections.length);
|
|
403
|
+
sections.push({ meta: section, items: [item] });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
sections[existingIndex].items.push(item);
|
|
407
|
+
});
|
|
408
|
+
return { sections, ungrouped };
|
|
409
|
+
};
|
|
410
|
+
function DropdownList({
|
|
411
|
+
items,
|
|
412
|
+
onSelect: customOnSelect,
|
|
413
|
+
hasResults,
|
|
414
|
+
selectedItem,
|
|
415
|
+
getItemKey,
|
|
416
|
+
getItemDisplay,
|
|
417
|
+
renderItem,
|
|
418
|
+
getItemDescription,
|
|
419
|
+
getItemIcon,
|
|
420
|
+
getItemSection,
|
|
421
|
+
getItemSeparator,
|
|
422
|
+
getItemDisabled,
|
|
423
|
+
getItemClassName,
|
|
424
|
+
className = "",
|
|
425
|
+
"data-testid": testId = "dropdown-list"
|
|
426
|
+
}) {
|
|
427
|
+
const {
|
|
428
|
+
isOpen,
|
|
429
|
+
getItemDescription: contextDescription,
|
|
430
|
+
getItemIcon: contextIcon,
|
|
431
|
+
getItemSection: contextSection,
|
|
432
|
+
getItemSeparator: contextSeparator,
|
|
433
|
+
getItemDisabled: contextDisabled,
|
|
434
|
+
getItemClassName: contextClassName,
|
|
435
|
+
onSelect: contextOnSelect,
|
|
436
|
+
closeDropdown,
|
|
437
|
+
closeOnSelect
|
|
438
|
+
} = useDropdownContext();
|
|
439
|
+
const listRef = React2.useRef(null);
|
|
440
|
+
const prevIsOpenRef = React2.useRef(isOpen);
|
|
441
|
+
React2.useEffect(() => {
|
|
442
|
+
if (isOpen && !prevIsOpenRef.current && listRef.current && selectedItem) {
|
|
443
|
+
const selectedElement = listRef.current.querySelector(
|
|
444
|
+
`[data-key="${getItemKey(selectedItem)}"]`
|
|
445
|
+
);
|
|
446
|
+
if (selectedElement) {
|
|
447
|
+
selectedElement.scrollIntoView({
|
|
448
|
+
behavior: "auto",
|
|
449
|
+
block: "start"
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
prevIsOpenRef.current = isOpen;
|
|
454
|
+
}, [isOpen, selectedItem, getItemKey]);
|
|
455
|
+
const descriptionAccessor = getItemDescription ?? contextDescription;
|
|
456
|
+
const iconAccessor = getItemIcon ?? contextIcon;
|
|
457
|
+
const sectionAccessor = getItemSection ?? contextSection;
|
|
458
|
+
const separatorAccessor = getItemSeparator ?? contextSeparator;
|
|
459
|
+
const disabledAccessor = getItemDisabled ?? contextDisabled;
|
|
460
|
+
const classNameAccessor = getItemClassName ?? contextClassName;
|
|
461
|
+
const groupedItems = React2.useMemo(
|
|
462
|
+
() => groupItemsBySection(items, sectionAccessor),
|
|
463
|
+
[items, sectionAccessor]
|
|
464
|
+
);
|
|
465
|
+
const resolveDescription = (item) => {
|
|
466
|
+
if (!descriptionAccessor) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
return descriptionAccessor(item) ?? null;
|
|
470
|
+
};
|
|
471
|
+
const resolveIcon = (item) => {
|
|
472
|
+
if (!iconAccessor) {
|
|
473
|
+
return void 0;
|
|
474
|
+
}
|
|
475
|
+
return iconAccessor(item);
|
|
476
|
+
};
|
|
477
|
+
const renderSectionHeader = (section) => {
|
|
478
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
479
|
+
"li",
|
|
480
|
+
{
|
|
481
|
+
role: "presentation",
|
|
482
|
+
className: "px-3 py-2 bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500",
|
|
483
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
484
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
|
|
485
|
+
section.meta.icon && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base text-gray-500", "aria-hidden": true, children: section.meta.icon }),
|
|
486
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { children: section.meta.label })
|
|
487
|
+
] }),
|
|
488
|
+
section.meta.description && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] font-normal normal-case text-gray-400", children: section.meta.description })
|
|
489
|
+
] })
|
|
490
|
+
},
|
|
491
|
+
`section-${section.meta.key}`
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
const resolvedOnSelect = React2.useMemo(() => {
|
|
495
|
+
const selectHandler = customOnSelect || contextOnSelect;
|
|
496
|
+
return (item) => {
|
|
497
|
+
selectHandler(item);
|
|
498
|
+
if (closeOnSelect) {
|
|
499
|
+
closeDropdown();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}, [customOnSelect, contextOnSelect, closeOnSelect, closeDropdown]);
|
|
503
|
+
const renderOption = (item) => {
|
|
504
|
+
const key = getItemKey(item);
|
|
505
|
+
const displayText = getItemDisplay(item);
|
|
506
|
+
const isSelected = selectedItem ? getItemKey(selectedItem) === key : false;
|
|
507
|
+
const isDisabled = disabledAccessor ? disabledAccessor(item) : false;
|
|
508
|
+
const customClassName = classNameAccessor ? classNameAccessor(item, isSelected, isDisabled) : "";
|
|
509
|
+
if (renderItem) {
|
|
510
|
+
return /* @__PURE__ */ jsxRuntime.jsx("li", { "data-key": key, children: renderItem(item, isSelected, resolvedOnSelect) }, key);
|
|
511
|
+
}
|
|
512
|
+
return /* @__PURE__ */ jsxRuntime.jsx("li", { "data-key": key, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
513
|
+
DropdownOption,
|
|
514
|
+
{
|
|
515
|
+
dataKey: key,
|
|
516
|
+
item,
|
|
517
|
+
onSelect: resolvedOnSelect,
|
|
518
|
+
isSelected,
|
|
519
|
+
displayText,
|
|
520
|
+
description: resolveDescription(item),
|
|
521
|
+
icon: resolveIcon(item),
|
|
522
|
+
isDisabled,
|
|
523
|
+
className: customClassName
|
|
524
|
+
}
|
|
525
|
+
) }, key);
|
|
526
|
+
};
|
|
527
|
+
if (!hasResults) {
|
|
528
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `p-4 text-center text-sm text-gray-500 ${className}`, children: "No results found" });
|
|
529
|
+
}
|
|
530
|
+
const renderedItems = [];
|
|
531
|
+
groupedItems.ungrouped.forEach((item) => {
|
|
532
|
+
const originalIndex = items.findIndex((i) => getItemKey(i) === getItemKey(item));
|
|
533
|
+
renderedItems.push(renderOption(item));
|
|
534
|
+
if (separatorAccessor && originalIndex >= 0 && separatorAccessor(item, originalIndex)) {
|
|
535
|
+
renderedItems.push(
|
|
536
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
537
|
+
"li",
|
|
538
|
+
{
|
|
539
|
+
role: "separator",
|
|
540
|
+
className: "border-b border-gray-200 my-1"
|
|
541
|
+
},
|
|
542
|
+
`separator-${originalIndex}`
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
groupedItems.sections.forEach((section) => {
|
|
548
|
+
renderedItems.push(renderSectionHeader(section));
|
|
549
|
+
section.items.forEach((item) => {
|
|
550
|
+
const originalIndex = items.findIndex((i) => getItemKey(i) === getItemKey(item));
|
|
551
|
+
renderedItems.push(renderOption(item));
|
|
552
|
+
if (separatorAccessor && originalIndex >= 0 && separatorAccessor(item, originalIndex)) {
|
|
553
|
+
renderedItems.push(
|
|
554
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
555
|
+
"li",
|
|
556
|
+
{
|
|
557
|
+
role: "separator",
|
|
558
|
+
className: "border-b border-gray-200 my-1"
|
|
559
|
+
},
|
|
560
|
+
`separator-${originalIndex}`
|
|
561
|
+
)
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
567
|
+
"ul",
|
|
568
|
+
{
|
|
569
|
+
ref: listRef,
|
|
570
|
+
className: `overflow-y-auto flex-1 min-h-0 flex flex-col no-scrollbar ${className}`,
|
|
571
|
+
role: "listbox",
|
|
572
|
+
"data-testid": testId,
|
|
573
|
+
children: renderedItems
|
|
574
|
+
}
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
function DropdownOption({
|
|
578
|
+
item,
|
|
579
|
+
onSelect,
|
|
580
|
+
isSelected,
|
|
581
|
+
displayText,
|
|
582
|
+
dataKey,
|
|
583
|
+
description,
|
|
584
|
+
icon,
|
|
585
|
+
isDisabled = false,
|
|
586
|
+
className = ""
|
|
587
|
+
}) {
|
|
588
|
+
const [isHovered, setIsHovered] = React2.useState(false);
|
|
589
|
+
const handleClick = () => {
|
|
590
|
+
if (!isDisabled) {
|
|
591
|
+
onSelect(item);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const baseClasses = "px-3 py-1.5 text-sm transition-colors";
|
|
595
|
+
const selectedClasses = isSelected ? "bg-blue-50 text-blue-600 font-medium" : "";
|
|
596
|
+
const hasCustomHover = className.includes("hover:");
|
|
597
|
+
const disabledClasses = isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
|
|
598
|
+
const classNameWithoutHover = className.replace(/hover:[^\s]+/g, "").trim();
|
|
599
|
+
const combinedClasses = `${baseClasses} ${selectedClasses} ${disabledClasses} ${classNameWithoutHover}`.trim();
|
|
600
|
+
const hoverBgMatch = className.match(/hover:!?bg-\[([^\]]+)\]/);
|
|
601
|
+
const hoverBgColor = hoverBgMatch ? hoverBgMatch[1] : hasCustomHover ? "#fee2e2" : "#f3f4f6";
|
|
602
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
603
|
+
"div",
|
|
604
|
+
{
|
|
605
|
+
"data-key": dataKey,
|
|
606
|
+
onClick: handleClick,
|
|
607
|
+
className: combinedClasses,
|
|
608
|
+
style: isHovered && !isDisabled ? { backgroundColor: hoverBgColor } : void 0,
|
|
609
|
+
role: "option",
|
|
610
|
+
"aria-selected": isSelected,
|
|
611
|
+
"aria-disabled": isDisabled,
|
|
612
|
+
onMouseEnter: () => setIsHovered(true),
|
|
613
|
+
onMouseLeave: () => setIsHovered(false),
|
|
614
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
|
|
615
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col flex-1", children: [
|
|
616
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { children: displayText }),
|
|
617
|
+
description && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-normal text-gray-500", children: description })
|
|
618
|
+
] }),
|
|
619
|
+
icon && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base", "aria-hidden": true, children: icon })
|
|
620
|
+
] })
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
function DropdownSearchable({
|
|
625
|
+
searchPlaceholder = "Search...",
|
|
626
|
+
className = "",
|
|
627
|
+
"data-testid": testId = "dropdown-searchable",
|
|
628
|
+
hideSearchThreshold
|
|
629
|
+
}) {
|
|
630
|
+
const {
|
|
631
|
+
searchQuery,
|
|
632
|
+
setSearchQuery,
|
|
633
|
+
filteredItems,
|
|
634
|
+
selectedItem,
|
|
635
|
+
getItemKey,
|
|
636
|
+
getItemDisplay,
|
|
637
|
+
getItemDescription,
|
|
638
|
+
getItemIcon,
|
|
639
|
+
getItemSection,
|
|
640
|
+
items
|
|
641
|
+
} = useDropdownContext();
|
|
642
|
+
const shouldShowSearch = hideSearchThreshold === void 0 || items.length > hideSearchThreshold;
|
|
643
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(DropdownContent, { className, "data-testid": testId, children: [
|
|
644
|
+
shouldShowSearch && /* @__PURE__ */ jsxRuntime.jsx(
|
|
645
|
+
DropdownSearch,
|
|
646
|
+
{
|
|
647
|
+
value: searchQuery,
|
|
648
|
+
onChange: setSearchQuery,
|
|
649
|
+
placeholder: searchPlaceholder
|
|
650
|
+
}
|
|
651
|
+
),
|
|
652
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
653
|
+
DropdownList,
|
|
654
|
+
{
|
|
655
|
+
items: filteredItems,
|
|
656
|
+
hasResults: filteredItems.length > 0,
|
|
657
|
+
selectedItem,
|
|
658
|
+
getItemKey,
|
|
659
|
+
getItemDisplay,
|
|
660
|
+
getItemDescription,
|
|
661
|
+
getItemIcon,
|
|
662
|
+
getItemSection
|
|
663
|
+
}
|
|
664
|
+
)
|
|
665
|
+
] });
|
|
666
|
+
}
|
|
667
|
+
function DropdownSimple({
|
|
668
|
+
className = "",
|
|
669
|
+
"data-testid": testId = "dropdown-simple"
|
|
670
|
+
}) {
|
|
671
|
+
const {
|
|
672
|
+
filteredItems,
|
|
673
|
+
selectedItem,
|
|
674
|
+
getItemKey,
|
|
675
|
+
getItemDisplay,
|
|
676
|
+
getItemDescription,
|
|
677
|
+
getItemIcon,
|
|
678
|
+
getItemSection
|
|
679
|
+
} = useDropdownContext();
|
|
680
|
+
return /* @__PURE__ */ jsxRuntime.jsx(DropdownContent, { className, "data-testid": testId, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
681
|
+
DropdownList,
|
|
682
|
+
{
|
|
683
|
+
items: filteredItems,
|
|
684
|
+
hasResults: filteredItems.length > 0,
|
|
685
|
+
selectedItem,
|
|
686
|
+
getItemKey,
|
|
687
|
+
getItemDisplay,
|
|
688
|
+
getItemDescription,
|
|
689
|
+
getItemIcon,
|
|
690
|
+
getItemSection
|
|
691
|
+
}
|
|
692
|
+
) });
|
|
693
|
+
}
|
|
694
|
+
var MenuTrigger = React2__default.default.forwardRef(
|
|
695
|
+
({ children }, ref) => {
|
|
696
|
+
const { isOpen, toggleDropdown } = useDropdownContext();
|
|
697
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { ref, onClick: toggleDropdown, "aria-expanded": isOpen, "aria-haspopup": "menu", children });
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
MenuTrigger.displayName = "MenuTrigger";
|
|
701
|
+
function DropdownMenu({
|
|
702
|
+
items,
|
|
703
|
+
onSelect,
|
|
704
|
+
getItemKey,
|
|
705
|
+
getItemDisplay,
|
|
706
|
+
getItemIcon,
|
|
707
|
+
getItemDescription,
|
|
708
|
+
getItemSeparator,
|
|
709
|
+
getItemDisabled,
|
|
710
|
+
getItemClassName,
|
|
711
|
+
getItemSection,
|
|
712
|
+
filterItems,
|
|
713
|
+
disabled = false,
|
|
714
|
+
placeholder,
|
|
715
|
+
className = "",
|
|
716
|
+
dropdownPlacement = "bottom",
|
|
717
|
+
closeOnSelect = true,
|
|
718
|
+
trigger,
|
|
719
|
+
contentClassName = "",
|
|
720
|
+
listClassName = "",
|
|
721
|
+
onOpenChange,
|
|
722
|
+
usePortal = false,
|
|
723
|
+
"data-testid": testId = "dropdown-menu"
|
|
724
|
+
}) {
|
|
725
|
+
const triggerRef = React2.useRef(null);
|
|
726
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
727
|
+
DropdownRoot,
|
|
728
|
+
{
|
|
729
|
+
items,
|
|
730
|
+
selectedItem: null,
|
|
731
|
+
onSelect,
|
|
732
|
+
getItemKey,
|
|
733
|
+
getItemDisplay,
|
|
734
|
+
getItemIcon,
|
|
735
|
+
getItemDescription,
|
|
736
|
+
getItemSeparator,
|
|
737
|
+
getItemDisabled,
|
|
738
|
+
getItemClassName,
|
|
739
|
+
getItemSection,
|
|
740
|
+
filterItems,
|
|
741
|
+
disabled,
|
|
742
|
+
placeholder,
|
|
743
|
+
className,
|
|
744
|
+
dropdownPlacement,
|
|
745
|
+
closeOnSelect,
|
|
746
|
+
onOpenChange,
|
|
747
|
+
triggerRef,
|
|
748
|
+
usePortal,
|
|
749
|
+
"data-testid": testId,
|
|
750
|
+
children: [
|
|
751
|
+
/* @__PURE__ */ jsxRuntime.jsx(MenuTrigger, { ref: triggerRef, children: trigger }),
|
|
752
|
+
/* @__PURE__ */ jsxRuntime.jsx(DropdownContent, { className: contentClassName, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
753
|
+
DropdownList,
|
|
754
|
+
{
|
|
755
|
+
items,
|
|
756
|
+
hasResults: items.length > 0,
|
|
757
|
+
selectedItem: null,
|
|
758
|
+
getItemKey,
|
|
759
|
+
getItemDisplay,
|
|
760
|
+
getItemIcon,
|
|
761
|
+
getItemDescription,
|
|
762
|
+
getItemSeparator,
|
|
763
|
+
getItemDisabled,
|
|
764
|
+
getItemClassName,
|
|
765
|
+
getItemSection,
|
|
766
|
+
className: listClassName
|
|
767
|
+
}
|
|
768
|
+
) })
|
|
769
|
+
]
|
|
770
|
+
}
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/index.ts
|
|
775
|
+
var Dropdown = {
|
|
776
|
+
Root: DropdownRoot,
|
|
777
|
+
Trigger: DropdownTrigger,
|
|
778
|
+
Content: DropdownContent,
|
|
779
|
+
Search: DropdownSearch,
|
|
780
|
+
List: DropdownList,
|
|
781
|
+
Simple: DropdownSimple,
|
|
782
|
+
Searchable: DropdownSearchable,
|
|
783
|
+
Menu: DropdownMenu
|
|
784
|
+
};
|
|
785
|
+
var index_default = Dropdown;
|
|
786
|
+
|
|
787
|
+
exports.DropdownContent = DropdownContent;
|
|
788
|
+
exports.DropdownList = DropdownList;
|
|
789
|
+
exports.DropdownMenu = DropdownMenu;
|
|
790
|
+
exports.DropdownProvider = DropdownProvider;
|
|
791
|
+
exports.DropdownRoot = DropdownRoot;
|
|
792
|
+
exports.DropdownSearch = DropdownSearch;
|
|
793
|
+
exports.DropdownSearchable = DropdownSearchable;
|
|
794
|
+
exports.DropdownSimple = DropdownSimple;
|
|
795
|
+
exports.DropdownTrigger = DropdownTrigger;
|
|
796
|
+
exports.default = index_default;
|
|
797
|
+
exports.useClickOutside = useClickOutside;
|
|
798
|
+
exports.useDropdownContext = useDropdownContext;
|
|
799
|
+
exports.useKeyboardNavigation = useKeyboardNavigation;
|