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,425 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { buildNestedFiltersQuery, cn, useDebounce } 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
+ interface ApiConfig {
15
+ url: string;
16
+ headers?: Record<string, string>;
17
+ params?: Record<string, any>;
18
+ }
19
+ // Generic Props
20
+ export interface BaseSearchInputProps<T = { id: string; name: string }>
21
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onSelect"> {
22
+ renderItem?: (item: T) => React.ReactNode;
23
+ itemOnClick?: (item: T) => void;
24
+ filters?: FilterItem[];
25
+ onFiltersChange?: (filtersState: Record<string, boolean>) => void;
26
+ debounceValue?: number;
27
+ parentClassName?: string;
28
+ text?: {
29
+ fetch?: string;
30
+ notItem?: string;
31
+ maxRecord?: string;
32
+ clearFilters?: string;
33
+ };
34
+ endContent?: React.ReactNode;
35
+ STORAGE_KEY?: string;
36
+ }
37
+
38
+ // Either user provides `fetch` function...
39
+ export interface FetchProps<T> extends BaseSearchInputProps<T> {
40
+ fetch: (
41
+ value: string,
42
+ filters?: Record<string, boolean>,
43
+ maxFetch?: number
44
+ ) => Promise<T[]>;
45
+ apiConfig?: never;
46
+ }
47
+
48
+ // ...or `apiConfig` object
49
+ export interface ApiConfigProps<T> extends BaseSearchInputProps<T> {
50
+ fetch?: never;
51
+ apiConfig: ApiConfig;
52
+ }
53
+
54
+ // The final props type
55
+ export type SearchInputProps<T = { id: string; name: string }> =
56
+ | FetchProps<T>
57
+ | ApiConfigProps<T>;
58
+
59
+ // ✅ Generic forwardRef wrapper
60
+ function SearchInputInner<T = { id: string; name: string }>(
61
+ {
62
+ fetch,
63
+ renderItem,
64
+ filters = [],
65
+ onFiltersChange,
66
+ debounceValue = 500,
67
+ parentClassName,
68
+ text = {
69
+ fetch: "Fetching...",
70
+ notItem: "No results found",
71
+ maxRecord: "Max records",
72
+ clearFilters: "Clear Filters",
73
+ },
74
+ endContent,
75
+ STORAGE_KEY = "FILTER_STORAGE_KEY",
76
+ apiConfig,
77
+ itemOnClick,
78
+ ...props
79
+ }: SearchInputProps<T>,
80
+ ref: React.Ref<HTMLInputElement>
81
+ ) {
82
+ const [inputValue, setInputValue] = useState("");
83
+ const [isFocused, setIsFocused] = useState(false);
84
+ const [showFilters, setShowFilters] = useState(false);
85
+ const [isFetching, setIsFetching] = useState(false);
86
+ const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
87
+ const [items, setItems] = useState<T[]>([]);
88
+
89
+ const [filtersState, setFiltersState] = useState<Record<string, boolean>>(
90
+ () => {
91
+ const saved = localStorage.getItem(STORAGE_KEY);
92
+ if (saved) return JSON.parse(saved);
93
+ return filters.reduce((acc, f) => ({ ...acc, [f.key]: false }), {});
94
+ }
95
+ );
96
+
97
+ const [maxFetch, setMaxFetch] = useState<number | "">(() => {
98
+ const saved = localStorage.getItem(`${STORAGE_KEY}_MAX_FETCH`);
99
+ return saved ? Number(saved) : "";
100
+ });
101
+
102
+ const inputRef = useRef<HTMLInputElement>(null);
103
+ const containerRef = useRef<HTMLDivElement>(null);
104
+ const wrapperRef = useRef<HTMLDivElement>(null);
105
+ const dropdownRef = useRef<HTMLDivElement>(null);
106
+
107
+ const debouncedValue = useDebounce(inputValue, debounceValue);
108
+
109
+ // Fetch items
110
+ useEffect(() => {
111
+ const get = async () => {
112
+ setIsFetching(true);
113
+ try {
114
+ let data: T[] = [];
115
+
116
+ if (fetch) {
117
+ // User-provided fetch function
118
+ data = await fetch(
119
+ debouncedValue,
120
+ filtersState,
121
+ maxFetch && !isNaN(Number(maxFetch)) ? Number(maxFetch) : undefined
122
+ );
123
+ } else if (apiConfig) {
124
+ // Only include active filters
125
+ const activeFilters = Object.fromEntries(
126
+ Object.entries(filtersState).filter(([_, v]) => v)
127
+ );
128
+
129
+ // Build nested filters query
130
+ const filtersQuery = buildNestedFiltersQuery(activeFilters);
131
+
132
+ const combinedParams = new URLSearchParams({
133
+ q: debouncedValue,
134
+ maxFetch: maxFetch?.toString() ?? "",
135
+ ...apiConfig.params,
136
+ }).toString();
137
+
138
+ const url = `${apiConfig.url}?${combinedParams}${
139
+ filtersQuery ? "&" + filtersQuery : ""
140
+ }`;
141
+
142
+ const res = await window.fetch(url, {
143
+ headers: apiConfig.headers,
144
+ });
145
+ data = await res.json();
146
+ }
147
+
148
+ setItems(data);
149
+ } catch (err: any) {
150
+ console.error(err);
151
+ setItems([]);
152
+ } finally {
153
+ setIsFetching(false);
154
+ }
155
+ };
156
+
157
+ get();
158
+ }, [debouncedValue, fetch, apiConfig, filtersState, maxFetch]);
159
+
160
+ const updatePosition = () => {
161
+ const el = containerRef.current;
162
+ if (!el) return;
163
+ const rect = el.getBoundingClientRect();
164
+ setPosition({
165
+ top: rect.bottom + window.scrollY,
166
+ left: rect.left + window.scrollX,
167
+ width: rect.width,
168
+ });
169
+ };
170
+
171
+ useEffect(() => {
172
+ const el = inputRef.current;
173
+ if (!el) return;
174
+ const handleFocus = () => {
175
+ setIsFocused(true);
176
+ setShowFilters(false);
177
+ updatePosition();
178
+ };
179
+ el.addEventListener("focus", handleFocus);
180
+ return () => el.removeEventListener("focus", handleFocus);
181
+ }, []);
182
+
183
+ useEffect(() => {
184
+ const handleClickOutside = (e: MouseEvent) => {
185
+ if (!wrapperRef.current || !dropdownRef.current) return;
186
+ if (
187
+ !wrapperRef.current.contains(e.target as Node) &&
188
+ !dropdownRef.current.contains(e.target as Node)
189
+ ) {
190
+ setIsFocused(false);
191
+ setShowFilters(false);
192
+ }
193
+ };
194
+ document.addEventListener("mousedown", handleClickOutside);
195
+ return () => document.removeEventListener("mousedown", handleClickOutside);
196
+ }, []);
197
+
198
+ useEffect(() => {
199
+ if (!isFocused && !showFilters) return;
200
+ updatePosition();
201
+ window.addEventListener("resize", updatePosition);
202
+ window.addEventListener("scroll", updatePosition, true);
203
+ return () => {
204
+ window.removeEventListener("resize", updatePosition);
205
+ window.removeEventListener("scroll", updatePosition, true);
206
+ };
207
+ }, [isFocused, showFilters]);
208
+
209
+ const clearIcon = (
210
+ <X
211
+ onClick={() => setInputValue("")}
212
+ className="hover:bg-tertiary/10 hover:text-tertiary size-[38px] p-3 cursor-pointer text-primary/60 rounded transition-colors"
213
+ />
214
+ );
215
+ const endIcon = endContent ?? clearIcon;
216
+
217
+ const handleFilterChange = (key: string, value: boolean) => {
218
+ setFiltersState((prev) => {
219
+ const next = { ...prev, [key]: value };
220
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
221
+ onFiltersChange?.(next);
222
+ return next;
223
+ });
224
+ };
225
+
226
+ const inputOnChange = useCallback(
227
+ (e: React.ChangeEvent<HTMLInputElement>) => {
228
+ setInputValue(e.target.value);
229
+ },
230
+ []
231
+ );
232
+
233
+ const dropdown =
234
+ isFocused || showFilters
235
+ ? Dropdown(
236
+ position,
237
+ isFetching,
238
+ text,
239
+ filters,
240
+ filtersState,
241
+ showFilters,
242
+ handleFilterChange,
243
+ items,
244
+ renderItem,
245
+ dropdownRef,
246
+ maxFetch,
247
+ setMaxFetch,
248
+ STORAGE_KEY,
249
+ onFiltersChange
250
+ )
251
+ : null;
252
+
253
+ return (
254
+ <div ref={wrapperRef}>
255
+ <div ref={containerRef} className={cn("w-full", parentClassName)}>
256
+ <Input
257
+ ref={ref || inputRef}
258
+ {...props}
259
+ value={inputValue}
260
+ onChange={inputOnChange}
261
+ endContent={
262
+ <div className="flex items-center gap-1 relative ltr:-right-1 rtl:-left-1">
263
+ {isFocused && endIcon}
264
+ {filters.length != 0 && (
265
+ <ListFilter
266
+ onClick={() => {
267
+ updatePosition();
268
+ setShowFilters((prev) => !prev);
269
+ setIsFocused(false);
270
+ }}
271
+ className={cn(
272
+ "text-primary/50 hover:bg-tertiary/10 hover:text-tertiary size-[38px] p-3 cursor-pointer rounded transition-colors",
273
+ showFilters && "text-tertiary"
274
+ )}
275
+ />
276
+ )}
277
+ </div>
278
+ }
279
+ />
280
+ </div>
281
+ {dropdown}
282
+ </div>
283
+ );
284
+ }
285
+
286
+ // Wrap with forwardRef and generic
287
+ const SearchInputForward = React.forwardRef(SearchInputInner) as <
288
+ T = { id: string; name: string }
289
+ >(
290
+ props: SearchInputProps<T> & { ref?: React.Ref<HTMLInputElement> }
291
+ ) => React.ReactElement;
292
+
293
+ export default SearchInputForward;
294
+
295
+ /* Dropdown */
296
+ const Dropdown = <T,>(
297
+ position: { top: number; left: number; width: number },
298
+ isFetching: boolean,
299
+ text: {
300
+ fetch?: string;
301
+ notItem?: string;
302
+ maxRecord?: string;
303
+ clearFilters?: string;
304
+ },
305
+ filters: FilterItem[],
306
+ filtersState: Record<string, boolean>,
307
+ showFilters: boolean | undefined,
308
+ handleFilterChange: (key: string, value: boolean) => void,
309
+ items: T[],
310
+ renderItem?: (item: T) => React.ReactNode,
311
+ dropdownRef?: React.Ref<HTMLDivElement>,
312
+ maxFetch?: number | "",
313
+ setMaxFetch?: React.Dispatch<React.SetStateAction<number | "">>,
314
+ STORAGE_KEY?: string,
315
+ onFiltersChange?: (filtersState: Record<string, boolean>) => void,
316
+ itemOnClick?: (item: T) => void
317
+ ) =>
318
+ createPortal(
319
+ <div
320
+ ref={dropdownRef}
321
+ className="absolute z-9999 border border-border rounded-b bg-card shadow-lg pt-3 pb-2"
322
+ style={{
323
+ top: position.top,
324
+ left: position.left,
325
+ width: position.width,
326
+ position: "absolute",
327
+ }}
328
+ >
329
+ {showFilters && filters.length > 0 && (
330
+ <div className="pb-3 px-3 flex flex-col gap-2 text-sm">
331
+ {filters.map((f) => (
332
+ <label key={f.key} className={`flex items-center gap-2`}>
333
+ <input
334
+ type="checkbox"
335
+ checked={filtersState[f.key]}
336
+ onChange={(e) => handleFilterChange(f.key, e.target.checked)}
337
+ />
338
+ {f.name}
339
+ </label>
340
+ ))}
341
+
342
+ {/* Max fetch input */}
343
+ {setMaxFetch && (
344
+ <input
345
+ type="number"
346
+ min={1}
347
+ value={maxFetch}
348
+ onChange={(e) => {
349
+ const value = e.target.value ? Number(e.target.value) : "";
350
+ setMaxFetch(value);
351
+ if (STORAGE_KEY)
352
+ localStorage.setItem(
353
+ `${STORAGE_KEY}_MAX_FETCH`,
354
+ JSON.stringify(value)
355
+ );
356
+ }}
357
+ className={cn(
358
+ "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",
359
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
360
+ "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",
361
+ "focus-visible:border-tertiary/60",
362
+ "[&::-webkit-outer-spin-button]:appearance-none",
363
+ "[&::-webkit-inner-spin-button]:appearance-none",
364
+ "[-moz-appearance:textfield] "
365
+ )}
366
+ placeholder={text.maxRecord}
367
+ />
368
+ )}
369
+
370
+ {/* Clear filters button */}
371
+ {STORAGE_KEY && setMaxFetch && (
372
+ <button
373
+ onClick={() => {
374
+ const cleared = filters.reduce(
375
+ (acc, f) => ({ ...acc, [f.key]: false }),
376
+ {}
377
+ );
378
+ handleFilterChange &&
379
+ Object.keys(cleared).forEach((key) =>
380
+ handleFilterChange(key, false)
381
+ );
382
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cleared));
383
+ setMaxFetch("");
384
+ localStorage.removeItem(`${STORAGE_KEY}_MAX_FETCH`);
385
+ onFiltersChange?.(cleared);
386
+ }}
387
+ 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"
388
+ >
389
+ <Eraser className="size-4" />
390
+ {text.clearFilters}
391
+ </button>
392
+ )}
393
+ </div>
394
+ )}
395
+
396
+ {!showFilters && isFetching && <CircleLoader label={text.fetch} />}
397
+
398
+ {!showFilters && !isFetching && (
399
+ <div className="max-h-60 overflow-auto">
400
+ {items.length > 0 ? (
401
+ items.map((item, index) =>
402
+ renderItem ? (
403
+ renderItem(item)
404
+ ) : (
405
+ <div
406
+ onClick={() => {
407
+ if (itemOnClick) itemOnClick(item);
408
+ }}
409
+ key={(item as any).id ?? index}
410
+ className="px-3 py-1 hover:bg-gray-100 cursor-pointer"
411
+ >
412
+ {(item as any).name ?? JSON.stringify(item)}
413
+ </div>
414
+ )
415
+ )
416
+ ) : (
417
+ <div className="text-center text-sm text-gray-500">
418
+ {text.notItem}
419
+ </div>
420
+ )}
421
+ </div>
422
+ )}
423
+ </div>,
424
+ document.body
425
+ );
@@ -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
 
package/src/utils/cn.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { clsx } from "clsx";
2
+ import { useEffect, useState } from "react";
2
3
  import { twMerge } from "tailwind-merge";
3
4
 
4
5
  /**
@@ -7,3 +8,28 @@ import { twMerge } from "tailwind-merge";
7
8
  export function cn(...inputs: Parameters<typeof clsx>) {
8
9
  return twMerge(clsx(...inputs));
9
10
  }
11
+ export function buildNestedFiltersQuery(filters: Record<string, any>): string {
12
+ const params = new URLSearchParams();
13
+
14
+ function recurse(obj: Record<string, any>, prefix: string) {
15
+ Object.entries(obj).forEach(([key, value]) => {
16
+ const newKey = `${prefix}[${key}]`;
17
+ if (value && typeof value === "object") {
18
+ recurse(value, newKey);
19
+ } else if (value !== undefined && value !== null) {
20
+ params.append(newKey, value.toString());
21
+ }
22
+ });
23
+ }
24
+
25
+ recurse(filters, "filters");
26
+ return params.toString();
27
+ }
28
+ export function useDebounce<T>(value: T, delay?: number): T {
29
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
30
+ useEffect(() => {
31
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
32
+ return () => clearTimeout(timer);
33
+ }, [value, delay]);
34
+ return debouncedValue;
35
+ }