@papernote/ui 2.0.0 → 2.0.2

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.
@@ -1,6 +1,12 @@
1
-
2
- import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle, useId } from 'react';
3
- import { Check, ChevronDown, Search, X, Plus } from 'lucide-react';
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?: 'error' | 'success' | 'warning' | null;
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?: 'sm' | 'md' | 'lg';
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
- value = '',
83
- onChange,
84
- options,
85
- onSearch,
86
- onCreateOption,
87
- label,
88
- placeholder = 'Search or select...',
89
- allowCustomValue = false,
90
- loading = false,
91
- validationState,
92
- validationMessage,
93
- helperText,
94
- required = false,
95
- disabled = false,
96
- className = '',
97
- size = 'md',
98
- }, ref) => {
99
- const [isOpen, setIsOpen] = useState(false);
100
- const [searchQuery, setSearchQuery] = useState('');
101
- const [highlightedIndex, setHighlightedIndex] = useState(0);
102
- const containerRef = useRef<HTMLDivElement>(null);
103
- const inputRef = useRef<HTMLInputElement>(null);
104
- const listRef = useRef<HTMLUListElement>(null);
105
-
106
- // Generate unique IDs for ARIA
107
- const labelId = useId();
108
- const listboxId = useId();
109
- const descriptionId = useId();
110
-
111
- // Expose methods via ref
112
- useImperativeHandle(ref, () => ({
113
- focus: () => inputRef.current?.focus(),
114
- blur: () => inputRef.current?.blur(),
115
- open: () => setIsOpen(true),
116
- close: () => setIsOpen(false),
117
- }));
118
-
119
- // Filter options based on search query
120
- const filteredOptions = options.filter(option =>
121
- option.label.toLowerCase().includes(searchQuery.toLowerCase())
122
- );
123
-
124
- // Get display value
125
- const selectedOption = options.find(opt => opt.value === value);
126
- const displayValue = isOpen ? searchQuery : (selectedOption?.label || value || '');
127
-
128
- // Close on click outside
129
- useEffect(() => {
130
- const handleClickOutside = (event: MouseEvent) => {
131
- if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
132
- setIsOpen(false);
133
- setSearchQuery('');
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
- if (isOpen) {
138
- document.addEventListener('mousedown', handleClickOutside);
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
- return () => {
142
- document.removeEventListener('mousedown', handleClickOutside);
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
- }, [highlightedIndex, isOpen]);
155
-
156
- // Handle search input change
157
- const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
158
- const query = e.target.value;
159
- setSearchQuery(query);
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
- case 'ArrowUp':
216
- e.preventDefault();
217
- if (isOpen) {
218
- setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0));
219
- }
220
- break;
221
-
222
- case 'Enter':
223
- e.preventDefault();
224
- if (isOpen && filteredOptions.length > 0) {
225
- handleSelectOption(filteredOptions[highlightedIndex]);
226
- } else if (allowCustomValue && searchQuery.trim()) {
227
- onChange?.(searchQuery.trim());
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
- break;
255
+ setSearchQuery("");
256
+ break;
231
257
 
232
- case 'Escape':
233
- e.preventDefault();
234
- setIsOpen(false);
235
- setSearchQuery('');
236
- break;
258
+ case "Tab":
259
+ setIsOpen(false);
260
+ setSearchQuery("");
261
+ break;
262
+ }
263
+ };
237
264
 
238
- case 'Tab':
239
- setIsOpen(false);
240
- setSearchQuery('');
241
- break;
242
- }
243
- };
244
-
245
- // Size classes
246
- const sizeClasses = {
247
- sm: 'text-sm py-1.5 px-3',
248
- md: 'text-sm py-2 px-3',
249
- lg: 'text-base py-2.5 px-4',
250
- };
251
-
252
- const iconSizeClasses = {
253
- sm: 'h-4 w-4',
254
- md: 'h-4 w-4',
255
- lg: 'h-5 w-5',
256
- };
257
-
258
- // Validation classes
259
- const validationClasses = {
260
- error: 'border-error-500 focus:ring-error-500 focus:border-error-500',
261
- success: 'border-success-500 focus:ring-success-500 focus:border-success-500',
262
- warning: 'border-warning-500 focus:ring-warning-500 focus:border-warning-500',
263
- };
264
-
265
- const validationMessageColors = {
266
- error: 'text-error-600',
267
- success: 'text-success-600',
268
- warning: 'text-warning-600',
269
- };
270
-
271
- // Check if can create custom option
272
- const canCreateOption =
273
- onCreateOption &&
274
- searchQuery.trim() &&
275
- !filteredOptions.some(opt => opt.label.toLowerCase() === searchQuery.toLowerCase());
276
-
277
- return (
278
- <div className={`relative ${className}`} ref={containerRef}>
279
- {/* Label */}
280
- {label && (
281
- <label id={labelId} className="block text-sm font-medium text-ink-700 mb-1">
282
- {label}
283
- {required && <span className="text-error-500 ml-1">*</span>}
284
- </label>
285
- )}
286
-
287
- {/* Input */}
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
- <input
291
- ref={inputRef}
292
- type="text"
293
- value={displayValue}
294
- onChange={handleInputChange}
295
- onKeyDown={handleKeyDown}
296
- onFocus={() => setIsOpen(true)}
297
- placeholder={placeholder}
298
- disabled={disabled}
299
- className={`
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] : 'border-paper-300 focus:ring-primary-500 focus:border-primary-500'}
303
- ${disabled ? 'bg-paper-100 text-ink-400 cursor-not-allowed' : ''}
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
- aria-labelledby={label ? labelId : undefined}
308
- aria-label={!label ? 'Combobox' : undefined}
309
- aria-expanded={isOpen}
310
- aria-autocomplete="list"
311
- aria-controls={listboxId}
312
- aria-activedescendant={isOpen && filteredOptions.length > 0 ? `option-${highlightedIndex}` : undefined}
313
- aria-invalid={validationState === 'error' ? 'true' : undefined}
314
- aria-describedby={validationMessage ? descriptionId : undefined}
315
- aria-required={required}
316
- role="combobox"
317
- />
318
-
319
- {/* Icons */}
320
- <div className="absolute inset-y-0 right-0 flex items-center pr-2 gap-1">
321
- {loading && (
322
- <div className="animate-spin">
323
- <Search className={`${iconSizeClasses[size]} text-ink-400`} />
324
- </div>
325
- )}
326
- {!loading && value && !disabled && (
327
- <button
328
- type="button"
329
- onClick={handleClear}
330
- className="p-0.5 text-ink-400 hover:text-ink-600 focus:outline-none"
331
- aria-label="Clear"
332
- tabIndex={-1}
333
- >
334
- <X className={iconSizeClasses[size]} />
335
- </button>
336
- )}
337
- {!loading && (
338
- <ChevronDown className={`${iconSizeClasses[size]} text-ink-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
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
- {/* Helper text or validation message */}
345
- {validationMessage && (
346
- <p id={descriptionId} className={`mt-1 text-xs ${validationState ? validationMessageColors[validationState] : 'text-ink-500'}`} role="alert" aria-live="polite">
347
- {validationMessage}
348
- </p>
349
- )}
350
- {helperText && !validationMessage && (
351
- <p className="mt-1 text-xs text-ink-500">
352
- {helperText}
353
- </p>
354
- )}
355
-
356
- {/* Dropdown */}
357
- {isOpen && (
358
- <div
359
- className="absolute z-50 mt-1 w-full bg-white rounded-md shadow-lg border border-paper-200 max-h-60 overflow-auto"
360
- role="listbox"
361
- id={listboxId}
362
- aria-label="Available options"
363
- >
364
- {loading ? (
365
- <div className="px-4 py-8 text-center text-ink-500 text-sm" role="status" aria-live="polite">
366
- Loading...
367
- </div>
368
- ) : filteredOptions.length === 0 && !canCreateOption ? (
369
- <div className="px-4 py-8 text-center text-ink-500 text-sm" role="status" aria-live="polite">
370
- No options found
371
- </div>
372
- ) : (
373
- <ul ref={listRef}>
374
- {/* Filtered options */}
375
- {filteredOptions.map((option, index) => {
376
- const Icon = option.icon;
377
- const isSelected = option.value === value;
378
- const isHighlighted = index === highlightedIndex;
379
-
380
- return (
381
- <li
382
- key={option.value}
383
- id={`option-${index}`}
384
- role="option"
385
- aria-selected={isSelected}
386
- aria-disabled={option.disabled}
387
- onClick={() => handleSelectOption(option)}
388
- onMouseEnter={() => setHighlightedIndex(index)}
389
- className={`
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 ? 'opacity-50 cursor-not-allowed' : ''}
392
- ${isHighlighted ? 'bg-primary-50' : ''}
393
- ${isSelected ? 'bg-primary-100 font-medium' : ''}
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
- <div className="flex items-center gap-2 flex-1 min-w-0">
398
- {Icon && <Icon className={`${iconSizeClasses[size]} flex-shrink-0 text-ink-600`} />}
399
- <span className="truncate text-sm text-ink-900">{option.label}</span>
400
- </div>
401
- {isSelected && (
402
- <Check className={`${iconSizeClasses[size]} flex-shrink-0 text-primary-600`} />
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
- {/* Create option */}
409
- {canCreateOption && (
410
- <li
411
- role="option"
412
- onClick={handleCreateOption}
413
- className="px-3 py-2 cursor-pointer flex items-center gap-2 border-t border-paper-200 hover:bg-primary-50 bg-success-50"
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 = 'Combobox';
515
+ Combobox.displayName = "Combobox";
430
516
  export default Combobox;