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