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,389 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { cn } from "../../utils/cn";
4
+ import Input from "../input";
5
+ import { Eraser, ListFilter, X } from "lucide-react";
6
+ import CircleLoader from "../circle-loader";
7
+
8
+ export type NastranInputSize = "sm" | "md" | "lg";
9
+
10
+ export interface FilterItem {
11
+ key: string;
12
+ name: string;
13
+ }
14
+
15
+ // Generic Props
16
+ export interface SearchInputProps<T = { id: string; name: string }>
17
+ extends React.InputHTMLAttributes<HTMLInputElement> {
18
+ fetch: (
19
+ value: string,
20
+ filters?: Record<string, boolean>,
21
+ maxFetch?: number
22
+ ) => Promise<T[]>;
23
+ renderItem?: (item: T) => React.ReactNode;
24
+ itemOnClick?: (item: T) => void;
25
+ filters?: FilterItem[];
26
+ onFiltersChange?: (filtersState: Record<string, boolean>) => void;
27
+ debounceValue?: number;
28
+ parentClassName?: string;
29
+ text?: {
30
+ fetch?: string;
31
+ notItem?: string;
32
+ maxRecord?: string;
33
+ clearFilters?: string;
34
+ };
35
+ endContent?: React.ReactNode;
36
+ STORAGE_KEY?: string;
37
+ }
38
+
39
+ // ✅ Generic forwardRef wrapper
40
+ function SearchInputInner<T = { id: string; name: string }>(
41
+ {
42
+ fetch,
43
+ renderItem,
44
+ filters = [],
45
+ onFiltersChange,
46
+ debounceValue = 500,
47
+ parentClassName,
48
+ text = {
49
+ fetch: "Fetching...",
50
+ notItem: "No results found",
51
+ maxRecord: "Max records",
52
+ clearFilters: "Clear Filters",
53
+ },
54
+ endContent,
55
+ STORAGE_KEY = "FILTER_STORAGE_KEY",
56
+ itemOnClick,
57
+ ...props
58
+ }: SearchInputProps<T>,
59
+ ref: React.Ref<HTMLInputElement>
60
+ ) {
61
+ const [inputValue, setInputValue] = useState("");
62
+ const [isFocused, setIsFocused] = useState(false);
63
+ const [showFilters, setShowFilters] = useState(false);
64
+ const [isFetching, setIsFetching] = useState(false);
65
+ const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
66
+ const [items, setItems] = useState<T[]>([]);
67
+
68
+ const [filtersState, setFiltersState] = useState<Record<string, boolean>>(
69
+ () => {
70
+ const saved = localStorage.getItem(STORAGE_KEY);
71
+ if (saved) return JSON.parse(saved);
72
+ return filters.reduce((acc, f) => ({ ...acc, [f.key]: false }), {});
73
+ }
74
+ );
75
+
76
+ const [maxFetch, setMaxFetch] = useState<number | "">(() => {
77
+ const saved = localStorage.getItem(`${STORAGE_KEY}_MAX_FETCH`);
78
+ return saved ? Number(saved) : "";
79
+ });
80
+
81
+ const inputRef = useRef<HTMLInputElement>(null);
82
+ const containerRef = useRef<HTMLDivElement>(null);
83
+ const wrapperRef = useRef<HTMLDivElement>(null);
84
+ const dropdownRef = useRef<HTMLDivElement>(null);
85
+
86
+ const debouncedValue = useDebounce(inputValue, debounceValue);
87
+
88
+ // Fetch items
89
+ useEffect(() => {
90
+ const get = async () => {
91
+ setIsFetching(true);
92
+ try {
93
+ const data = await fetch(
94
+ debouncedValue,
95
+ filtersState,
96
+ maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
97
+ );
98
+ setItems(data);
99
+ } catch (err) {
100
+ setItems([]);
101
+ console.error(err);
102
+ } finally {
103
+ setIsFetching(false);
104
+ }
105
+ };
106
+ if (
107
+ debouncedValue ||
108
+ debouncedValue === "" ||
109
+ Object.values(filtersState).some((v) => v)
110
+ )
111
+ get();
112
+ }, [debouncedValue, fetch, filtersState, maxFetch]);
113
+
114
+ const updatePosition = () => {
115
+ const el = containerRef.current;
116
+ if (!el) return;
117
+ const rect = el.getBoundingClientRect();
118
+ setPosition({
119
+ top: rect.bottom + window.scrollY,
120
+ left: rect.left + window.scrollX,
121
+ width: rect.width,
122
+ });
123
+ };
124
+
125
+ useEffect(() => {
126
+ const el = inputRef.current;
127
+ if (!el) return;
128
+ const handleFocus = () => {
129
+ setIsFocused(true);
130
+ setShowFilters(false);
131
+ updatePosition();
132
+ };
133
+ el.addEventListener("focus", handleFocus);
134
+ return () => el.removeEventListener("focus", handleFocus);
135
+ }, []);
136
+
137
+ useEffect(() => {
138
+ const handleClickOutside = (e: MouseEvent) => {
139
+ if (!wrapperRef.current || !dropdownRef.current) return;
140
+ if (
141
+ !wrapperRef.current.contains(e.target as Node) &&
142
+ !dropdownRef.current.contains(e.target as Node)
143
+ ) {
144
+ setIsFocused(false);
145
+ setShowFilters(false);
146
+ }
147
+ };
148
+ document.addEventListener("mousedown", handleClickOutside);
149
+ return () => document.removeEventListener("mousedown", handleClickOutside);
150
+ }, []);
151
+
152
+ useEffect(() => {
153
+ if (!isFocused && !showFilters) return;
154
+ updatePosition();
155
+ window.addEventListener("resize", updatePosition);
156
+ window.addEventListener("scroll", updatePosition, true);
157
+ return () => {
158
+ window.removeEventListener("resize", updatePosition);
159
+ window.removeEventListener("scroll", updatePosition, true);
160
+ };
161
+ }, [isFocused, showFilters]);
162
+
163
+ const clearIcon = (
164
+ <X
165
+ onClick={() => setInputValue("")}
166
+ className="hover:bg-tertiary/10 hover:text-tertiary size-[38px] p-3 cursor-pointer text-primary/60 rounded transition-colors"
167
+ />
168
+ );
169
+ const endIcon = endContent ?? clearIcon;
170
+
171
+ const handleFilterChange = (key: string, value: boolean) => {
172
+ setFiltersState((prev) => {
173
+ const next = { ...prev, [key]: value };
174
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
175
+ onFiltersChange?.(next);
176
+ return next;
177
+ });
178
+ };
179
+
180
+ const inputOnChange = useCallback(
181
+ (e: React.ChangeEvent<HTMLInputElement>) => {
182
+ setInputValue(e.target.value);
183
+ },
184
+ []
185
+ );
186
+
187
+ const dropdown =
188
+ isFocused || showFilters
189
+ ? Dropdown(
190
+ position,
191
+ isFetching,
192
+ text,
193
+ filters,
194
+ filtersState,
195
+ showFilters,
196
+ handleFilterChange,
197
+ items,
198
+ renderItem,
199
+ dropdownRef,
200
+ maxFetch,
201
+ setMaxFetch,
202
+ STORAGE_KEY,
203
+ onFiltersChange
204
+ )
205
+ : null;
206
+
207
+ return (
208
+ <div ref={wrapperRef}>
209
+ <div ref={containerRef} className={cn("w-full", parentClassName)}>
210
+ <Input
211
+ ref={ref || inputRef}
212
+ {...props}
213
+ value={inputValue}
214
+ onChange={inputOnChange}
215
+ endContent={
216
+ <div className="flex items-center gap-1 relative ltr:-right-1 rtl:-left-1">
217
+ {isFocused && endIcon}
218
+ {filters.length != 0 && (
219
+ <ListFilter
220
+ onClick={() => {
221
+ updatePosition();
222
+ setShowFilters((prev) => !prev);
223
+ setIsFocused(false);
224
+ }}
225
+ className={cn(
226
+ "text-primary/50 hover:bg-tertiary/10 hover:text-tertiary size-[38px] p-3 cursor-pointer rounded transition-colors",
227
+ showFilters && "text-tertiary"
228
+ )}
229
+ />
230
+ )}
231
+ </div>
232
+ }
233
+ />
234
+ </div>
235
+ {dropdown}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ // Wrap with forwardRef and generic
241
+ const SearchInputForward = React.forwardRef(SearchInputInner) as <
242
+ T = { id: string; name: string }
243
+ >(
244
+ props: SearchInputProps<T> & { ref?: React.Ref<HTMLInputElement> }
245
+ ) => React.ReactElement;
246
+
247
+ export default SearchInputForward;
248
+
249
+ /* Debounce Hook */
250
+ export function useDebounce<T>(value: T, delay?: number): T {
251
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
252
+ useEffect(() => {
253
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
254
+ return () => clearTimeout(timer);
255
+ }, [value, delay]);
256
+ return debouncedValue;
257
+ }
258
+
259
+ /* Dropdown */
260
+ const Dropdown = <T,>(
261
+ position: { top: number; left: number; width: number },
262
+ isFetching: boolean,
263
+ text: {
264
+ fetch?: string;
265
+ notItem?: string;
266
+ maxRecord?: string;
267
+ clearFilters?: string;
268
+ },
269
+ filters: FilterItem[],
270
+ filtersState: Record<string, boolean>,
271
+ showFilters: boolean | undefined,
272
+ handleFilterChange: (key: string, value: boolean) => void,
273
+ items: T[],
274
+ renderItem?: (item: T) => React.ReactNode,
275
+ dropdownRef?: React.Ref<HTMLDivElement>,
276
+ maxFetch?: number | "",
277
+ setMaxFetch?: React.Dispatch<React.SetStateAction<number | "">>,
278
+ STORAGE_KEY?: string,
279
+ onFiltersChange?: (filtersState: Record<string, boolean>) => void,
280
+ itemOnClick?: (item: T) => void
281
+ ) =>
282
+ createPortal(
283
+ <div
284
+ ref={dropdownRef}
285
+ className="absolute z-9999 border border-border rounded-b bg-card shadow-lg pt-3 pb-2"
286
+ style={{
287
+ top: position.top,
288
+ left: position.left,
289
+ width: position.width,
290
+ position: "absolute",
291
+ }}
292
+ >
293
+ {showFilters && filters.length > 0 && (
294
+ <div className="pb-3 px-3 flex flex-col gap-2 text-sm">
295
+ {filters.map((f) => (
296
+ <label key={f.key} className={`flex items-center gap-2`}>
297
+ <input
298
+ type="checkbox"
299
+ checked={filtersState[f.key]}
300
+ onChange={(e) => handleFilterChange(f.key, e.target.checked)}
301
+ />
302
+ {f.name}
303
+ </label>
304
+ ))}
305
+
306
+ {/* Max fetch input */}
307
+ {setMaxFetch && (
308
+ <input
309
+ type="number"
310
+ min={1}
311
+ value={maxFetch}
312
+ onChange={(e) => {
313
+ const value = e.target.value ? Number(e.target.value) : "";
314
+ setMaxFetch(value);
315
+ if (STORAGE_KEY)
316
+ localStorage.setItem(
317
+ `${STORAGE_KEY}_MAX_FETCH`,
318
+ JSON.stringify(value)
319
+ );
320
+ }}
321
+ className={cn(
322
+ "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",
323
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
324
+ "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",
325
+ "focus-visible:border-tertiary/60",
326
+ "[&::-webkit-outer-spin-button]:appearance-none",
327
+ "[&::-webkit-inner-spin-button]:appearance-none",
328
+ "[-moz-appearance:textfield] "
329
+ )}
330
+ placeholder={text.maxRecord}
331
+ />
332
+ )}
333
+
334
+ {/* Clear filters button */}
335
+ {STORAGE_KEY && setMaxFetch && (
336
+ <button
337
+ onClick={() => {
338
+ const cleared = filters.reduce(
339
+ (acc, f) => ({ ...acc, [f.key]: false }),
340
+ {}
341
+ );
342
+ handleFilterChange &&
343
+ Object.keys(cleared).forEach((key) =>
344
+ handleFilterChange(key, false)
345
+ );
346
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cleared));
347
+ setMaxFetch("");
348
+ localStorage.removeItem(`${STORAGE_KEY}_MAX_FETCH`);
349
+ onFiltersChange?.(cleared);
350
+ }}
351
+ 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"
352
+ >
353
+ <Eraser className="size-4" />
354
+ {text.clearFilters}
355
+ </button>
356
+ )}
357
+ </div>
358
+ )}
359
+
360
+ {!showFilters && isFetching && <CircleLoader label={text.fetch} />}
361
+
362
+ {!showFilters && !isFetching && (
363
+ <div className="max-h-60 overflow-auto">
364
+ {items.length > 0 ? (
365
+ items.map((item, index) =>
366
+ renderItem ? (
367
+ renderItem(item)
368
+ ) : (
369
+ <div
370
+ onClick={() => {
371
+ if (itemOnClick) itemOnClick(item);
372
+ }}
373
+ key={(item as any).id ?? index}
374
+ className="px-3 py-1 hover:bg-gray-100 cursor-pointer"
375
+ >
376
+ {(item as any).name ?? JSON.stringify(item)}
377
+ </div>
378
+ )
379
+ )
380
+ ) : (
381
+ <div className="text-center text-sm text-gray-500">
382
+ {text.notItem}
383
+ </div>
384
+ )}
385
+ </div>
386
+ )}
387
+ </div>,
388
+ document.body
389
+ );
@@ -0,0 +1,131 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import SearchInput from "./index";
3
+ import { FilterItem } from "@/components/notion-ui/search-input/search-input";
4
+
5
+ const meta: Meta<typeof SearchInput> = {
6
+ title: "Form/SearchInput",
7
+ component: SearchInput,
8
+ parameters: { layout: "centered" },
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof SearchInput>;
12
+
13
+ // --- Shared Filters & Mock Fetch ---
14
+ const fruitFilters: FilterItem[] = [
15
+ { key: "red", name: "Red Fruits" },
16
+ { key: "yellow", name: "Yellow Fruits" },
17
+ { key: "citrus", name: "Citrus Fruits" },
18
+ ];
19
+
20
+ const mockFetch = async (
21
+ value: string,
22
+ filters?: Record<string, boolean>,
23
+ maxFetch?: number
24
+ ) => {
25
+ const data = [
26
+ { id: "1", name: "Apple" },
27
+ { id: "2", name: "Banana" },
28
+ { id: "3", name: "Orange" },
29
+ { id: "4", name: "Grapes" },
30
+ { id: "5", name: "Lemon" },
31
+ { id: "6", name: "Strawberry" },
32
+ ];
33
+ await new Promise((res) => setTimeout(res, 500));
34
+ return data.filter((item) =>
35
+ item.name.toLowerCase().includes(value.toLowerCase())
36
+ );
37
+ };
38
+
39
+ // --- Story 1: Basic Search (No filters) ---
40
+ export const BasicTextSearch: Story = {
41
+ render: () => (
42
+ <SearchInput
43
+ fetch={mockFetch}
44
+ placeholder="Search fruits..."
45
+ STORAGE_KEY="basic-text-search"
46
+ />
47
+ ),
48
+ };
49
+
50
+ // --- Story 2: Search With Checkbox Filters ---
51
+ export const SearchWithFilters: Story = {
52
+ render: () => (
53
+ <SearchInput
54
+ fetch={mockFetch}
55
+ placeholder="Search fruits..."
56
+ filters={[
57
+ { key: "Max Fetch", name: "Max Fetch" },
58
+ { key: "yellow", name: "Yellow Fruits" },
59
+ { key: "citrus", name: "Citrus Fruits" },
60
+ ]}
61
+ STORAGE_KEY="search-with-filters"
62
+ onFiltersChange={(state) => console.log("Filters changed:", state)}
63
+ />
64
+ ),
65
+ };
66
+
67
+ // --- Story 3: Custom Item Rendering ---
68
+ export const SearchWithCustomItem: Story = {
69
+ render: () => (
70
+ <SearchInput
71
+ fetch={mockFetch}
72
+ placeholder="Search fruits..."
73
+ STORAGE_KEY="custom-item-search"
74
+ renderItem={(item) => (
75
+ <div className="flex items-center justify-between px-3 py-1 hover:bg-gray-100">
76
+ <span>{item.name}</span>
77
+ <span role="img" aria-label="fruit">
78
+ 🍎
79
+ </span>
80
+ </div>
81
+ )}
82
+ />
83
+ ),
84
+ };
85
+
86
+ // --- Story 4: Debounced Search ---
87
+ export const DebouncedSearch: Story = {
88
+ render: () => (
89
+ <SearchInput
90
+ fetch={mockFetch}
91
+ placeholder="Type slowly..."
92
+ debounceValue={1000}
93
+ STORAGE_KEY="debounced-search"
94
+ />
95
+ ),
96
+ };
97
+
98
+ // --- Story 5: Filters Only Panel ---
99
+ export const FiltersOnly: Story = {
100
+ render: () => (
101
+ <SearchInput
102
+ fetch={mockFetch}
103
+ placeholder="Focus to see filters..."
104
+ filters={fruitFilters}
105
+ STORAGE_KEY="filters-only"
106
+ />
107
+ ),
108
+ };
109
+
110
+ // --- Story 6: Full Featured Example ---
111
+ export const FullFeatured: Story = {
112
+ render: () => (
113
+ <SearchInput
114
+ fetch={mockFetch}
115
+ placeholder="Search fruits..."
116
+ filters={fruitFilters}
117
+ STORAGE_KEY="full-featured-search"
118
+ onFiltersChange={(state) => console.log("Filters:", state)}
119
+ debounceValue={700}
120
+ renderItem={(item) => (
121
+ <div className="flex justify-between px-3 py-1 hover:bg-gray-100">
122
+ <strong>{item.name}</strong>
123
+ <span role="img" aria-label="banana">
124
+ 🍌
125
+ </span>
126
+ </div>
127
+ )}
128
+ text={{ fetch: "Loading fruits...", notItem: "No fruits found" }}
129
+ />
130
+ ),
131
+ };
@@ -1,4 +1,4 @@
1
- import BooleanStatusButton from "./BooleanStatusButton";
1
+ import StatusButton from "./status-button";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
 
4
4
  // --------------------------------------------
@@ -33,7 +33,7 @@ interface WrapperProps {
33
33
 
34
34
  function Wrapper({ status, className }: WrapperProps) {
35
35
  return (
36
- <BooleanStatusButton
36
+ <StatusButton
37
37
  className={className}
38
38
  getColor={() => statusOptions[status]}
39
39
  />
@@ -44,7 +44,7 @@ function Wrapper({ status, className }: WrapperProps) {
44
44
  // Storybook meta (now uses Wrapper, not the original)
45
45
  // --------------------------------------------
46
46
  const meta: Meta<typeof Wrapper> = {
47
- title: "Button/BooleanStatusButton",
47
+ title: "Button/StatusButton",
48
48
  component: Wrapper,
49
49
  argTypes: {
50
50
  status: {
@@ -1,7 +1,7 @@
1
1
  import { cn } from "../../utils/cn";
2
2
  // import { cn } from "@/utils/cn";
3
3
 
4
- export interface BooleanStatusButtonProps {
4
+ export interface StatusButtonProps {
5
5
  getColor: () => {
6
6
  style: string;
7
7
  value?: string;
@@ -9,7 +9,7 @@ export interface BooleanStatusButtonProps {
9
9
  className?: string;
10
10
  }
11
11
 
12
- export default function BooleanStatusButton(props: BooleanStatusButtonProps) {
12
+ export default function StatusButton(props: StatusButtonProps) {
13
13
  const { getColor, className } = props;
14
14
  const data = getColor();
15
15