@papernote/ui 2.0.1 → 2.0.3
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/Combobox.d.ts +3 -3
- package/dist/components/Combobox.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +106 -89
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +106 -89
- package/dist/index.js.map +1 -1
- package/dist/styles.css +28 -28
- package/package.json +1 -1
- package/src/components/Combobox.tsx +416 -330
- package/src/styles/index.css +15 -0
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
useRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useId,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Check, ChevronDown, Search, X, Plus } from "lucide-react";
|
|
4
10
|
|
|
5
11
|
export interface ComboboxHandle {
|
|
6
12
|
focus: () => void;
|
|
@@ -40,7 +46,7 @@ export interface ComboboxProps {
|
|
|
40
46
|
/** Loading state */
|
|
41
47
|
loading?: boolean;
|
|
42
48
|
/** Validation state */
|
|
43
|
-
validationState?:
|
|
49
|
+
validationState?: "error" | "success" | "warning" | null;
|
|
44
50
|
/** Validation message */
|
|
45
51
|
validationMessage?: string;
|
|
46
52
|
/** Helper text */
|
|
@@ -52,7 +58,7 @@ export interface ComboboxProps {
|
|
|
52
58
|
/** Custom className */
|
|
53
59
|
className?: string;
|
|
54
60
|
/** Size variant */
|
|
55
|
-
size?:
|
|
61
|
+
size?: "sm" | "md" | "lg";
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/**
|
|
@@ -78,353 +84,433 @@ export interface ComboboxProps {
|
|
|
78
84
|
* />
|
|
79
85
|
* ```
|
|
80
86
|
*/
|
|
81
|
-
const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
87
|
+
const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(
|
|
88
|
+
(
|
|
89
|
+
{
|
|
90
|
+
value = "",
|
|
91
|
+
onChange,
|
|
92
|
+
options,
|
|
93
|
+
onSearch,
|
|
94
|
+
onCreateOption,
|
|
95
|
+
label,
|
|
96
|
+
placeholder = "Search or select...",
|
|
97
|
+
allowCustomValue = false,
|
|
98
|
+
loading = false,
|
|
99
|
+
validationState,
|
|
100
|
+
validationMessage,
|
|
101
|
+
helperText,
|
|
102
|
+
required = false,
|
|
103
|
+
disabled = false,
|
|
104
|
+
className = "",
|
|
105
|
+
size = "md",
|
|
106
|
+
},
|
|
107
|
+
ref,
|
|
108
|
+
) => {
|
|
109
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
110
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
111
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
112
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
113
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
114
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
115
|
+
|
|
116
|
+
// Generate unique IDs for ARIA
|
|
117
|
+
const labelId = useId();
|
|
118
|
+
const listboxId = useId();
|
|
119
|
+
const descriptionId = useId();
|
|
120
|
+
|
|
121
|
+
// Expose methods via ref
|
|
122
|
+
useImperativeHandle(ref, () => ({
|
|
123
|
+
focus: () => inputRef.current?.focus(),
|
|
124
|
+
blur: () => inputRef.current?.blur(),
|
|
125
|
+
open: () => setIsOpen(true),
|
|
126
|
+
close: () => setIsOpen(false),
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
// Filter options based on search query
|
|
130
|
+
const filteredOptions = options.filter((option) =>
|
|
131
|
+
option.label.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Get display value
|
|
135
|
+
const selectedOption = options.find((opt) => opt.value === value);
|
|
136
|
+
const displayValue = isOpen
|
|
137
|
+
? searchQuery
|
|
138
|
+
: selectedOption?.label || value || "";
|
|
139
|
+
|
|
140
|
+
// Close on click outside
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
143
|
+
if (
|
|
144
|
+
containerRef.current &&
|
|
145
|
+
!containerRef.current.contains(event.target as Node)
|
|
146
|
+
) {
|
|
147
|
+
setIsOpen(false);
|
|
148
|
+
setSearchQuery("");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (isOpen) {
|
|
153
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
134
154
|
}
|
|
135
|
-
};
|
|
136
155
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
return () => {
|
|
157
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
158
|
+
};
|
|
159
|
+
}, [isOpen]);
|
|
160
|
+
|
|
161
|
+
// Scroll highlighted option into view
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (isOpen && listRef.current) {
|
|
164
|
+
const highlightedElement = listRef.current.children[
|
|
165
|
+
highlightedIndex
|
|
166
|
+
] as HTMLElement;
|
|
167
|
+
if (highlightedElement) {
|
|
168
|
+
highlightedElement.scrollIntoView({
|
|
169
|
+
block: "nearest",
|
|
170
|
+
behavior: "smooth",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}, [highlightedIndex, isOpen]);
|
|
175
|
+
|
|
176
|
+
// Handle search input change
|
|
177
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
178
|
+
const query = e.target.value;
|
|
179
|
+
setSearchQuery(query);
|
|
180
|
+
setIsOpen(true);
|
|
181
|
+
setHighlightedIndex(0);
|
|
182
|
+
|
|
183
|
+
// Trigger search callback for async filtering
|
|
184
|
+
if (onSearch) {
|
|
185
|
+
onSearch(query);
|
|
186
|
+
}
|
|
140
187
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}, [isOpen]);
|
|
145
|
-
|
|
146
|
-
// Scroll highlighted option into view
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
if (isOpen && listRef.current) {
|
|
149
|
-
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
|
|
150
|
-
if (highlightedElement) {
|
|
151
|
-
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
188
|
+
// If allowCustomValue, update value immediately
|
|
189
|
+
if (allowCustomValue) {
|
|
190
|
+
onChange?.(query);
|
|
152
191
|
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
setIsOpen(true);
|
|
161
|
-
setHighlightedIndex(0);
|
|
162
|
-
|
|
163
|
-
// Trigger search callback for async filtering
|
|
164
|
-
if (onSearch) {
|
|
165
|
-
onSearch(query);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// If allowCustomValue, update value immediately
|
|
169
|
-
if (allowCustomValue) {
|
|
170
|
-
onChange?.(query);
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
// Handle option selection
|
|
175
|
-
const handleSelectOption = (option: ComboboxOption) => {
|
|
176
|
-
if (option.disabled) return;
|
|
177
|
-
onChange?.(option.value);
|
|
178
|
-
setSearchQuery('');
|
|
179
|
-
setIsOpen(false);
|
|
180
|
-
inputRef.current?.blur();
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
// Handle create custom option
|
|
184
|
-
const handleCreateOption = () => {
|
|
185
|
-
if (searchQuery.trim() && onCreateOption) {
|
|
186
|
-
onCreateOption(searchQuery.trim());
|
|
187
|
-
setSearchQuery('');
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Handle option selection
|
|
195
|
+
const handleSelectOption = (option: ComboboxOption) => {
|
|
196
|
+
if (option.disabled) return;
|
|
197
|
+
onChange?.(option.value);
|
|
198
|
+
setSearchQuery("");
|
|
188
199
|
setIsOpen(false);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Handle clear
|
|
193
|
-
const handleClear = () => {
|
|
194
|
-
onChange?.('');
|
|
195
|
-
setSearchQuery('');
|
|
196
|
-
inputRef.current?.focus();
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
// Keyboard navigation
|
|
200
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
201
|
-
if (disabled) return;
|
|
202
|
-
|
|
203
|
-
switch (e.key) {
|
|
204
|
-
case 'ArrowDown':
|
|
205
|
-
e.preventDefault();
|
|
206
|
-
if (!isOpen) {
|
|
207
|
-
setIsOpen(true);
|
|
208
|
-
} else {
|
|
209
|
-
setHighlightedIndex(prev =>
|
|
210
|
-
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
break;
|
|
200
|
+
inputRef.current?.blur();
|
|
201
|
+
};
|
|
214
202
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
203
|
+
// Handle create custom option
|
|
204
|
+
const handleCreateOption = () => {
|
|
205
|
+
if (searchQuery.trim() && onCreateOption) {
|
|
206
|
+
onCreateOption(searchQuery.trim());
|
|
207
|
+
setSearchQuery("");
|
|
208
|
+
setIsOpen(false);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Handle clear
|
|
213
|
+
const handleClear = () => {
|
|
214
|
+
onChange?.("");
|
|
215
|
+
setSearchQuery("");
|
|
216
|
+
inputRef.current?.focus();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Keyboard navigation
|
|
220
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
221
|
+
if (disabled) return;
|
|
222
|
+
|
|
223
|
+
switch (e.key) {
|
|
224
|
+
case "ArrowDown":
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
if (!isOpen) {
|
|
227
|
+
setIsOpen(true);
|
|
228
|
+
} else {
|
|
229
|
+
setHighlightedIndex((prev) =>
|
|
230
|
+
prev < filteredOptions.length - 1 ? prev + 1 : prev,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case "ArrowUp":
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
if (isOpen) {
|
|
238
|
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case "Enter":
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
if (isOpen && filteredOptions.length > 0) {
|
|
245
|
+
handleSelectOption(filteredOptions[highlightedIndex]);
|
|
246
|
+
} else if (allowCustomValue && searchQuery.trim()) {
|
|
247
|
+
onChange?.(searchQuery.trim());
|
|
248
|
+
setIsOpen(false);
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
|
|
252
|
+
case "Escape":
|
|
253
|
+
e.preventDefault();
|
|
228
254
|
setIsOpen(false);
|
|
229
|
-
|
|
230
|
-
|
|
255
|
+
setSearchQuery("");
|
|
256
|
+
break;
|
|
231
257
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
258
|
+
case "Tab":
|
|
259
|
+
setIsOpen(false);
|
|
260
|
+
setSearchQuery("");
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
237
264
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
<div className="relative">
|
|
265
|
+
// Size classes
|
|
266
|
+
const sizeClasses = {
|
|
267
|
+
sm: "text-sm py-1.5 px-3",
|
|
268
|
+
md: "text-sm py-2 px-3",
|
|
269
|
+
lg: "text-base py-2.5 px-4",
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const iconSizeClasses = {
|
|
273
|
+
sm: "h-4 w-4",
|
|
274
|
+
md: "h-4 w-4",
|
|
275
|
+
lg: "h-5 w-5",
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Validation classes
|
|
279
|
+
const validationClasses = {
|
|
280
|
+
error: "border-error-500 focus:ring-error-500 focus:border-error-500",
|
|
281
|
+
success:
|
|
282
|
+
"border-success-500 focus:ring-success-500 focus:border-success-500",
|
|
283
|
+
warning:
|
|
284
|
+
"border-warning-500 focus:ring-warning-500 focus:border-warning-500",
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const validationMessageColors = {
|
|
288
|
+
error: "text-error-600",
|
|
289
|
+
success: "text-success-600",
|
|
290
|
+
warning: "text-warning-600",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Check if can create custom option
|
|
294
|
+
const canCreateOption =
|
|
295
|
+
onCreateOption &&
|
|
296
|
+
searchQuery.trim() &&
|
|
297
|
+
!filteredOptions.some(
|
|
298
|
+
(opt) => opt.label.toLowerCase() === searchQuery.toLowerCase(),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className={`relative ${className}`} ref={containerRef}>
|
|
303
|
+
{/* Label */}
|
|
304
|
+
{label && (
|
|
305
|
+
<label
|
|
306
|
+
id={labelId}
|
|
307
|
+
className="block text-sm font-medium text-ink-700 mb-1"
|
|
308
|
+
>
|
|
309
|
+
{label}
|
|
310
|
+
{required && <span className="text-error-500 ml-1">*</span>}
|
|
311
|
+
</label>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{/* Input */}
|
|
289
315
|
<div className="relative">
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
316
|
+
<div className="relative">
|
|
317
|
+
<input
|
|
318
|
+
ref={inputRef}
|
|
319
|
+
type="text"
|
|
320
|
+
value={displayValue}
|
|
321
|
+
onChange={handleInputChange}
|
|
322
|
+
onKeyDown={handleKeyDown}
|
|
323
|
+
onFocus={() => setIsOpen(true)}
|
|
324
|
+
onMouseDown={(e) => {
|
|
325
|
+
// Toggle close on click when the input is already focused + open.
|
|
326
|
+
// Without this, the second click is a no-op because onFocus only
|
|
327
|
+
// fires on focus transition.
|
|
328
|
+
if (isOpen && document.activeElement === inputRef.current) {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
setIsOpen(false);
|
|
331
|
+
}
|
|
332
|
+
}}
|
|
333
|
+
placeholder={placeholder}
|
|
334
|
+
disabled={disabled}
|
|
335
|
+
className={`
|
|
300
336
|
w-full rounded-md border bg-white
|
|
301
337
|
${sizeClasses[size]}
|
|
302
|
-
${validationState ? validationClasses[validationState] :
|
|
303
|
-
${disabled ?
|
|
338
|
+
${validationState ? validationClasses[validationState] : "border-paper-300 focus:ring-primary-500 focus:border-primary-500"}
|
|
339
|
+
${disabled ? "bg-paper-100 text-ink-400 cursor-not-allowed" : ""}
|
|
304
340
|
focus:outline-none focus:ring-2
|
|
305
341
|
pr-20
|
|
306
342
|
`}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
343
|
+
aria-labelledby={label ? labelId : undefined}
|
|
344
|
+
aria-label={!label ? "Combobox" : undefined}
|
|
345
|
+
aria-expanded={isOpen}
|
|
346
|
+
aria-autocomplete="list"
|
|
347
|
+
aria-controls={listboxId}
|
|
348
|
+
aria-activedescendant={
|
|
349
|
+
isOpen && filteredOptions.length > 0
|
|
350
|
+
? `option-${highlightedIndex}`
|
|
351
|
+
: undefined
|
|
352
|
+
}
|
|
353
|
+
aria-invalid={validationState === "error" ? "true" : undefined}
|
|
354
|
+
aria-describedby={validationMessage ? descriptionId : undefined}
|
|
355
|
+
aria-required={required}
|
|
356
|
+
role="combobox"
|
|
357
|
+
/>
|
|
358
|
+
|
|
359
|
+
{/* Icons */}
|
|
360
|
+
<div className="absolute inset-y-0 right-0 flex items-center pr-2 gap-1">
|
|
361
|
+
{loading && (
|
|
362
|
+
<div className="animate-spin">
|
|
363
|
+
<Search className={`${iconSizeClasses[size]} text-ink-400`} />
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
{!loading && value && !disabled && (
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={handleClear}
|
|
370
|
+
className="p-0.5 text-ink-400 hover:text-ink-600 focus:outline-none"
|
|
371
|
+
aria-label="Clear"
|
|
372
|
+
tabIndex={-1}
|
|
373
|
+
>
|
|
374
|
+
<X className={iconSizeClasses[size]} />
|
|
375
|
+
</button>
|
|
376
|
+
)}
|
|
377
|
+
{!loading && !disabled && (
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
onMouseDown={(e) => {
|
|
381
|
+
// preventDefault keeps the input from losing focus on
|
|
382
|
+
// mousedown so the toggle stays smooth. Manually re-focus
|
|
383
|
+
// when opening so keyboard nav works immediately.
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
setIsOpen((o) => !o);
|
|
386
|
+
if (!isOpen) {
|
|
387
|
+
inputRef.current?.focus();
|
|
388
|
+
}
|
|
389
|
+
}}
|
|
390
|
+
className="p-0.5 text-ink-400 hover:text-ink-600 focus:outline-none"
|
|
391
|
+
aria-label={isOpen ? "Close options" : "Open options"}
|
|
392
|
+
tabIndex={-1}
|
|
393
|
+
>
|
|
394
|
+
<ChevronDown
|
|
395
|
+
className={`${iconSizeClasses[size]} transition-transform ${isOpen ? "rotate-180" : ""}`}
|
|
396
|
+
/>
|
|
397
|
+
</button>
|
|
398
|
+
)}
|
|
399
|
+
{!loading && disabled && (
|
|
400
|
+
<ChevronDown
|
|
401
|
+
className={`${iconSizeClasses[size]} text-ink-400 transition-transform`}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
340
405
|
</div>
|
|
341
406
|
</div>
|
|
342
|
-
</div>
|
|
343
407
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
408
|
+
{/* Helper text or validation message */}
|
|
409
|
+
{validationMessage && (
|
|
410
|
+
<p
|
|
411
|
+
id={descriptionId}
|
|
412
|
+
className={`mt-1 text-xs ${validationState ? validationMessageColors[validationState] : "text-ink-500"}`}
|
|
413
|
+
role="alert"
|
|
414
|
+
aria-live="polite"
|
|
415
|
+
>
|
|
416
|
+
{validationMessage}
|
|
417
|
+
</p>
|
|
418
|
+
)}
|
|
419
|
+
{helperText && !validationMessage && (
|
|
420
|
+
<p className="mt-1 text-xs text-ink-500">{helperText}</p>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Dropdown */}
|
|
424
|
+
{isOpen && (
|
|
425
|
+
<div
|
|
426
|
+
className="absolute z-50 mt-1 w-full bg-white rounded-md shadow-lg border border-paper-200 max-h-60 overflow-auto"
|
|
427
|
+
role="listbox"
|
|
428
|
+
id={listboxId}
|
|
429
|
+
aria-label="Available options"
|
|
430
|
+
>
|
|
431
|
+
{loading ? (
|
|
432
|
+
<div
|
|
433
|
+
className="px-4 py-8 text-center text-ink-500 text-sm"
|
|
434
|
+
role="status"
|
|
435
|
+
aria-live="polite"
|
|
436
|
+
>
|
|
437
|
+
Loading...
|
|
438
|
+
</div>
|
|
439
|
+
) : filteredOptions.length === 0 && !canCreateOption ? (
|
|
440
|
+
<div
|
|
441
|
+
className="px-4 py-8 text-center text-ink-500 text-sm"
|
|
442
|
+
role="status"
|
|
443
|
+
aria-live="polite"
|
|
444
|
+
>
|
|
445
|
+
No options found
|
|
446
|
+
</div>
|
|
447
|
+
) : (
|
|
448
|
+
<ul ref={listRef}>
|
|
449
|
+
{/* Filtered options */}
|
|
450
|
+
{filteredOptions.map((option, index) => {
|
|
451
|
+
const Icon = option.icon;
|
|
452
|
+
const isSelected = option.value === value;
|
|
453
|
+
const isHighlighted = index === highlightedIndex;
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<li
|
|
457
|
+
key={option.value}
|
|
458
|
+
id={`option-${index}`}
|
|
459
|
+
role="option"
|
|
460
|
+
aria-selected={isSelected}
|
|
461
|
+
aria-disabled={option.disabled}
|
|
462
|
+
onClick={() => handleSelectOption(option)}
|
|
463
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
464
|
+
className={`
|
|
390
465
|
px-3 py-2 cursor-pointer flex items-center justify-between gap-2
|
|
391
|
-
${option.disabled ?
|
|
392
|
-
${isHighlighted ?
|
|
393
|
-
${isSelected ?
|
|
466
|
+
${option.disabled ? "opacity-50 cursor-not-allowed" : ""}
|
|
467
|
+
${isHighlighted ? "bg-primary-50" : ""}
|
|
468
|
+
${isSelected ? "bg-primary-100 font-medium" : ""}
|
|
394
469
|
hover:bg-primary-50
|
|
395
470
|
`}
|
|
471
|
+
>
|
|
472
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
473
|
+
{Icon && (
|
|
474
|
+
<Icon
|
|
475
|
+
className={`${iconSizeClasses[size]} flex-shrink-0 text-ink-600`}
|
|
476
|
+
/>
|
|
477
|
+
)}
|
|
478
|
+
<span className="truncate text-sm text-ink-900">
|
|
479
|
+
{option.label}
|
|
480
|
+
</span>
|
|
481
|
+
</div>
|
|
482
|
+
{isSelected && (
|
|
483
|
+
<Check
|
|
484
|
+
className={`${iconSizeClasses[size]} flex-shrink-0 text-primary-600`}
|
|
485
|
+
/>
|
|
486
|
+
)}
|
|
487
|
+
</li>
|
|
488
|
+
);
|
|
489
|
+
})}
|
|
490
|
+
|
|
491
|
+
{/* Create option */}
|
|
492
|
+
{canCreateOption && (
|
|
493
|
+
<li
|
|
494
|
+
role="option"
|
|
495
|
+
onClick={handleCreateOption}
|
|
496
|
+
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-t border-paper-200 hover:bg-primary-50 bg-success-50"
|
|
396
497
|
>
|
|
397
|
-
<
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
)}
|
|
498
|
+
<Plus
|
|
499
|
+
className={`${iconSizeClasses[size]} text-success-600`}
|
|
500
|
+
/>
|
|
501
|
+
<span className="text-sm text-success-700 font-medium">
|
|
502
|
+
Create "{searchQuery}"
|
|
503
|
+
</span>
|
|
404
504
|
</li>
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
>
|
|
415
|
-
<Plus className={`${iconSizeClasses[size]} text-success-600`} />
|
|
416
|
-
<span className="text-sm text-success-700 font-medium">
|
|
417
|
-
Create "{searchQuery}"
|
|
418
|
-
</span>
|
|
419
|
-
</li>
|
|
420
|
-
)}
|
|
421
|
-
</ul>
|
|
422
|
-
)}
|
|
423
|
-
</div>
|
|
424
|
-
)}
|
|
425
|
-
</div>
|
|
426
|
-
);
|
|
427
|
-
});
|
|
505
|
+
)}
|
|
506
|
+
</ul>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
},
|
|
513
|
+
);
|
|
428
514
|
|
|
429
|
-
Combobox.displayName =
|
|
515
|
+
Combobox.displayName = "Combobox";
|
|
430
516
|
export default Combobox;
|