@synthaxai/ui 1.0.0 → 1.1.0

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.
Files changed (36) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +40 -220
  3. package/dist/data-display/DataTable/DataTable.svelte +14 -8
  4. package/dist/data-display/DataTable/DataTable.svelte.d.ts +64 -61
  5. package/dist/data-display/DataTable/DataTable.svelte.d.ts.map +1 -1
  6. package/dist/forms/DatePicker/DatePicker.svelte +1564 -0
  7. package/dist/forms/DatePicker/DatePicker.svelte.d.ts +129 -0
  8. package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
  9. package/dist/forms/DatePicker/index.d.ts +8 -0
  10. package/dist/forms/DatePicker/index.d.ts.map +1 -0
  11. package/dist/forms/DatePicker/index.js +7 -0
  12. package/dist/forms/MultiSelect/MultiSelect.svelte +1651 -0
  13. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +127 -0
  14. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
  15. package/dist/forms/MultiSelect/index.d.ts +9 -0
  16. package/dist/forms/MultiSelect/index.d.ts.map +1 -0
  17. package/dist/forms/MultiSelect/index.js +7 -0
  18. package/dist/forms/SearchInput/SearchInput.svelte +1319 -0
  19. package/dist/forms/SearchInput/SearchInput.svelte.d.ts +148 -0
  20. package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
  21. package/dist/forms/SearchInput/index.d.ts +9 -0
  22. package/dist/forms/SearchInput/index.d.ts.map +1 -0
  23. package/dist/forms/SearchInput/index.js +7 -0
  24. package/dist/forms/TextInput/TextInput.svelte +763 -122
  25. package/dist/forms/TextInput/TextInput.svelte.d.ts +98 -13
  26. package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
  27. package/dist/forms/TextInput/index.d.ts +7 -1
  28. package/dist/forms/TextInput/index.d.ts.map +1 -1
  29. package/dist/forms/TextInput/index.js +7 -1
  30. package/dist/forms/index.d.ts +3 -0
  31. package/dist/forms/index.d.ts.map +1 -1
  32. package/dist/forms/index.js +3 -0
  33. package/dist/index.d.ts +5 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/package.json +3 -3
@@ -0,0 +1,1319 @@
1
+ <!--
2
+ @component SearchInput
3
+
4
+ A fully accessible autocomplete/combobox component following WAI-ARIA 1.2 pattern.
5
+ Supports async search, keyboard navigation, and custom option rendering.
6
+
7
+ Designed for healthcare applications requiring patient, provider, or payer search.
8
+
9
+ @example Basic usage
10
+ <SearchInput
11
+ id="patient-search"
12
+ label="Search Patient"
13
+ value={searchQuery}
14
+ options={patients}
15
+ onselect={(option) => handlePatientSelect(option)}
16
+ onsearch={(query) => searchPatients(query)}
17
+ />
18
+
19
+ @example With custom rendering
20
+ <SearchInput
21
+ id="payer-search"
22
+ label="Insurance Payer"
23
+ value={payerQuery}
24
+ options={payers}
25
+ onselect={handlePayerSelect}
26
+ >
27
+ {#snippet optionContent(option)}
28
+ <div class="flex items-center gap-2">
29
+ <span class="font-medium">{option.label}</span>
30
+ <span class="text-sm text-muted">{option.metadata?.type}</span>
31
+ </div>
32
+ {/snippet}
33
+ </SearchInput>
34
+ -->
35
+ <script lang="ts">
36
+ import type { Snippet } from 'svelte';
37
+ import type { HTMLInputAttributes } from 'svelte/elements';
38
+ import { Search, X, Loader2, AlertCircle, Check, ChevronDown, Info } from 'lucide-svelte';
39
+ import { cn } from '../../utils/cn.js';
40
+ import { generateId, Keys } from '../../utils/keyboard.js';
41
+ import { announce, prefersReducedMotion } from '../../utils/accessibility.js';
42
+ import type { InputMode, AutoComplete } from '../../types/index.js';
43
+
44
+ // ============================================================================
45
+ // Types
46
+ // ============================================================================
47
+
48
+ type InputSize = 'sm' | 'md' | 'lg';
49
+
50
+ /**
51
+ * Search option item with value, label, and optional metadata.
52
+ */
53
+ export interface SearchOption {
54
+ /** Unique value for the option */
55
+ value: string;
56
+ /** Display label for the option */
57
+ label: string;
58
+ /** Whether the option is disabled */
59
+ disabled?: boolean;
60
+ /** Optional description shown below the label */
61
+ description?: string;
62
+ /** Optional icon or visual indicator */
63
+ icon?: typeof Search;
64
+ /** Optional metadata for custom rendering */
65
+ metadata?: Record<string, unknown>;
66
+ }
67
+
68
+ /**
69
+ * Grouped search options for categorized results.
70
+ */
71
+ export interface SearchOptionGroup {
72
+ /** Group label */
73
+ label: string;
74
+ /** Options in this group */
75
+ options: SearchOption[];
76
+ }
77
+
78
+ type SearchOptions = SearchOption[] | SearchOptionGroup[];
79
+
80
+ interface Props extends Omit<HTMLInputAttributes, 'class' | 'size' | 'value' | 'onselect'> {
81
+ /** Unique identifier for the input */
82
+ id?: string;
83
+ /** Form field name */
84
+ name?: string;
85
+ /** Label text for the input */
86
+ label: string;
87
+ /** Current input value (the search query) */
88
+ value?: string;
89
+ /** Currently selected option */
90
+ selected?: SearchOption | null;
91
+ /** Placeholder text */
92
+ placeholder?: string;
93
+ /** Options to display (flat or grouped) */
94
+ options?: SearchOptions;
95
+ /** Whether the field is required */
96
+ required?: boolean;
97
+ /** Whether the field is disabled */
98
+ disabled?: boolean;
99
+ /** Whether the field is read-only */
100
+ readonly?: boolean;
101
+ /** Error message to display */
102
+ error?: string;
103
+ /** Hint text to display below the input */
104
+ hint?: string;
105
+ /** Success message when valid */
106
+ successMessage?: string;
107
+ /** Size of the input */
108
+ size?: InputSize;
109
+ /** Whether to hide the label visually (still accessible) */
110
+ hideLabel?: boolean;
111
+ /** Additional CSS classes for the wrapper */
112
+ class?: string;
113
+ /** Show loading spinner during async search */
114
+ loading?: boolean;
115
+ /** Message to show when no results found */
116
+ emptyMessage?: string;
117
+ /** Minimum characters before triggering search */
118
+ minSearchLength?: number;
119
+ /** Debounce delay for search in milliseconds */
120
+ debounceMs?: number;
121
+ /** Whether to show clear button when has value */
122
+ clearable?: boolean;
123
+ /** Whether to open dropdown on focus (even if empty) */
124
+ openOnFocus?: boolean;
125
+ /** Whether to auto-select first option */
126
+ autoSelect?: boolean;
127
+ /** Whether to allow free text (value not from options) */
128
+ allowFreeText?: boolean;
129
+ /** Icon to display on the left */
130
+ iconLeft?: Snippet;
131
+ /** Custom content for option rendering */
132
+ optionContent?: Snippet<[SearchOption]>;
133
+ /** Custom empty state content */
134
+ emptyContent?: Snippet;
135
+ /** Custom loading content */
136
+ loadingContent?: Snippet;
137
+ /** Input mode for mobile keyboards */
138
+ inputMode?: InputMode;
139
+ /** Autocomplete value */
140
+ autocomplete?: AutoComplete;
141
+ /** Called when search query changes (debounced) */
142
+ onsearch?: (query: string) => void;
143
+ /** Called when an option is selected */
144
+ onselect?: (option: SearchOption) => void;
145
+ /** Called when selection is cleared */
146
+ onclear?: () => void;
147
+ /** Called when input value changes */
148
+ oninput?: (event: Event) => void;
149
+ /** Called when input is focused */
150
+ onfocus?: (event: FocusEvent) => void;
151
+ /** Called when input loses focus */
152
+ onblur?: (event: FocusEvent) => void;
153
+ /** Test ID for e2e testing */
154
+ testId?: string;
155
+ /** Show validation state immediately */
156
+ validateOnMount?: boolean;
157
+ }
158
+
159
+ let {
160
+ id = generateId('search'),
161
+ name,
162
+ label,
163
+ value = $bindable(''),
164
+ selected = $bindable(null),
165
+ placeholder = 'Search...',
166
+ options = [],
167
+ required = false,
168
+ disabled = false,
169
+ readonly = false,
170
+ error = '',
171
+ hint = '',
172
+ successMessage = '',
173
+ size = 'md',
174
+ hideLabel = false,
175
+ class: className = '',
176
+ loading = false,
177
+ emptyMessage = 'No results found',
178
+ minSearchLength = 1,
179
+ debounceMs = 300,
180
+ clearable = true,
181
+ openOnFocus = false,
182
+ autoSelect = false,
183
+ allowFreeText = false,
184
+ iconLeft,
185
+ optionContent,
186
+ emptyContent,
187
+ loadingContent,
188
+ inputMode = 'search',
189
+ autocomplete = 'off',
190
+ onsearch,
191
+ onselect,
192
+ onclear,
193
+ oninput,
194
+ onfocus,
195
+ onblur,
196
+ testId,
197
+ validateOnMount = false,
198
+ ...restProps
199
+ }: Props = $props();
200
+
201
+ // ============================================================================
202
+ // State
203
+ // ============================================================================
204
+
205
+ let inputRef: HTMLInputElement | undefined = $state();
206
+ let listboxRef: HTMLDivElement | undefined = $state();
207
+ let isOpen = $state(false);
208
+ let isFocused = $state(false);
209
+ let hasInteracted = $state(false);
210
+ let activeIndex = $state(-1);
211
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
212
+
213
+ // ============================================================================
214
+ // Derived State
215
+ // ============================================================================
216
+
217
+ // Flatten grouped options for navigation
218
+ const flatOptions = $derived.by(() => {
219
+ if (options.length === 0) return [];
220
+ if ('options' in options[0]) {
221
+ return (options as SearchOptionGroup[]).flatMap((g) => g.options);
222
+ }
223
+ return options as SearchOption[];
224
+ });
225
+
226
+ // Filter to only enabled options for navigation
227
+ const enabledOptions = $derived(flatOptions.filter((o) => !o.disabled));
228
+
229
+ // Check if options are grouped
230
+ const isGrouped = $derived(options.length > 0 && 'options' in options[0]);
231
+
232
+ // Validation display logic
233
+ const shouldValidate = $derived(hasInteracted || validateOnMount);
234
+ const showError = $derived(shouldValidate && !!error);
235
+ const showSuccess = $derived(shouldValidate && !error && !!selected && !!successMessage);
236
+ const isValid = $derived(shouldValidate && !error && !!selected && !!successMessage);
237
+
238
+ // Should show dropdown
239
+ const shouldShowDropdown = $derived(
240
+ isOpen &&
241
+ !disabled &&
242
+ !readonly &&
243
+ (loading || flatOptions.length > 0 || (value.length >= minSearchLength && !loading))
244
+ );
245
+
246
+ // Show "no results" state
247
+ const showEmpty = $derived(
248
+ !loading && flatOptions.length === 0 && value.length >= minSearchLength
249
+ );
250
+
251
+ // IDs for accessibility
252
+ const listboxId = $derived(`${id}-listbox`);
253
+ const errorId = $derived(`${id}-error`);
254
+ const hintId = $derived(`${id}-hint`);
255
+ const activeDescendantId = $derived(
256
+ activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined
257
+ );
258
+
259
+ // Build aria-describedby
260
+ const ariaDescribedBy = $derived(
261
+ [showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
262
+ );
263
+
264
+ // ============================================================================
265
+ // Helpers
266
+ // ============================================================================
267
+
268
+ function getOptionIndex(option: SearchOption): number {
269
+ return flatOptions.findIndex((o) => o.value === option.value);
270
+ }
271
+
272
+ function scrollToOption(index: number) {
273
+ if (!listboxRef || index < 0) return;
274
+ const optionEl = listboxRef.querySelector(`[data-index="${index}"]`);
275
+ if (optionEl && typeof optionEl.scrollIntoView === 'function') {
276
+ optionEl.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
277
+ }
278
+ }
279
+
280
+ // ============================================================================
281
+ // Event Handlers
282
+ // ============================================================================
283
+
284
+ function handleInput(e: Event) {
285
+ const target = e.target as HTMLInputElement;
286
+ value = target.value;
287
+ hasInteracted = true;
288
+
289
+ // Clear selection if user modifies text (unless allowFreeText)
290
+ if (selected && !allowFreeText) {
291
+ selected = null;
292
+ }
293
+
294
+ // Open dropdown
295
+ if (value.length >= minSearchLength) {
296
+ isOpen = true;
297
+ }
298
+
299
+ // Reset active index
300
+ activeIndex = autoSelect && enabledOptions.length > 0 ? 0 : -1;
301
+
302
+ // Debounced search callback
303
+ if (debounceTimer) {
304
+ clearTimeout(debounceTimer);
305
+ }
306
+ debounceTimer = setTimeout(() => {
307
+ if (value.length >= minSearchLength) {
308
+ onsearch?.(value);
309
+ }
310
+ }, debounceMs);
311
+
312
+ oninput?.(e);
313
+ }
314
+
315
+ function handleFocus(e: FocusEvent) {
316
+ isFocused = true;
317
+
318
+ // Open on focus if configured
319
+ if (openOnFocus && value.length >= minSearchLength) {
320
+ isOpen = true;
321
+ }
322
+
323
+ onfocus?.(e);
324
+ }
325
+
326
+ function handleBlur(e: FocusEvent) {
327
+ // Don't close if clicking inside the dropdown
328
+ const relatedTarget = e.relatedTarget as HTMLElement | null;
329
+ if (relatedTarget && listboxRef?.contains(relatedTarget)) {
330
+ return;
331
+ }
332
+
333
+ isFocused = false;
334
+ hasInteracted = true;
335
+
336
+ // Delay closing to allow click events to fire
337
+ setTimeout(() => {
338
+ if (!isFocused) {
339
+ isOpen = false;
340
+ activeIndex = -1;
341
+ }
342
+ }, 150);
343
+
344
+ onblur?.(e);
345
+ }
346
+
347
+ function handleKeyDown(e: KeyboardEvent) {
348
+ if (disabled || readonly) return;
349
+
350
+ switch (e.key) {
351
+ case Keys.ArrowDown:
352
+ e.preventDefault();
353
+ if (!isOpen && value.length >= minSearchLength) {
354
+ isOpen = true;
355
+ activeIndex = 0;
356
+ } else if (isOpen && enabledOptions.length > 0) {
357
+ activeIndex = Math.min(activeIndex + 1, enabledOptions.length - 1);
358
+ // Find actual index in flatOptions
359
+ const targetOption = enabledOptions[activeIndex];
360
+ const actualIndex = getOptionIndex(targetOption);
361
+ scrollToOption(actualIndex);
362
+ }
363
+ break;
364
+
365
+ case Keys.ArrowUp:
366
+ e.preventDefault();
367
+ if (isOpen && enabledOptions.length > 0) {
368
+ activeIndex = Math.max(activeIndex - 1, 0);
369
+ const targetOption = enabledOptions[activeIndex];
370
+ const actualIndex = getOptionIndex(targetOption);
371
+ scrollToOption(actualIndex);
372
+ }
373
+ break;
374
+
375
+ case Keys.Home:
376
+ if (isOpen && enabledOptions.length > 0) {
377
+ e.preventDefault();
378
+ activeIndex = 0;
379
+ scrollToOption(getOptionIndex(enabledOptions[0]));
380
+ }
381
+ break;
382
+
383
+ case Keys.End:
384
+ if (isOpen && enabledOptions.length > 0) {
385
+ e.preventDefault();
386
+ activeIndex = enabledOptions.length - 1;
387
+ scrollToOption(getOptionIndex(enabledOptions[activeIndex]));
388
+ }
389
+ break;
390
+
391
+ case Keys.Enter:
392
+ if (isOpen && activeIndex >= 0 && activeIndex < enabledOptions.length) {
393
+ e.preventDefault();
394
+ selectOption(enabledOptions[activeIndex]);
395
+ }
396
+ break;
397
+
398
+ case Keys.Escape:
399
+ if (isOpen) {
400
+ e.preventDefault();
401
+ isOpen = false;
402
+ activeIndex = -1;
403
+ inputRef?.focus();
404
+ }
405
+ break;
406
+
407
+ case Keys.Tab:
408
+ // Allow tab but close dropdown
409
+ isOpen = false;
410
+ activeIndex = -1;
411
+ break;
412
+ }
413
+ }
414
+
415
+ function selectOption(option: SearchOption) {
416
+ if (option.disabled) return;
417
+
418
+ selected = option;
419
+ value = option.label;
420
+ isOpen = false;
421
+ activeIndex = -1;
422
+ hasInteracted = true;
423
+
424
+ // Announce selection to screen readers
425
+ announce(`Selected: ${option.label}`, 'polite');
426
+
427
+ onselect?.(option);
428
+ inputRef?.focus();
429
+ }
430
+
431
+ function handleOptionClick(option: SearchOption, e: MouseEvent) {
432
+ e.preventDefault();
433
+ e.stopPropagation();
434
+ selectOption(option);
435
+ }
436
+
437
+ function handleOptionMouseEnter(index: number) {
438
+ // Find the index in enabled options
439
+ const option = flatOptions[index];
440
+ if (!option?.disabled) {
441
+ const enabledIndex = enabledOptions.findIndex((o) => o.value === option.value);
442
+ if (enabledIndex >= 0) {
443
+ activeIndex = enabledIndex;
444
+ }
445
+ }
446
+ }
447
+
448
+ function handleClear(e: MouseEvent) {
449
+ e.preventDefault();
450
+ e.stopPropagation();
451
+ value = '';
452
+ selected = null;
453
+ activeIndex = -1;
454
+ isOpen = false;
455
+ hasInteracted = true;
456
+ announce('Selection cleared', 'polite');
457
+ onclear?.();
458
+ inputRef?.focus();
459
+ }
460
+
461
+ function handleToggleClick(e: MouseEvent) {
462
+ e.preventDefault();
463
+ e.stopPropagation();
464
+ if (!disabled && !readonly) {
465
+ isOpen = !isOpen;
466
+ if (isOpen) {
467
+ inputRef?.focus();
468
+ }
469
+ }
470
+ }
471
+
472
+ // ============================================================================
473
+ // Cleanup
474
+ // ============================================================================
475
+
476
+ $effect(() => {
477
+ return () => {
478
+ if (debounceTimer) {
479
+ clearTimeout(debounceTimer);
480
+ }
481
+ };
482
+ });
483
+ </script>
484
+
485
+ <div
486
+ class={cn(
487
+ 'search-input-wrapper',
488
+ `search-input-${size}`,
489
+ disabled && 'search-input-disabled',
490
+ className
491
+ )}
492
+ >
493
+ <!-- Label -->
494
+ <label for={id} class={cn('search-input-label', hideLabel && 'sr-only')}>
495
+ {label}
496
+ {#if required}
497
+ <span class="search-input-required" aria-hidden="true">*</span>
498
+ {/if}
499
+ {#if hint && !hideLabel}
500
+ <span class="search-input-hint-icon" title={hint}>
501
+ <Info size={14} />
502
+ </span>
503
+ {/if}
504
+ </label>
505
+
506
+ <!-- Combobox container -->
507
+ <div class="search-input-container">
508
+ <!-- Input wrapper -->
509
+ <div class="search-input-field-wrapper">
510
+ {#if iconLeft}
511
+ <span class={cn('search-input-icon-left', isFocused && 'search-input-icon-focused')} aria-hidden="true">
512
+ {@render iconLeft()}
513
+ </span>
514
+ {:else}
515
+ <span class={cn('search-input-icon-left', isFocused && 'search-input-icon-focused')} aria-hidden="true">
516
+ <Search size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
517
+ </span>
518
+ {/if}
519
+
520
+ <input
521
+ bind:this={inputRef}
522
+ {id}
523
+ {name}
524
+ type="text"
525
+ role="combobox"
526
+ {value}
527
+ {placeholder}
528
+ {disabled}
529
+ {readonly}
530
+ {required}
531
+ inputmode={inputMode}
532
+ {autocomplete}
533
+ aria-expanded={isOpen}
534
+ aria-controls={listboxId}
535
+ aria-autocomplete="list"
536
+ aria-haspopup="listbox"
537
+ aria-activedescendant={activeDescendantId}
538
+ aria-invalid={showError}
539
+ aria-describedby={ariaDescribedBy}
540
+ aria-required={required}
541
+ data-testid={testId}
542
+ class={cn(
543
+ 'search-input-field',
544
+ 'search-input-has-icon-left',
545
+ (clearable || showError || isValid || loading) && 'search-input-has-icon-right',
546
+ showError && 'search-input-error',
547
+ isValid && 'search-input-valid'
548
+ )}
549
+ oninput={handleInput}
550
+ onfocus={handleFocus}
551
+ onblur={handleBlur}
552
+ onkeydown={handleKeyDown}
553
+ {...restProps}
554
+ />
555
+
556
+ <!-- Right icons -->
557
+ <div class="search-input-icons-right">
558
+ {#if loading}
559
+ <span class="search-input-icon-loading" aria-hidden="true">
560
+ <Loader2 size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} class="search-input-spinner" />
561
+ </span>
562
+ {:else if showError}
563
+ <span class="search-input-icon-error" aria-hidden="true">
564
+ <AlertCircle size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
565
+ </span>
566
+ {:else if isValid}
567
+ <span class="search-input-icon-success" aria-hidden="true">
568
+ <Check size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
569
+ </span>
570
+ {/if}
571
+
572
+ {#if clearable && value && !disabled && !readonly}
573
+ <button
574
+ type="button"
575
+ class="search-input-clear-button"
576
+ onclick={handleClear}
577
+ aria-label="Clear search"
578
+ tabindex={-1}
579
+ >
580
+ <X size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
581
+ </button>
582
+ {/if}
583
+
584
+ <button
585
+ type="button"
586
+ class="search-input-toggle-button"
587
+ onclick={handleToggleClick}
588
+ aria-label={isOpen ? 'Close suggestions' : 'Show suggestions'}
589
+ aria-expanded={isOpen}
590
+ tabindex={-1}
591
+ {disabled}
592
+ >
593
+ <ChevronDown
594
+ size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16}
595
+ class={cn('search-input-chevron', isOpen && 'search-input-chevron-open')}
596
+ />
597
+ </button>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- Listbox dropdown -->
602
+ {#if shouldShowDropdown}
603
+ <div
604
+ bind:this={listboxRef}
605
+ id={listboxId}
606
+ role="listbox"
607
+ aria-label={`${label} suggestions`}
608
+ class="search-input-listbox"
609
+ >
610
+ {#if loading}
611
+ <div class="search-input-loading" role="status" aria-live="polite">
612
+ {#if loadingContent}
613
+ {@render loadingContent()}
614
+ {:else}
615
+ <Loader2 size={20} class="search-input-spinner" />
616
+ <span>Searching...</span>
617
+ {/if}
618
+ </div>
619
+ {:else if showEmpty}
620
+ <div class="search-input-empty" role="status" aria-live="polite">
621
+ {#if emptyContent}
622
+ {@render emptyContent()}
623
+ {:else}
624
+ <span>{emptyMessage}</span>
625
+ {/if}
626
+ </div>
627
+ {:else if isGrouped}
628
+ {#each options as group, groupIndex}
629
+ {@const typedGroup = group as SearchOptionGroup}
630
+ <div class="search-input-group" role="group" aria-labelledby={`${id}-group-${groupIndex}`}>
631
+ <div id={`${id}-group-${groupIndex}`} class="search-input-group-label">
632
+ {typedGroup.label}
633
+ </div>
634
+ {#each typedGroup.options as option}
635
+ {@const optionIndex = getOptionIndex(option)}
636
+ {@const enabledIndex = enabledOptions.findIndex((o) => o.value === option.value)}
637
+ {@const isActive = enabledIndex === activeIndex}
638
+ {@const isSelected = selected?.value === option.value}
639
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
640
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
641
+ <div
642
+ id={`${id}-option-${optionIndex}`}
643
+ role="option"
644
+ aria-selected={isSelected}
645
+ aria-disabled={option.disabled}
646
+ data-index={optionIndex}
647
+ class={cn(
648
+ 'search-input-option',
649
+ isActive && 'search-input-option-active',
650
+ isSelected && 'search-input-option-selected',
651
+ option.disabled && 'search-input-option-disabled'
652
+ )}
653
+ onclick={(e) => handleOptionClick(option, e)}
654
+ onmouseenter={() => handleOptionMouseEnter(optionIndex)}
655
+ >
656
+ {#if optionContent}
657
+ {@render optionContent(option)}
658
+ {:else}
659
+ <div class="search-input-option-content">
660
+ <span class="search-input-option-label">{option.label}</span>
661
+ {#if option.description}
662
+ <span class="search-input-option-description">{option.description}</span>
663
+ {/if}
664
+ </div>
665
+ {/if}
666
+ {#if isSelected}
667
+ <Check size={16} class="search-input-option-check" />
668
+ {/if}
669
+ </div>
670
+ {/each}
671
+ </div>
672
+ {/each}
673
+ {:else}
674
+ {#each flatOptions as option, index}
675
+ {@const enabledIndex = enabledOptions.findIndex((o) => o.value === option.value)}
676
+ {@const isActive = enabledIndex === activeIndex}
677
+ {@const isSelected = selected?.value === option.value}
678
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
679
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
680
+ <div
681
+ id={`${id}-option-${index}`}
682
+ role="option"
683
+ aria-selected={isSelected}
684
+ aria-disabled={option.disabled}
685
+ data-index={index}
686
+ class={cn(
687
+ 'search-input-option',
688
+ isActive && 'search-input-option-active',
689
+ isSelected && 'search-input-option-selected',
690
+ option.disabled && 'search-input-option-disabled'
691
+ )}
692
+ onclick={(e) => handleOptionClick(option, e)}
693
+ onmouseenter={() => handleOptionMouseEnter(index)}
694
+ >
695
+ {#if optionContent}
696
+ {@render optionContent(option)}
697
+ {:else}
698
+ <div class="search-input-option-content">
699
+ <span class="search-input-option-label">{option.label}</span>
700
+ {#if option.description}
701
+ <span class="search-input-option-description">{option.description}</span>
702
+ {/if}
703
+ </div>
704
+ {/if}
705
+ {#if isSelected}
706
+ <Check size={16} class="search-input-option-check" />
707
+ {/if}
708
+ </div>
709
+ {/each}
710
+ {/if}
711
+ </div>
712
+ {/if}
713
+ </div>
714
+
715
+ <!-- Screen reader announcements for list count -->
716
+ {#if isOpen && !loading && flatOptions.length > 0}
717
+ <div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
718
+ {flatOptions.length} suggestion{flatOptions.length === 1 ? '' : 's'} available
719
+ </div>
720
+ {/if}
721
+
722
+ <!-- Footer messages -->
723
+ <div class="search-input-footer">
724
+ <div class="search-input-messages">
725
+ {#if showError}
726
+ <p id={errorId} class="search-input-message search-input-message-error" role="alert" aria-live="assertive">
727
+ {error}
728
+ </p>
729
+ {:else if showSuccess}
730
+ <p class="search-input-message search-input-message-success">
731
+ {successMessage}
732
+ </p>
733
+ {:else if hint && isFocused}
734
+ <p id={hintId} class="search-input-message search-input-message-hint">
735
+ {hint}
736
+ </p>
737
+ {/if}
738
+ </div>
739
+ </div>
740
+ </div>
741
+
742
+ <style>
743
+ /* ========================================
744
+ BASE WRAPPER
745
+ ======================================== */
746
+ .search-input-wrapper {
747
+ display: flex;
748
+ flex-direction: column;
749
+ gap: 0.375rem;
750
+ position: relative;
751
+ }
752
+
753
+ .search-input-disabled {
754
+ opacity: 0.6;
755
+ }
756
+
757
+ /* ========================================
758
+ LABEL
759
+ ======================================== */
760
+ .search-input-label {
761
+ display: flex;
762
+ align-items: center;
763
+ gap: 0.375rem;
764
+ font-weight: 500;
765
+ color: var(--ui-text-primary);
766
+ }
767
+
768
+ .search-input-sm .search-input-label {
769
+ font-size: 0.75rem;
770
+ }
771
+
772
+ .search-input-md .search-input-label,
773
+ .search-input-lg .search-input-label {
774
+ font-size: 0.875rem;
775
+ }
776
+
777
+ .search-input-required {
778
+ color: rgb(var(--ui-color-error));
779
+ }
780
+
781
+ :global([data-theme='dark']) .search-input-required {
782
+ color: rgb(var(--ui-color-error-light));
783
+ }
784
+
785
+ @media (prefers-color-scheme: dark) {
786
+ :global(:root:not([data-theme='light'])) .search-input-required {
787
+ color: rgb(var(--ui-color-error-light));
788
+ }
789
+ }
790
+
791
+ .search-input-hint-icon {
792
+ color: var(--ui-text-tertiary);
793
+ cursor: help;
794
+ }
795
+
796
+ /* ========================================
797
+ CONTAINER
798
+ ======================================== */
799
+ .search-input-container {
800
+ position: relative;
801
+ }
802
+
803
+ /* ========================================
804
+ FIELD WRAPPER
805
+ ======================================== */
806
+ .search-input-field-wrapper {
807
+ position: relative;
808
+ display: flex;
809
+ align-items: center;
810
+ }
811
+
812
+ /* ========================================
813
+ INPUT FIELD
814
+ ======================================== */
815
+ .search-input-field {
816
+ width: 100%;
817
+ border: 2px solid var(--ui-border-default);
818
+ border-radius: 0.5rem;
819
+ background: var(--ui-bg-secondary);
820
+ color: var(--ui-text-primary);
821
+ transition: all 0.2s ease;
822
+ }
823
+
824
+ .search-input-field::placeholder {
825
+ color: var(--ui-text-tertiary);
826
+ }
827
+
828
+ .search-input-field:focus-visible {
829
+ outline: none;
830
+ background: var(--ui-bg-primary);
831
+ border-color: rgb(var(--ui-color-primary));
832
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary) / 0.4);
833
+ }
834
+
835
+ :global([data-theme='dark']) .search-input-field:focus-visible {
836
+ border-color: rgb(var(--ui-color-primary-light));
837
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
838
+ }
839
+
840
+ @media (prefers-color-scheme: dark) {
841
+ :global(:root:not([data-theme='light'])) .search-input-field:focus-visible {
842
+ border-color: rgb(var(--ui-color-primary-light));
843
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
844
+ }
845
+ }
846
+
847
+ .search-input-field:disabled {
848
+ cursor: not-allowed;
849
+ background: var(--ui-bg-tertiary);
850
+ }
851
+
852
+ .search-input-field:read-only {
853
+ background: var(--ui-bg-tertiary);
854
+ cursor: default;
855
+ }
856
+
857
+ /* Size variants */
858
+ .search-input-sm .search-input-field {
859
+ height: 2rem;
860
+ padding: 0 0.75rem;
861
+ font-size: 0.875rem;
862
+ }
863
+
864
+ .search-input-md .search-input-field {
865
+ height: 2.5rem;
866
+ padding: 0 0.75rem;
867
+ font-size: 0.875rem;
868
+ }
869
+
870
+ .search-input-lg .search-input-field {
871
+ height: 3rem;
872
+ padding: 0 1rem;
873
+ font-size: 1rem;
874
+ }
875
+
876
+ /* Icon padding adjustments */
877
+ .search-input-field.search-input-has-icon-left {
878
+ padding-left: 2.5rem !important;
879
+ }
880
+
881
+ .search-input-field.search-input-has-icon-right {
882
+ padding-right: 5rem !important;
883
+ }
884
+
885
+ /* Error state */
886
+ .search-input-error {
887
+ border-color: rgb(var(--ui-color-error));
888
+ }
889
+
890
+ .search-input-error:focus-visible {
891
+ border-color: rgb(var(--ui-color-error));
892
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-error) / 0.4);
893
+ }
894
+
895
+ :global([data-theme='dark']) .search-input-error {
896
+ border-color: rgb(var(--ui-color-error-light));
897
+ }
898
+
899
+ :global([data-theme='dark']) .search-input-error:focus-visible {
900
+ border-color: rgb(var(--ui-color-error-light));
901
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-error-light) / 0.5);
902
+ }
903
+
904
+ @media (prefers-color-scheme: dark) {
905
+ :global(:root:not([data-theme='light'])) .search-input-error {
906
+ border-color: rgb(var(--ui-color-error-light));
907
+ }
908
+
909
+ :global(:root:not([data-theme='light'])) .search-input-error:focus-visible {
910
+ border-color: rgb(var(--ui-color-error-light));
911
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-error-light) / 0.5);
912
+ }
913
+ }
914
+
915
+ /* Valid state */
916
+ .search-input-valid {
917
+ border-color: rgb(var(--ui-color-success));
918
+ }
919
+
920
+ .search-input-valid:focus-visible {
921
+ border-color: rgb(var(--ui-color-success));
922
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-success) / 0.4);
923
+ }
924
+
925
+ :global([data-theme='dark']) .search-input-valid {
926
+ border-color: rgb(var(--ui-color-success-light));
927
+ }
928
+
929
+ :global([data-theme='dark']) .search-input-valid:focus-visible {
930
+ border-color: rgb(var(--ui-color-success-light));
931
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-success-light) / 0.5);
932
+ }
933
+
934
+ @media (prefers-color-scheme: dark) {
935
+ :global(:root:not([data-theme='light'])) .search-input-valid {
936
+ border-color: rgb(var(--ui-color-success-light));
937
+ }
938
+
939
+ :global(:root:not([data-theme='light'])) .search-input-valid:focus-visible {
940
+ border-color: rgb(var(--ui-color-success-light));
941
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-success-light) / 0.5);
942
+ }
943
+ }
944
+
945
+ /* ========================================
946
+ ICONS
947
+ ======================================== */
948
+ .search-input-icon-left {
949
+ position: absolute;
950
+ left: 0.75rem;
951
+ top: 50%;
952
+ transform: translateY(-50%);
953
+ z-index: 1;
954
+ pointer-events: none;
955
+ color: var(--ui-text-tertiary);
956
+ transition: color 0.2s ease;
957
+ }
958
+
959
+ .search-input-icon-focused {
960
+ color: rgb(var(--ui-color-primary));
961
+ }
962
+
963
+ :global([data-theme='dark']) .search-input-icon-focused {
964
+ color: rgb(var(--ui-color-primary-light));
965
+ }
966
+
967
+ @media (prefers-color-scheme: dark) {
968
+ :global(:root:not([data-theme='light'])) .search-input-icon-focused {
969
+ color: rgb(var(--ui-color-primary-light));
970
+ }
971
+ }
972
+
973
+ .search-input-icons-right {
974
+ position: absolute;
975
+ right: 0.5rem;
976
+ top: 50%;
977
+ transform: translateY(-50%);
978
+ display: flex;
979
+ align-items: center;
980
+ gap: 0.25rem;
981
+ }
982
+
983
+ .search-input-icon-loading,
984
+ .search-input-icon-error,
985
+ .search-input-icon-success {
986
+ display: flex;
987
+ align-items: center;
988
+ justify-content: center;
989
+ color: var(--ui-text-tertiary);
990
+ }
991
+
992
+ .search-input-icon-error {
993
+ color: rgb(var(--ui-color-error));
994
+ }
995
+
996
+ :global([data-theme='dark']) .search-input-icon-error {
997
+ color: rgb(var(--ui-color-error-light));
998
+ }
999
+
1000
+ @media (prefers-color-scheme: dark) {
1001
+ :global(:root:not([data-theme='light'])) .search-input-icon-error {
1002
+ color: rgb(var(--ui-color-error-light));
1003
+ }
1004
+ }
1005
+
1006
+ .search-input-icon-success {
1007
+ color: rgb(var(--ui-color-success));
1008
+ }
1009
+
1010
+ :global([data-theme='dark']) .search-input-icon-success {
1011
+ color: rgb(var(--ui-color-success-light));
1012
+ }
1013
+
1014
+ @media (prefers-color-scheme: dark) {
1015
+ :global(:root:not([data-theme='light'])) .search-input-icon-success {
1016
+ color: rgb(var(--ui-color-success-light));
1017
+ }
1018
+ }
1019
+
1020
+ .search-input-spinner {
1021
+ animation: spin 1s linear infinite;
1022
+ }
1023
+
1024
+ @keyframes spin {
1025
+ from {
1026
+ transform: rotate(0deg);
1027
+ }
1028
+ to {
1029
+ transform: rotate(360deg);
1030
+ }
1031
+ }
1032
+
1033
+ @media (prefers-reduced-motion: reduce) {
1034
+ .search-input-spinner {
1035
+ animation: none;
1036
+ }
1037
+ }
1038
+
1039
+ .search-input-clear-button,
1040
+ .search-input-toggle-button {
1041
+ display: flex;
1042
+ align-items: center;
1043
+ justify-content: center;
1044
+ padding: 0.25rem;
1045
+ border: none;
1046
+ background: transparent;
1047
+ color: var(--ui-text-tertiary);
1048
+ cursor: pointer;
1049
+ border-radius: 0.25rem;
1050
+ transition: all 0.15s ease;
1051
+ }
1052
+
1053
+ .search-input-clear-button:hover,
1054
+ .search-input-toggle-button:hover {
1055
+ color: var(--ui-text-primary);
1056
+ background: var(--ui-bg-tertiary);
1057
+ }
1058
+
1059
+ .search-input-clear-button:focus-visible,
1060
+ .search-input-toggle-button:focus-visible {
1061
+ outline: 2px solid rgb(var(--ui-color-primary));
1062
+ outline-offset: 1px;
1063
+ }
1064
+
1065
+ .search-input-toggle-button:disabled {
1066
+ cursor: not-allowed;
1067
+ opacity: 0.5;
1068
+ }
1069
+
1070
+ .search-input-chevron {
1071
+ transition: transform 0.2s ease;
1072
+ }
1073
+
1074
+ .search-input-chevron-open {
1075
+ transform: rotate(180deg);
1076
+ }
1077
+
1078
+ /* ========================================
1079
+ LISTBOX DROPDOWN
1080
+ ======================================== */
1081
+ .search-input-listbox {
1082
+ position: absolute;
1083
+ top: 100%;
1084
+ left: 0;
1085
+ right: 0;
1086
+ z-index: 50;
1087
+ margin-top: 0.25rem;
1088
+ max-height: 16rem;
1089
+ overflow-y: auto;
1090
+ background: var(--ui-bg-primary);
1091
+ border: 2px solid var(--ui-border-default);
1092
+ border-radius: 0.5rem;
1093
+ box-shadow: var(--ui-shadow-lg);
1094
+ animation: slideDown 0.15s ease-out;
1095
+ }
1096
+
1097
+ @keyframes slideDown {
1098
+ from {
1099
+ opacity: 0;
1100
+ transform: translateY(-4px);
1101
+ }
1102
+ to {
1103
+ opacity: 1;
1104
+ transform: translateY(0);
1105
+ }
1106
+ }
1107
+
1108
+ @media (prefers-reduced-motion: reduce) {
1109
+ .search-input-listbox {
1110
+ animation: none;
1111
+ }
1112
+ }
1113
+
1114
+ /* ========================================
1115
+ OPTION ITEMS
1116
+ ======================================== */
1117
+ .search-input-option {
1118
+ display: flex;
1119
+ align-items: center;
1120
+ justify-content: space-between;
1121
+ padding: 0.625rem 0.75rem;
1122
+ cursor: pointer;
1123
+ transition: background-color 0.1s ease;
1124
+ }
1125
+
1126
+ .search-input-option:hover,
1127
+ .search-input-option-active {
1128
+ background: var(--ui-bg-secondary);
1129
+ }
1130
+
1131
+ .search-input-option-selected {
1132
+ background: rgb(var(--ui-color-primary) / 0.1);
1133
+ }
1134
+
1135
+ :global([data-theme='dark']) .search-input-option-selected {
1136
+ background: rgb(var(--ui-color-primary-light) / 0.15);
1137
+ }
1138
+
1139
+ @media (prefers-color-scheme: dark) {
1140
+ :global(:root:not([data-theme='light'])) .search-input-option-selected {
1141
+ background: rgb(var(--ui-color-primary-light) / 0.15);
1142
+ }
1143
+ }
1144
+
1145
+ .search-input-option-active.search-input-option-selected {
1146
+ background: rgb(var(--ui-color-primary) / 0.15);
1147
+ }
1148
+
1149
+ :global([data-theme='dark']) .search-input-option-active.search-input-option-selected {
1150
+ background: rgb(var(--ui-color-primary-light) / 0.2);
1151
+ }
1152
+
1153
+ @media (prefers-color-scheme: dark) {
1154
+ :global(:root:not([data-theme='light'])) .search-input-option-active.search-input-option-selected {
1155
+ background: rgb(var(--ui-color-primary-light) / 0.2);
1156
+ }
1157
+ }
1158
+
1159
+ .search-input-option-disabled {
1160
+ opacity: 0.5;
1161
+ cursor: not-allowed;
1162
+ }
1163
+
1164
+ .search-input-option-disabled:hover {
1165
+ background: transparent;
1166
+ }
1167
+
1168
+ .search-input-option-content {
1169
+ display: flex;
1170
+ flex-direction: column;
1171
+ gap: 0.125rem;
1172
+ min-width: 0;
1173
+ flex: 1;
1174
+ }
1175
+
1176
+ .search-input-option-label {
1177
+ font-weight: 500;
1178
+ color: var(--ui-text-primary);
1179
+ white-space: nowrap;
1180
+ overflow: hidden;
1181
+ text-overflow: ellipsis;
1182
+ }
1183
+
1184
+ .search-input-option-description {
1185
+ font-size: 0.75rem;
1186
+ color: var(--ui-text-tertiary);
1187
+ white-space: nowrap;
1188
+ overflow: hidden;
1189
+ text-overflow: ellipsis;
1190
+ }
1191
+
1192
+ .search-input-option-check {
1193
+ flex-shrink: 0;
1194
+ color: rgb(var(--ui-color-primary));
1195
+ margin-left: 0.5rem;
1196
+ }
1197
+
1198
+ :global([data-theme='dark']) .search-input-option-check {
1199
+ color: rgb(var(--ui-color-primary-light));
1200
+ }
1201
+
1202
+ @media (prefers-color-scheme: dark) {
1203
+ :global(:root:not([data-theme='light'])) .search-input-option-check {
1204
+ color: rgb(var(--ui-color-primary-light));
1205
+ }
1206
+ }
1207
+
1208
+ /* ========================================
1209
+ GROUPS
1210
+ ======================================== */
1211
+ .search-input-group {
1212
+ padding-top: 0.25rem;
1213
+ }
1214
+
1215
+ .search-input-group:not(:first-child) {
1216
+ border-top: 1px solid var(--ui-border-default);
1217
+ margin-top: 0.25rem;
1218
+ }
1219
+
1220
+ .search-input-group-label {
1221
+ padding: 0.5rem 0.75rem 0.25rem;
1222
+ font-size: 0.6875rem;
1223
+ font-weight: 600;
1224
+ text-transform: uppercase;
1225
+ letter-spacing: 0.05em;
1226
+ color: var(--ui-text-tertiary);
1227
+ }
1228
+
1229
+ /* ========================================
1230
+ LOADING & EMPTY STATES
1231
+ ======================================== */
1232
+ .search-input-loading,
1233
+ .search-input-empty {
1234
+ display: flex;
1235
+ align-items: center;
1236
+ justify-content: center;
1237
+ gap: 0.5rem;
1238
+ padding: 1.5rem;
1239
+ color: var(--ui-text-tertiary);
1240
+ font-size: 0.875rem;
1241
+ }
1242
+
1243
+ /* ========================================
1244
+ FOOTER & MESSAGES
1245
+ ======================================== */
1246
+ .search-input-footer {
1247
+ display: flex;
1248
+ justify-content: space-between;
1249
+ align-items: center;
1250
+ min-height: 1.25rem;
1251
+ }
1252
+
1253
+ .search-input-messages {
1254
+ flex: 1;
1255
+ }
1256
+
1257
+ .search-input-message {
1258
+ font-size: 0.75rem;
1259
+ animation: slideIn 0.2s ease-out;
1260
+ }
1261
+
1262
+ @keyframes slideIn {
1263
+ from {
1264
+ opacity: 0;
1265
+ transform: translateY(-4px);
1266
+ }
1267
+ to {
1268
+ opacity: 1;
1269
+ transform: translateY(0);
1270
+ }
1271
+ }
1272
+
1273
+ .search-input-message-error {
1274
+ color: rgb(var(--ui-color-error));
1275
+ }
1276
+
1277
+ :global([data-theme='dark']) .search-input-message-error {
1278
+ color: rgb(var(--ui-color-error-light));
1279
+ }
1280
+
1281
+ @media (prefers-color-scheme: dark) {
1282
+ :global(:root:not([data-theme='light'])) .search-input-message-error {
1283
+ color: rgb(var(--ui-color-error-light));
1284
+ }
1285
+ }
1286
+
1287
+ .search-input-message-success {
1288
+ color: rgb(var(--ui-color-success));
1289
+ }
1290
+
1291
+ :global([data-theme='dark']) .search-input-message-success {
1292
+ color: rgb(var(--ui-color-success-light));
1293
+ }
1294
+
1295
+ @media (prefers-color-scheme: dark) {
1296
+ :global(:root:not([data-theme='light'])) .search-input-message-success {
1297
+ color: rgb(var(--ui-color-success-light));
1298
+ }
1299
+ }
1300
+
1301
+ .search-input-message-hint {
1302
+ color: var(--ui-text-tertiary);
1303
+ }
1304
+
1305
+ /* ========================================
1306
+ UTILITIES
1307
+ ======================================== */
1308
+ .sr-only {
1309
+ position: absolute;
1310
+ width: 1px;
1311
+ height: 1px;
1312
+ padding: 0;
1313
+ margin: -1px;
1314
+ overflow: hidden;
1315
+ clip: rect(0, 0, 0, 0);
1316
+ white-space: nowrap;
1317
+ border: 0;
1318
+ }
1319
+ </style>