notionsoft-ui 1.0.14 → 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.
- package/package.json +1 -1
- package/src/notion-ui/animated-item/index.ts +3 -0
- package/src/notion-ui/button/Button.stories.tsx +6 -0
- package/src/notion-ui/button/button.tsx +10 -6
- package/src/notion-ui/button/index.ts +3 -0
- package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +1 -1
- package/src/notion-ui/button-spinner/index.ts +1 -1
- package/src/notion-ui/circle-loader/CircleLoader.stories.tsx +1 -1
- package/src/notion-ui/circle-loader/index.ts +3 -0
- package/src/notion-ui/input/index.ts +3 -0
- 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 +148 -0
- package/src/notion-ui/multi-select-input/multi-select-input.tsx +466 -0
- package/src/notion-ui/search-input/index.ts +3 -0
- package/src/notion-ui/search-input/search-input.tsx +389 -0
- package/src/notion-ui/search-input/search.Input.stories.tsx +131 -0
- package/src/notion-ui/sheet/index.ts +3 -0
- package/src/notion-ui/shining-text/index.ts +3 -0
- package/src/notion-ui/status-button/index.ts +3 -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
|
@@ -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
|
|
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
|
-
<
|
|
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/
|
|
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
|
|
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
|
|
12
|
+
export default function StatusButton(props: StatusButtonProps) {
|
|
13
13
|
const { getColor, className } = props;
|
|
14
14
|
const data = getColor();
|
|
15
15
|
|