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