notionsoft-ui 1.0.15 → 1.0.16
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/package.json +1 -1
- package/src/notion-ui/button/Button.stories.tsx +6 -0
- package/src/notion-ui/button/button.tsx +10 -6
- package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +1 -1
- package/src/notion-ui/circle-loader/CircleLoader.stories.tsx +1 -1
- package/src/notion-ui/input/input.tsx +21 -21
- package/src/notion-ui/multi-select-input/index.ts +3 -0
- package/src/notion-ui/multi-select-input/multi-select-input.stories.tsx +148 -0
- package/src/notion-ui/multi-select-input/multi-select-input.tsx +466 -0
- package/src/notion-ui/search-input/index.ts +3 -0
- package/src/notion-ui/search-input/search-input.tsx +389 -0
- package/src/notion-ui/search-input/search.Input.stories.tsx +131 -0
- package/src/notion-ui/{boolean-status-button/BooleanStatusButton.stories.tsx → status-button/status-button.stories.tsx} +3 -3
- package/src/notion-ui/{boolean-status-button/BooleanStatusButton.tsx → status-button/status-button.tsx} +2 -2
- /package/src/notion-ui/{boolean-status-button → status-button}/index.ts +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { createPortal } from "react-dom";
|
|
9
|
+
import { cn } from "../../utils/cn";
|
|
10
|
+
import Input from "../input";
|
|
11
|
+
import { Check, CheckCheck, Eraser, ListFilter, X } from "lucide-react";
|
|
12
|
+
import CircleLoader from "../circle-loader";
|
|
13
|
+
|
|
14
|
+
export interface FilterItem {
|
|
15
|
+
key: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MultiSelectInputProps<T = any>
|
|
20
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onSelect"> {
|
|
21
|
+
fetch: (
|
|
22
|
+
value: string,
|
|
23
|
+
filters?: Record<string, boolean>,
|
|
24
|
+
maxFetch?: number
|
|
25
|
+
) => Promise<T[]>;
|
|
26
|
+
renderItem?: (item: T, selected?: boolean) => React.ReactNode;
|
|
27
|
+
filters?: FilterItem[];
|
|
28
|
+
onFiltersChange?: (filtersState: Record<string, boolean>) => void;
|
|
29
|
+
debounceValue?: number;
|
|
30
|
+
parentClassName?: string;
|
|
31
|
+
text?: {
|
|
32
|
+
fetch?: string;
|
|
33
|
+
notItem?: string;
|
|
34
|
+
maxRecord?: string;
|
|
35
|
+
clearFilters?: string;
|
|
36
|
+
};
|
|
37
|
+
endContent?: React.ReactNode;
|
|
38
|
+
selectionMode?: "single" | "multiple";
|
|
39
|
+
selected?: T | T[];
|
|
40
|
+
onItemsSelect?: (selected: T | T[]) => void;
|
|
41
|
+
searchBy?: keyof T | (keyof T)[];
|
|
42
|
+
itemKey?: keyof T;
|
|
43
|
+
STORAGE_KEY?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function MultiSelectInputInner<T = any>(
|
|
47
|
+
{
|
|
48
|
+
fetch,
|
|
49
|
+
renderItem,
|
|
50
|
+
filters = [],
|
|
51
|
+
onFiltersChange,
|
|
52
|
+
debounceValue = 500,
|
|
53
|
+
parentClassName,
|
|
54
|
+
text = {
|
|
55
|
+
fetch: "Fetching...",
|
|
56
|
+
notItem: "No results found",
|
|
57
|
+
maxRecord: "Max records",
|
|
58
|
+
clearFilters: "Clear Filters",
|
|
59
|
+
},
|
|
60
|
+
endContent,
|
|
61
|
+
STORAGE_KEY = "FILTER_STORAGE_KEY",
|
|
62
|
+
selectionMode,
|
|
63
|
+
selected,
|
|
64
|
+
onItemsSelect,
|
|
65
|
+
searchBy,
|
|
66
|
+
itemKey,
|
|
67
|
+
...props
|
|
68
|
+
}: MultiSelectInputProps<T>,
|
|
69
|
+
ref: React.Ref<HTMLInputElement>
|
|
70
|
+
) {
|
|
71
|
+
const [inputValue, setInputValue] = useState("");
|
|
72
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
73
|
+
const [showFilters, setShowFilters] = useState(false);
|
|
74
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
75
|
+
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
76
|
+
const [items, setItems] = useState<T[]>([]);
|
|
77
|
+
const [filtersState, setFiltersState] = useState<Record<string, boolean>>(
|
|
78
|
+
() => {
|
|
79
|
+
try {
|
|
80
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
81
|
+
if (saved) return JSON.parse(saved);
|
|
82
|
+
} catch {}
|
|
83
|
+
return filters.reduce((acc, f) => ({ ...acc, [f.key]: false }), {});
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const [maxFetch, setMaxFetch] = useState<number | "">(() => {
|
|
88
|
+
try {
|
|
89
|
+
const saved = localStorage.getItem(`${STORAGE_KEY}_MAX_FETCH`);
|
|
90
|
+
return saved ? Number(saved) : "";
|
|
91
|
+
} catch {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const [selectedItems, setSelectedItems] = useState<T[]>(
|
|
97
|
+
Array.isArray(selected) ? selected : selected ? [selected] : []
|
|
98
|
+
);
|
|
99
|
+
const [pendingSelection, setPendingSelection] = useState<T[] | T | null>(
|
|
100
|
+
null
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
104
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
105
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
106
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
|
|
108
|
+
const debouncedValue = useDebounce(inputValue, debounceValue);
|
|
109
|
+
|
|
110
|
+
// Fetch items
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const get = async () => {
|
|
113
|
+
setIsFetching(true);
|
|
114
|
+
try {
|
|
115
|
+
const data = await fetch(
|
|
116
|
+
debouncedValue,
|
|
117
|
+
filtersState,
|
|
118
|
+
maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
|
|
119
|
+
);
|
|
120
|
+
setItems(data);
|
|
121
|
+
} catch {
|
|
122
|
+
setItems([]);
|
|
123
|
+
} finally {
|
|
124
|
+
setIsFetching(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
get();
|
|
128
|
+
}, [debouncedValue, fetch, filtersState, maxFetch]);
|
|
129
|
+
|
|
130
|
+
// Update dropdown position
|
|
131
|
+
const updatePosition = () => {
|
|
132
|
+
const el = containerRef.current;
|
|
133
|
+
if (!el) return;
|
|
134
|
+
const rect = el.getBoundingClientRect();
|
|
135
|
+
setPosition({
|
|
136
|
+
top: rect.bottom + window.scrollY,
|
|
137
|
+
left: rect.left + window.scrollX,
|
|
138
|
+
width: rect.width,
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Focus handlers
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const el = inputRef.current;
|
|
145
|
+
if (!el) return;
|
|
146
|
+
const handleFocus = () => {
|
|
147
|
+
setIsFocused(true);
|
|
148
|
+
setShowFilters(false);
|
|
149
|
+
updatePosition();
|
|
150
|
+
};
|
|
151
|
+
el.addEventListener("focus", handleFocus);
|
|
152
|
+
return () => el.removeEventListener("focus", handleFocus);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
// Click outside
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
158
|
+
if (!wrapperRef.current || !dropdownRef.current) return;
|
|
159
|
+
if (
|
|
160
|
+
!wrapperRef.current.contains(e.target as Node) &&
|
|
161
|
+
!dropdownRef.current.contains(e.target as Node)
|
|
162
|
+
) {
|
|
163
|
+
setIsFocused(false);
|
|
164
|
+
setShowFilters(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
168
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
// Window resize/scroll
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!isFocused && !showFilters) return;
|
|
174
|
+
updatePosition();
|
|
175
|
+
window.addEventListener("resize", updatePosition);
|
|
176
|
+
window.addEventListener("scroll", updatePosition, true);
|
|
177
|
+
return () => {
|
|
178
|
+
window.removeEventListener("resize", updatePosition);
|
|
179
|
+
window.removeEventListener("scroll", updatePosition, true);
|
|
180
|
+
};
|
|
181
|
+
}, [isFocused, showFilters]);
|
|
182
|
+
|
|
183
|
+
const inputOnChange = useCallback(
|
|
184
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
185
|
+
setInputValue(e.target.value);
|
|
186
|
+
},
|
|
187
|
+
[]
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const handleFilterChange = (key: string, value: boolean) => {
|
|
191
|
+
setFiltersState((prev) => {
|
|
192
|
+
const next = { ...prev, [key]: value };
|
|
193
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
194
|
+
onFiltersChange?.(next);
|
|
195
|
+
return next;
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const handleItemClick = (item: T) => {
|
|
199
|
+
const key = itemKey ? (item as any)[itemKey] : undefined;
|
|
200
|
+
|
|
201
|
+
if (selectionMode === "single") {
|
|
202
|
+
setSelectedItems([item]);
|
|
203
|
+
setPendingSelection(item); // defer parent update
|
|
204
|
+
setIsFocused(false);
|
|
205
|
+
} else {
|
|
206
|
+
setSelectedItems((prev) => {
|
|
207
|
+
const exists = prev.some((i) => key && (i as any)[itemKey] === key);
|
|
208
|
+
const next = exists
|
|
209
|
+
? prev.filter((i) => key && (i as any)[itemKey] !== key)
|
|
210
|
+
: [...prev, item];
|
|
211
|
+
setPendingSelection(next); // defer parent update
|
|
212
|
+
return next;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Use useLayoutEffect to call parent callback **after render but before paint**
|
|
218
|
+
useLayoutEffect(() => {
|
|
219
|
+
if (pendingSelection !== null) {
|
|
220
|
+
onItemsSelect?.(pendingSelection);
|
|
221
|
+
setPendingSelection(null);
|
|
222
|
+
}
|
|
223
|
+
}, [pendingSelection, onItemsSelect]);
|
|
224
|
+
|
|
225
|
+
const clearIcon = (
|
|
226
|
+
<X
|
|
227
|
+
onClick={() => {
|
|
228
|
+
setInputValue("");
|
|
229
|
+
setSelectedItems([]);
|
|
230
|
+
onItemsSelect?.([]);
|
|
231
|
+
}}
|
|
232
|
+
className="hover:bg-tertiary/10 hover:text-tertiary size-[38px] p-3 cursor-pointer text-primary/60 rounded transition-colors"
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
const endIcon = endContent ?? clearIcon;
|
|
236
|
+
|
|
237
|
+
const dropdown =
|
|
238
|
+
(isFocused || showFilters) &&
|
|
239
|
+
Dropdown(
|
|
240
|
+
position,
|
|
241
|
+
isFetching,
|
|
242
|
+
text,
|
|
243
|
+
filters,
|
|
244
|
+
filtersState,
|
|
245
|
+
showFilters,
|
|
246
|
+
handleFilterChange,
|
|
247
|
+
items,
|
|
248
|
+
renderItem,
|
|
249
|
+
dropdownRef,
|
|
250
|
+
maxFetch,
|
|
251
|
+
setMaxFetch,
|
|
252
|
+
STORAGE_KEY,
|
|
253
|
+
onFiltersChange,
|
|
254
|
+
handleItemClick,
|
|
255
|
+
selectedItems,
|
|
256
|
+
searchBy,
|
|
257
|
+
itemKey,
|
|
258
|
+
setSelectedItems,
|
|
259
|
+
setInputValue,
|
|
260
|
+
onItemsSelect
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div ref={wrapperRef}>
|
|
265
|
+
<div ref={containerRef} className={cn("w-full", parentClassName)}>
|
|
266
|
+
<Input
|
|
267
|
+
ref={ref || inputRef}
|
|
268
|
+
{...props}
|
|
269
|
+
value={inputValue}
|
|
270
|
+
onChange={inputOnChange}
|
|
271
|
+
endContent={
|
|
272
|
+
<div className="flex items-center gap-1 relative ltr:-right-1 rtl:-left-1">
|
|
273
|
+
{isFocused && endIcon}
|
|
274
|
+
{filters.length !== 0 && (
|
|
275
|
+
<ListFilter
|
|
276
|
+
onClick={() => {
|
|
277
|
+
updatePosition();
|
|
278
|
+
setShowFilters((prev) => !prev);
|
|
279
|
+
setIsFocused(false);
|
|
280
|
+
}}
|
|
281
|
+
className={cn(
|
|
282
|
+
"text-primary/50 hover:bg-tertiary/10 hover:text-tertiary size-[38px] p-3 cursor-pointer rounded transition-colors",
|
|
283
|
+
showFilters && "text-tertiary"
|
|
284
|
+
)}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
{dropdown}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const MultiSelectInputForward = React.forwardRef(MultiSelectInputInner) as <
|
|
297
|
+
T = any
|
|
298
|
+
>(
|
|
299
|
+
props: MultiSelectInputProps<T> & { ref?: React.Ref<HTMLInputElement> }
|
|
300
|
+
) => React.ReactElement;
|
|
301
|
+
|
|
302
|
+
export default MultiSelectInputForward;
|
|
303
|
+
|
|
304
|
+
// ---------------- Debounce Hook ----------------
|
|
305
|
+
export function useDebounce<T>(value: T, delay?: number): T {
|
|
306
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
|
309
|
+
return () => clearTimeout(timer);
|
|
310
|
+
}, [value, delay]);
|
|
311
|
+
return debouncedValue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------- Dropdown ----------------
|
|
315
|
+
const Dropdown = <T,>(
|
|
316
|
+
position: { top: number; left: number; width: number },
|
|
317
|
+
isFetching: boolean,
|
|
318
|
+
text: {
|
|
319
|
+
fetch?: string;
|
|
320
|
+
notItem?: string;
|
|
321
|
+
maxRecord?: string;
|
|
322
|
+
clearFilters?: string;
|
|
323
|
+
},
|
|
324
|
+
filters: FilterItem[],
|
|
325
|
+
filtersState: Record<string, boolean>,
|
|
326
|
+
showFilters: boolean | undefined,
|
|
327
|
+
handleFilterChange: (key: string, value: boolean) => void,
|
|
328
|
+
items: T[],
|
|
329
|
+
renderItem?: (item: T, selected?: boolean) => React.ReactNode,
|
|
330
|
+
dropdownRef?: React.Ref<HTMLDivElement>,
|
|
331
|
+
maxFetch?: number | "",
|
|
332
|
+
setMaxFetch?: React.Dispatch<React.SetStateAction<number | "">>,
|
|
333
|
+
STORAGE_KEY?: string,
|
|
334
|
+
onFiltersChange?: (filtersState: Record<string, boolean>) => void,
|
|
335
|
+
handleItemClick?: (item: T) => void,
|
|
336
|
+
selectedItems?: T[],
|
|
337
|
+
searchBy?: keyof T | (keyof T)[],
|
|
338
|
+
itemKey?: keyof T,
|
|
339
|
+
setSelectedItems?: React.Dispatch<React.SetStateAction<T[]>>,
|
|
340
|
+
setInputValue?: React.Dispatch<React.SetStateAction<string>>,
|
|
341
|
+
onItemsSelect?: (selected: T | T[]) => void
|
|
342
|
+
) =>
|
|
343
|
+
createPortal(
|
|
344
|
+
<div
|
|
345
|
+
ref={dropdownRef}
|
|
346
|
+
className="absolute z-50 border border-border rounded-b bg-card shadow-lg pt-3 pb-2"
|
|
347
|
+
style={{ top: position.top, left: position.left, width: position.width }}
|
|
348
|
+
>
|
|
349
|
+
{/* Filters Panel */}
|
|
350
|
+
{showFilters && filters.length > 0 && (
|
|
351
|
+
<div className="pb-3 px-3 flex flex-col gap-2 text-sm">
|
|
352
|
+
{filters.map((f) => (
|
|
353
|
+
<label key={f.key} className="flex items-center gap-2">
|
|
354
|
+
<input
|
|
355
|
+
type="checkbox"
|
|
356
|
+
checked={filtersState[f.key]}
|
|
357
|
+
onChange={(e) => handleFilterChange(f.key, e.target.checked)}
|
|
358
|
+
/>
|
|
359
|
+
|
|
360
|
+
{f.name}
|
|
361
|
+
</label>
|
|
362
|
+
))}
|
|
363
|
+
|
|
364
|
+
{setMaxFetch && (
|
|
365
|
+
<input
|
|
366
|
+
type="number"
|
|
367
|
+
min={1}
|
|
368
|
+
value={maxFetch}
|
|
369
|
+
onChange={(e) => {
|
|
370
|
+
const value = e.target.value ? Number(e.target.value) : "";
|
|
371
|
+
setMaxFetch(value);
|
|
372
|
+
if (STORAGE_KEY)
|
|
373
|
+
localStorage.setItem(
|
|
374
|
+
`${STORAGE_KEY}_MAX_FETCH`,
|
|
375
|
+
JSON.stringify(value)
|
|
376
|
+
);
|
|
377
|
+
}}
|
|
378
|
+
className={cn(
|
|
379
|
+
"selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded border bg-transparent px-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
380
|
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
381
|
+
"appearance-none placeholder:text-primary/60 ltr:text-sm rtl:text-sm rtl:font-semibold focus-visible:ring-0 focus-visible:shadow-sm focus-visible:ring-offset-0 transition-[border] bg-card dark:bg-black/30",
|
|
382
|
+
"focus-visible:border-tertiary/60",
|
|
383
|
+
"[&::-webkit-outer-spin-button]:appearance-none",
|
|
384
|
+
"[&::-webkit-inner-spin-button]:appearance-none",
|
|
385
|
+
"[-moz-appearance:textfield] "
|
|
386
|
+
)}
|
|
387
|
+
placeholder={text.maxRecord}
|
|
388
|
+
/>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* Clear Filters + Selected Items */}
|
|
392
|
+
{STORAGE_KEY && setMaxFetch && (
|
|
393
|
+
<button
|
|
394
|
+
onClick={() => {
|
|
395
|
+
// Clear filters
|
|
396
|
+
const cleared = filters.reduce(
|
|
397
|
+
(acc, f) => ({ ...acc, [f.key]: false }),
|
|
398
|
+
{}
|
|
399
|
+
);
|
|
400
|
+
Object.keys(cleared).forEach((key) =>
|
|
401
|
+
handleFilterChange(key, false)
|
|
402
|
+
);
|
|
403
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(cleared));
|
|
404
|
+
|
|
405
|
+
// Clear maxFetch
|
|
406
|
+
setMaxFetch("");
|
|
407
|
+
localStorage.removeItem(`${STORAGE_KEY}_MAX_FETCH`);
|
|
408
|
+
|
|
409
|
+
// Clear selected items and input
|
|
410
|
+
setSelectedItems?.([]);
|
|
411
|
+
setInputValue?.("");
|
|
412
|
+
onItemsSelect?.([]);
|
|
413
|
+
|
|
414
|
+
onFiltersChange?.(cleared);
|
|
415
|
+
}}
|
|
416
|
+
className="mt-2 flex items-center gap-x-1 text-sm cursor-pointer w-fit mx-auto text-red-600/90 hover:text-red-600"
|
|
417
|
+
>
|
|
418
|
+
<Eraser className="size-4" />
|
|
419
|
+
{text.clearFilters}
|
|
420
|
+
</button>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{!showFilters && isFetching && <CircleLoader label={text.fetch} />}
|
|
426
|
+
|
|
427
|
+
{!showFilters && !isFetching && (
|
|
428
|
+
<div className="max-h-60 overflow-auto">
|
|
429
|
+
{items.length > 0 ? (
|
|
430
|
+
items.map((item, index) => {
|
|
431
|
+
const keyVal = itemKey ? (item as any)[itemKey] : index;
|
|
432
|
+
const isSelected =
|
|
433
|
+
selectedItems?.some(
|
|
434
|
+
(i) => itemKey && (i as any)[itemKey] === keyVal
|
|
435
|
+
) ?? false;
|
|
436
|
+
|
|
437
|
+
const displayValue = Array.isArray(searchBy)
|
|
438
|
+
? searchBy.map((k) => (item as any)[k]).join(" / ")
|
|
439
|
+
: (item as any)[searchBy ?? "name"];
|
|
440
|
+
|
|
441
|
+
return renderItem ? (
|
|
442
|
+
renderItem(item, isSelected)
|
|
443
|
+
) : (
|
|
444
|
+
<div
|
|
445
|
+
key={keyVal}
|
|
446
|
+
className={cn(
|
|
447
|
+
"px-3 flex items-center gap-x-1 py-1 hover:bg-gray-100 cursor-pointer",
|
|
448
|
+
isSelected && "bg-gray-200"
|
|
449
|
+
)}
|
|
450
|
+
onClick={() => handleItemClick?.(item)}
|
|
451
|
+
>
|
|
452
|
+
{isSelected && <Check className="size-4" />}
|
|
453
|
+
{displayValue}
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
})
|
|
457
|
+
) : (
|
|
458
|
+
<div className="text-center text-sm text-gray-500">
|
|
459
|
+
{text.notItem}
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
</div>,
|
|
465
|
+
document.body
|
|
466
|
+
);
|