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.
@@ -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
+ );
@@ -0,0 +1,3 @@
1
+ import SearchInput from "./search-input";
2
+
3
+ export default SearchInput;