@ttt-productions/ui-core 0.2.16 → 0.2.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.
- package/dist/components/search-dropdown.d.ts +55 -0
- package/dist/components/search-dropdown.d.ts.map +1 -0
- package/dist/components/search-dropdown.jsx +129 -0
- package/dist/components/search-dropdown.jsx.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/search-dropdown.tsx +234 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface SearchDropdownProps<T> {
|
|
2
|
+
/** Current search value */
|
|
3
|
+
value: string;
|
|
4
|
+
/** Called when search value changes */
|
|
5
|
+
onValueChange: (value: string) => void;
|
|
6
|
+
/** Search results array */
|
|
7
|
+
results: T[];
|
|
8
|
+
/** Loading state */
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
/** Error message */
|
|
11
|
+
error: string | null;
|
|
12
|
+
/** Called when a result is selected */
|
|
13
|
+
onSelect: (result: T) => void;
|
|
14
|
+
/** Called when search is cleared */
|
|
15
|
+
onClear: () => void;
|
|
16
|
+
/** Placeholder text */
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
/** Label for the input */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Custom className */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Disabled state */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/** Icon to show in input (default: Search) */
|
|
25
|
+
icon?: React.ReactNode;
|
|
26
|
+
/** Minimum characters before showing results (default: 3) */
|
|
27
|
+
minChars?: number;
|
|
28
|
+
/** Message to show when no results found */
|
|
29
|
+
emptyMessage?: string;
|
|
30
|
+
/** Custom render function for each result */
|
|
31
|
+
renderResult: (result: T, index: number) => React.ReactNode;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generic search dropdown component with debounced input and keyboard navigation.
|
|
35
|
+
* Supports any data type and custom rendering.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <SearchDropdown<User>
|
|
40
|
+
* value={searchValue}
|
|
41
|
+
* onValueChange={setSearchValue}
|
|
42
|
+
* results={users}
|
|
43
|
+
* isLoading={isLoading}
|
|
44
|
+
* error={error}
|
|
45
|
+
* onSelect={(user) => console.log(user)}
|
|
46
|
+
* onClear={() => setSearchValue('')}
|
|
47
|
+
* placeholder="Search users..."
|
|
48
|
+
* renderResult={(user) => (
|
|
49
|
+
* <div>{user.displayName}</div>
|
|
50
|
+
* )}
|
|
51
|
+
* />
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function SearchDropdown<T>({ value, onValueChange, results, isLoading, error, onSelect, onClear, placeholder, label, className, disabled, icon, minChars, emptyMessage, renderResult, }: SearchDropdownProps<T>): import("react").JSX.Element;
|
|
55
|
+
//# sourceMappingURL=search-dropdown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-dropdown.d.ts","sourceRoot":"","sources":["../../src/components/search-dropdown.tsx"],"names":[],"mappings":"AAQA,MAAM,WAAW,mBAAmB,CAAC,CAAC;IACpC,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,2BAA2B;IAC3B,OAAO,EAAE,CAAC,EAAE,CAAC;IACb,oBAAoB;IACpB,SAAS,EAAE,OAAO,CAAC;IACnB,oBAAoB;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,uCAAuC;IACvC,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,oCAAoC;IACpC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uBAAuB;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6CAA6C;IAC7C,YAAY,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,KAAK,CAAC,SAAS,CAAC;CAC7D;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,EAChC,KAAK,EACL,aAAa,EACb,OAAO,EACP,SAAS,EACT,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAyB,EACzB,KAAK,EACL,SAAS,EACT,QAAgB,EAChB,IAAqC,EACrC,QAAY,EACZ,YAAiC,EACjC,YAAY,GACb,EAAE,mBAAmB,CAAC,CAAC,CAAC,+BA2JxB"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useRef, useEffect } from 'react';
|
|
3
|
+
import { Input } from './input';
|
|
4
|
+
import { Label } from './label';
|
|
5
|
+
import { Loader2, X, Search } from 'lucide-react';
|
|
6
|
+
import { cn } from '../lib/utils';
|
|
7
|
+
/**
|
|
8
|
+
* Generic search dropdown component with debounced input and keyboard navigation.
|
|
9
|
+
* Supports any data type and custom rendering.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <SearchDropdown<User>
|
|
14
|
+
* value={searchValue}
|
|
15
|
+
* onValueChange={setSearchValue}
|
|
16
|
+
* results={users}
|
|
17
|
+
* isLoading={isLoading}
|
|
18
|
+
* error={error}
|
|
19
|
+
* onSelect={(user) => console.log(user)}
|
|
20
|
+
* onClear={() => setSearchValue('')}
|
|
21
|
+
* placeholder="Search users..."
|
|
22
|
+
* renderResult={(user) => (
|
|
23
|
+
* <div>{user.displayName}</div>
|
|
24
|
+
* )}
|
|
25
|
+
* />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function SearchDropdown({ value, onValueChange, results, isLoading, error, onSelect, onClear, placeholder = 'Search...', label, className, disabled = false, icon = <Search className="h-4 w-4"/>, minChars = 3, emptyMessage = 'No results found', renderResult, }) {
|
|
29
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
30
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
31
|
+
const containerRef = useRef(null);
|
|
32
|
+
const inputRef = useRef(null);
|
|
33
|
+
// Close dropdown when clicking outside
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const handleClickOutside = (event) => {
|
|
36
|
+
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
37
|
+
setIsOpen(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
41
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
42
|
+
}, []);
|
|
43
|
+
// Show dropdown when there are results or loading/error states
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (value.length >= minChars && (results.length > 0 || isLoading || error)) {
|
|
46
|
+
setIsOpen(true);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
setIsOpen(false);
|
|
50
|
+
}
|
|
51
|
+
}, [value, results, isLoading, error, minChars]);
|
|
52
|
+
// Reset selected index when results change
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setSelectedIndex(-1);
|
|
55
|
+
}, [results]);
|
|
56
|
+
const handleKeyDown = (e) => {
|
|
57
|
+
if (!isOpen || results.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
switch (e.key) {
|
|
60
|
+
case 'ArrowDown':
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
|
|
63
|
+
break;
|
|
64
|
+
case 'ArrowUp':
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
67
|
+
break;
|
|
68
|
+
case 'Enter':
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
if (selectedIndex >= 0 && selectedIndex < results.length) {
|
|
71
|
+
onSelect(results[selectedIndex]);
|
|
72
|
+
setIsOpen(false);
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
case 'Escape':
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
setIsOpen(false);
|
|
78
|
+
inputRef.current?.blur();
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const handleResultClick = (result) => {
|
|
83
|
+
onSelect(result);
|
|
84
|
+
setIsOpen(false);
|
|
85
|
+
};
|
|
86
|
+
const handleClear = () => {
|
|
87
|
+
onClear();
|
|
88
|
+
setIsOpen(false);
|
|
89
|
+
inputRef.current?.focus();
|
|
90
|
+
};
|
|
91
|
+
const showDropdown = isOpen && value.length >= minChars;
|
|
92
|
+
return (<div ref={containerRef} className={cn('relative', className)}>
|
|
93
|
+
{label && (<Label htmlFor="search-input" className="mb-2 block">
|
|
94
|
+
{label}
|
|
95
|
+
</Label>)}
|
|
96
|
+
|
|
97
|
+
<div className="relative">
|
|
98
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
99
|
+
{icon}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<Input ref={inputRef} id="search-input" type="text" value={value} onChange={(e) => onValueChange(e.target.value)} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={disabled} className="pl-10 pr-10"/>
|
|
103
|
+
|
|
104
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
105
|
+
{isLoading ? (<Loader2 className="h-4 w-4 animate-spin text-muted-foreground"/>) : value ? (<button type="button" onClick={handleClear} className="text-muted-foreground hover:text-foreground transition-colors" aria-label="Clear search">
|
|
106
|
+
<X className="h-4 w-4"/>
|
|
107
|
+
</button>) : null}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{showDropdown && (<div className="absolute z-50 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-auto">
|
|
112
|
+
{error ? (<div className="px-3 py-2 text-sm text-destructive">
|
|
113
|
+
{error}
|
|
114
|
+
</div>) : isLoading ? (<div className="px-3 py-2 text-sm text-muted-foreground flex items-center gap-2">
|
|
115
|
+
<Loader2 className="h-4 w-4 animate-spin"/>
|
|
116
|
+
Searching...
|
|
117
|
+
</div>) : results.length === 0 ? (<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
118
|
+
{emptyMessage}
|
|
119
|
+
</div>) : (results.map((result, index) => (<button key={index} type="button" onClick={() => handleResultClick(result)} onMouseEnter={() => setSelectedIndex(index)} className={cn('w-full text-left transition-colors cursor-pointer', 'hover:bg-accent focus:bg-accent focus:outline-none', selectedIndex === index && 'bg-accent')}>
|
|
120
|
+
{renderResult(result, index)}
|
|
121
|
+
</button>)))}
|
|
122
|
+
</div>)}
|
|
123
|
+
|
|
124
|
+
{value.length > 0 && value.length < minChars && (<p className="text-xs text-muted-foreground mt-1">
|
|
125
|
+
Type at least {minChars} characters to search
|
|
126
|
+
</p>)}
|
|
127
|
+
</div>);
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=search-dropdown.jsx.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-dropdown.jsx","sourceRoot":"","sources":["../../src/components/search-dropdown.tsx"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export * from "./components/scroll-area";
|
|
|
31
31
|
export * from "./components/table";
|
|
32
32
|
export * from "./components/sheet";
|
|
33
33
|
export * from "./components/collapsible";
|
|
34
|
+
export * from "./components/search-dropdown";
|
|
34
35
|
export * from "./components/layout/screen-adaptive-view";
|
|
35
36
|
export * from "./hooks/use-media-query";
|
|
36
37
|
export * from "./lib/utils";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAG7C,cAAc,0CAA0C,CAAC;AAGzD,cAAc,yBAAyB,CAAC;AAGxC,cAAc,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export * from "./components/scroll-area";
|
|
|
32
32
|
export * from "./components/table";
|
|
33
33
|
export * from "./components/sheet";
|
|
34
34
|
export * from "./components/collapsible";
|
|
35
|
+
export * from "./components/search-dropdown";
|
|
35
36
|
// Layout
|
|
36
37
|
export * from "./components/layout/screen-adaptive-view";
|
|
37
38
|
// Hooks
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wBAAwB;AACxB,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wBAAwB;AACxB,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAE7C,SAAS;AACT,cAAc,0CAA0C,CAAC;AAEzD,QAAQ;AACR,cAAc,yBAAyB,CAAC;AAExC,mBAAmB;AACnB,cAAc,aAAa,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
import { Input } from './input';
|
|
5
|
+
import { Label } from './label';
|
|
6
|
+
import { Loader2, X, Search } from 'lucide-react';
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
|
|
9
|
+
export interface SearchDropdownProps<T> {
|
|
10
|
+
/** Current search value */
|
|
11
|
+
value: string;
|
|
12
|
+
/** Called when search value changes */
|
|
13
|
+
onValueChange: (value: string) => void;
|
|
14
|
+
/** Search results array */
|
|
15
|
+
results: T[];
|
|
16
|
+
/** Loading state */
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
/** Error message */
|
|
19
|
+
error: string | null;
|
|
20
|
+
/** Called when a result is selected */
|
|
21
|
+
onSelect: (result: T) => void;
|
|
22
|
+
/** Called when search is cleared */
|
|
23
|
+
onClear: () => void;
|
|
24
|
+
/** Placeholder text */
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
/** Label for the input */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** Custom className */
|
|
29
|
+
className?: string;
|
|
30
|
+
/** Disabled state */
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/** Icon to show in input (default: Search) */
|
|
33
|
+
icon?: React.ReactNode;
|
|
34
|
+
/** Minimum characters before showing results (default: 3) */
|
|
35
|
+
minChars?: number;
|
|
36
|
+
/** Message to show when no results found */
|
|
37
|
+
emptyMessage?: string;
|
|
38
|
+
/** Custom render function for each result */
|
|
39
|
+
renderResult: (result: T, index: number) => React.ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generic search dropdown component with debounced input and keyboard navigation.
|
|
44
|
+
* Supports any data type and custom rendering.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <SearchDropdown<User>
|
|
49
|
+
* value={searchValue}
|
|
50
|
+
* onValueChange={setSearchValue}
|
|
51
|
+
* results={users}
|
|
52
|
+
* isLoading={isLoading}
|
|
53
|
+
* error={error}
|
|
54
|
+
* onSelect={(user) => console.log(user)}
|
|
55
|
+
* onClear={() => setSearchValue('')}
|
|
56
|
+
* placeholder="Search users..."
|
|
57
|
+
* renderResult={(user) => (
|
|
58
|
+
* <div>{user.displayName}</div>
|
|
59
|
+
* )}
|
|
60
|
+
* />
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function SearchDropdown<T>({
|
|
64
|
+
value,
|
|
65
|
+
onValueChange,
|
|
66
|
+
results,
|
|
67
|
+
isLoading,
|
|
68
|
+
error,
|
|
69
|
+
onSelect,
|
|
70
|
+
onClear,
|
|
71
|
+
placeholder = 'Search...',
|
|
72
|
+
label,
|
|
73
|
+
className,
|
|
74
|
+
disabled = false,
|
|
75
|
+
icon = <Search className="h-4 w-4" />,
|
|
76
|
+
minChars = 3,
|
|
77
|
+
emptyMessage = 'No results found',
|
|
78
|
+
renderResult,
|
|
79
|
+
}: SearchDropdownProps<T>) {
|
|
80
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
81
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
82
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
83
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
84
|
+
|
|
85
|
+
// Close dropdown when clicking outside
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
88
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
89
|
+
setIsOpen(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
94
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
// Show dropdown when there are results or loading/error states
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (value.length >= minChars && (results.length > 0 || isLoading || error)) {
|
|
100
|
+
setIsOpen(true);
|
|
101
|
+
} else {
|
|
102
|
+
setIsOpen(false);
|
|
103
|
+
}
|
|
104
|
+
}, [value, results, isLoading, error, minChars]);
|
|
105
|
+
|
|
106
|
+
// Reset selected index when results change
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
setSelectedIndex(-1);
|
|
109
|
+
}, [results]);
|
|
110
|
+
|
|
111
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
112
|
+
if (!isOpen || results.length === 0) return;
|
|
113
|
+
|
|
114
|
+
switch (e.key) {
|
|
115
|
+
case 'ArrowDown':
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
|
|
118
|
+
break;
|
|
119
|
+
case 'ArrowUp':
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
122
|
+
break;
|
|
123
|
+
case 'Enter':
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
if (selectedIndex >= 0 && selectedIndex < results.length) {
|
|
126
|
+
onSelect(results[selectedIndex]);
|
|
127
|
+
setIsOpen(false);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
case 'Escape':
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
setIsOpen(false);
|
|
133
|
+
inputRef.current?.blur();
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handleResultClick = (result: T) => {
|
|
139
|
+
onSelect(result);
|
|
140
|
+
setIsOpen(false);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const handleClear = () => {
|
|
144
|
+
onClear();
|
|
145
|
+
setIsOpen(false);
|
|
146
|
+
inputRef.current?.focus();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const showDropdown = isOpen && value.length >= minChars;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div ref={containerRef} className={cn('relative', className)}>
|
|
153
|
+
{label && (
|
|
154
|
+
<Label htmlFor="search-input" className="mb-2 block">
|
|
155
|
+
{label}
|
|
156
|
+
</Label>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<div className="relative">
|
|
160
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
161
|
+
{icon}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<Input
|
|
165
|
+
ref={inputRef}
|
|
166
|
+
id="search-input"
|
|
167
|
+
type="text"
|
|
168
|
+
value={value}
|
|
169
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
170
|
+
onKeyDown={handleKeyDown}
|
|
171
|
+
placeholder={placeholder}
|
|
172
|
+
disabled={disabled}
|
|
173
|
+
className="pl-10 pr-10"
|
|
174
|
+
/>
|
|
175
|
+
|
|
176
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
177
|
+
{isLoading ? (
|
|
178
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
179
|
+
) : value ? (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={handleClear}
|
|
183
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
184
|
+
aria-label="Clear search"
|
|
185
|
+
>
|
|
186
|
+
<X className="h-4 w-4" />
|
|
187
|
+
</button>
|
|
188
|
+
) : null}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{showDropdown && (
|
|
193
|
+
<div className="absolute z-50 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-auto">
|
|
194
|
+
{error ? (
|
|
195
|
+
<div className="px-3 py-2 text-sm text-destructive">
|
|
196
|
+
{error}
|
|
197
|
+
</div>
|
|
198
|
+
) : isLoading ? (
|
|
199
|
+
<div className="px-3 py-2 text-sm text-muted-foreground flex items-center gap-2">
|
|
200
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
201
|
+
Searching...
|
|
202
|
+
</div>
|
|
203
|
+
) : results.length === 0 ? (
|
|
204
|
+
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
205
|
+
{emptyMessage}
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
results.map((result, index) => (
|
|
209
|
+
<button
|
|
210
|
+
key={index}
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => handleResultClick(result)}
|
|
213
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
214
|
+
className={cn(
|
|
215
|
+
'w-full text-left transition-colors cursor-pointer',
|
|
216
|
+
'hover:bg-accent focus:bg-accent focus:outline-none',
|
|
217
|
+
selectedIndex === index && 'bg-accent'
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
{renderResult(result, index)}
|
|
221
|
+
</button>
|
|
222
|
+
))
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{value.length > 0 && value.length < minChars && (
|
|
228
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
229
|
+
Type at least {minChars} characters to search
|
|
230
|
+
</p>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from "./components/scroll-area";
|
|
|
32
32
|
export * from "./components/table";
|
|
33
33
|
export * from "./components/sheet";
|
|
34
34
|
export * from "./components/collapsible";
|
|
35
|
+
export * from "./components/search-dropdown";
|
|
35
36
|
|
|
36
37
|
// Layout
|
|
37
38
|
export * from "./components/layout/screen-adaptive-view";
|