@salmexio/ui 1.2.0 → 1.3.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 (78) hide show
  1. package/dist/feedback/Alert/Alert.svelte +4 -1
  2. package/dist/feedback/Alert/Alert.svelte.d.ts +1 -0
  3. package/dist/feedback/Alert/Alert.svelte.d.ts.map +1 -1
  4. package/dist/feedback/Spinner/Spinner.svelte +4 -1
  5. package/dist/feedback/Spinner/Spinner.svelte.d.ts +1 -0
  6. package/dist/feedback/Spinner/Spinner.svelte.d.ts.map +1 -1
  7. package/dist/forms/DatePicker/DatePicker.svelte +725 -0
  8. package/dist/forms/DatePicker/DatePicker.svelte.d.ts +48 -0
  9. package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
  10. package/dist/forms/DatePicker/index.d.ts +2 -0
  11. package/dist/forms/DatePicker/index.d.ts.map +1 -0
  12. package/dist/forms/DatePicker/index.js +1 -0
  13. package/dist/forms/FormField/FormField.svelte +173 -0
  14. package/dist/forms/FormField/FormField.svelte.d.ts +46 -0
  15. package/dist/forms/FormField/FormField.svelte.d.ts.map +1 -0
  16. package/dist/forms/FormField/index.d.ts +2 -0
  17. package/dist/forms/FormField/index.d.ts.map +1 -0
  18. package/dist/forms/FormField/index.js +1 -0
  19. package/dist/forms/MultiSelect/MultiSelect.svelte +820 -0
  20. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +69 -0
  21. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
  22. package/dist/forms/MultiSelect/index.d.ts +3 -0
  23. package/dist/forms/MultiSelect/index.d.ts.map +1 -0
  24. package/dist/forms/MultiSelect/index.js +1 -0
  25. package/dist/forms/PhoneInput/PhoneInput.svelte +591 -0
  26. package/dist/forms/PhoneInput/PhoneInput.svelte.d.ts +57 -0
  27. package/dist/forms/PhoneInput/PhoneInput.svelte.d.ts.map +1 -0
  28. package/dist/forms/PhoneInput/index.d.ts +4 -0
  29. package/dist/forms/PhoneInput/index.d.ts.map +1 -0
  30. package/dist/forms/PhoneInput/index.js +2 -0
  31. package/dist/forms/RadioGroup/RadioGroup.svelte +417 -0
  32. package/dist/forms/RadioGroup/RadioGroup.svelte.d.ts +62 -0
  33. package/dist/forms/RadioGroup/RadioGroup.svelte.d.ts.map +1 -0
  34. package/dist/forms/RadioGroup/index.d.ts +3 -0
  35. package/dist/forms/RadioGroup/index.d.ts.map +1 -0
  36. package/dist/forms/RadioGroup/index.js +1 -0
  37. package/dist/forms/SearchInput/SearchInput.svelte +788 -0
  38. package/dist/forms/SearchInput/SearchInput.svelte.d.ts +79 -0
  39. package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
  40. package/dist/forms/SearchInput/index.d.ts +3 -0
  41. package/dist/forms/SearchInput/index.d.ts.map +1 -0
  42. package/dist/forms/SearchInput/index.js +1 -0
  43. package/dist/forms/Select/Select.svelte +14 -8
  44. package/dist/forms/Select/Select.svelte.d.ts +2 -0
  45. package/dist/forms/Select/Select.svelte.d.ts.map +1 -1
  46. package/dist/forms/TextInput/TextInput.svelte +38 -16
  47. package/dist/forms/TextInput/TextInput.svelte.d.ts +6 -0
  48. package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
  49. package/dist/forms/Textarea/Textarea.svelte +7 -1
  50. package/dist/forms/Textarea/Textarea.svelte.d.ts +2 -0
  51. package/dist/forms/Textarea/Textarea.svelte.d.ts.map +1 -1
  52. package/dist/forms/TimePicker/TimePicker.svelte +417 -0
  53. package/dist/forms/TimePicker/TimePicker.svelte.d.ts +53 -0
  54. package/dist/forms/TimePicker/TimePicker.svelte.d.ts.map +1 -0
  55. package/dist/forms/TimePicker/index.d.ts +2 -0
  56. package/dist/forms/TimePicker/index.d.ts.map +1 -0
  57. package/dist/forms/TimePicker/index.js +1 -0
  58. package/dist/forms/index.d.ts +12 -0
  59. package/dist/forms/index.d.ts.map +1 -1
  60. package/dist/forms/index.js +8 -0
  61. package/dist/layout/Container/Container.svelte +3 -0
  62. package/dist/layout/Container/Container.svelte.d.ts +1 -0
  63. package/dist/layout/Container/Container.svelte.d.ts.map +1 -1
  64. package/dist/primitives/Badge/Badge.svelte +5 -1
  65. package/dist/primitives/Badge/Badge.svelte.d.ts +1 -0
  66. package/dist/primitives/Badge/Badge.svelte.d.ts.map +1 -1
  67. package/dist/primitives/Tooltip/Tooltip.svelte +30 -0
  68. package/dist/primitives/Tooltip/Tooltip.svelte.d.ts.map +1 -1
  69. package/dist/utils/accessibility.d.ts +16 -0
  70. package/dist/utils/accessibility.d.ts.map +1 -0
  71. package/dist/utils/accessibility.js +80 -0
  72. package/dist/utils/index.d.ts +2 -1
  73. package/dist/utils/index.d.ts.map +1 -1
  74. package/dist/utils/index.js +2 -1
  75. package/dist/utils/keyboard.d.ts +6 -0
  76. package/dist/utils/keyboard.d.ts.map +1 -1
  77. package/dist/utils/keyboard.js +15 -9
  78. package/package.json +21 -1
@@ -0,0 +1,788 @@
1
+ <!--
2
+ @component SearchInput
3
+
4
+ INFRARED — Search input with autocomplete dropdown, match highlighting,
5
+ grouped results, loading state, and keyboard navigation.
6
+ Follows WAI-ARIA combobox pattern.
7
+
8
+ @example
9
+ <SearchInput
10
+ label="Search models"
11
+ options={[
12
+ { value: 'opus', label: 'Claude Opus' },
13
+ { value: 'sonnet', label: 'Claude Sonnet' },
14
+ ]}
15
+ bind:value={query}
16
+ onselect={(opt) => console.log(opt)}
17
+ />
18
+ -->
19
+ <script lang="ts" module>
20
+ export interface SearchOption {
21
+ value: string;
22
+ label: string;
23
+ description?: string;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ export interface SearchGroup {
28
+ label: string;
29
+ options: SearchOption[];
30
+ }
31
+ </script>
32
+
33
+ <script lang="ts">
34
+ import { cn } from '../../utils/cn.js';
35
+ import { Keys, generateId } from '../../utils/keyboard.js';
36
+ import { onMount, tick } from 'svelte';
37
+
38
+ type SearchSize = 'sm' | 'md' | 'lg';
39
+
40
+ interface Props {
41
+ /** Visible label. */
42
+ label: string;
43
+ /** Search query value (bindable). */
44
+ value?: string;
45
+ /** Flat list of options. */
46
+ options?: SearchOption[];
47
+ /** Grouped options. */
48
+ groups?: SearchGroup[];
49
+ /** Placeholder text. */
50
+ placeholder?: string;
51
+ /** Minimum characters before showing suggestions. */
52
+ minChars?: number;
53
+ /** Debounce delay in ms for the onsearch callback. */
54
+ debounceMs?: number;
55
+ /** Show loading spinner in dropdown. */
56
+ loading?: boolean;
57
+ /** Allow clearing the input. */
58
+ clearable?: boolean;
59
+ /** Size variant. */
60
+ size?: SearchSize;
61
+ /** Error message. */
62
+ error?: string;
63
+ /** Hint text. */
64
+ hint?: string;
65
+ /** Disabled state. */
66
+ disabled?: boolean;
67
+ /** Required field. */
68
+ required?: boolean;
69
+ /** Hide the visible label. */
70
+ hideLabel?: boolean;
71
+ /** Reserve footer space even when empty. */
72
+ alwaysShowFooter?: boolean;
73
+ /** Additional CSS class. */
74
+ class?: string;
75
+ /** Called when an option is selected. */
76
+ onselect?: (option: SearchOption) => void;
77
+ /** Called when search query changes (debounced). */
78
+ onsearch?: (query: string) => void;
79
+ /** Called when input value changes. */
80
+ oninput?: (event: Event) => void;
81
+ /** Called when input loses focus. */
82
+ onblur?: (event: FocusEvent) => void;
83
+ /** Test ID. */
84
+ testId?: string;
85
+ }
86
+
87
+ let {
88
+ label,
89
+ value = $bindable(''),
90
+ options = [],
91
+ groups = [],
92
+ placeholder = 'Search...',
93
+ minChars = 1,
94
+ debounceMs = 250,
95
+ loading = false,
96
+ clearable = true,
97
+ size = 'md',
98
+ error = '',
99
+ hint = '',
100
+ disabled = false,
101
+ required = false,
102
+ hideLabel = false,
103
+ alwaysShowFooter = true,
104
+ class: className = '',
105
+ onselect,
106
+ onsearch,
107
+ oninput,
108
+ onblur,
109
+ testId
110
+ }: Props = $props();
111
+
112
+ const id = generateId('search');
113
+ const listboxId = `${id}-listbox`;
114
+ const labelId = `${id}-label`;
115
+ const errorId = `${id}-error`;
116
+ const hintId = `${id}-hint`;
117
+
118
+ let inputRef = $state<HTMLInputElement | null>(null);
119
+ let listboxEl = $state<HTMLDivElement | null>(null);
120
+ let isOpen = $state(false);
121
+ let activeIndex = $state(-1);
122
+ let isFocused = $state(false);
123
+ let hasInteracted = $state(false);
124
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
125
+ let blurTimer: ReturnType<typeof setTimeout> | undefined;
126
+
127
+ // Panel positioning
128
+ let panelTop = $state(0);
129
+ let panelLeft = $state(0);
130
+ let panelWidth = $state(0);
131
+ let panelMaxHeight = $state(260);
132
+ let placeAbove = $state(false);
133
+
134
+ // Flatten all options
135
+ const flatOptions = $derived<SearchOption[]>(
136
+ groups.length > 0
137
+ ? groups.flatMap((g) => g.options)
138
+ : options
139
+ );
140
+
141
+ // Filter based on query
142
+ const filteredFlat = $derived<SearchOption[]>(
143
+ value.length >= minChars
144
+ ? flatOptions.filter((o) =>
145
+ o.label.toLowerCase().includes(value.toLowerCase())
146
+ )
147
+ : []
148
+ );
149
+
150
+ const filteredGroups = $derived(
151
+ groups.length > 0 && value.length >= minChars
152
+ ? groups
153
+ .map((g) => ({
154
+ ...g,
155
+ options: g.options.filter((o) =>
156
+ o.label.toLowerCase().includes(value.toLowerCase())
157
+ ),
158
+ }))
159
+ .filter((g) => g.options.length > 0)
160
+ : []
161
+ );
162
+
163
+ const displayOptions = $derived<SearchOption[]>(
164
+ groups.length > 0
165
+ ? filteredGroups.flatMap((g) => g.options)
166
+ : filteredFlat
167
+ );
168
+
169
+ const enabledIndices = $derived(
170
+ displayOptions
171
+ .map((o, i) => ({ i, disabled: o.disabled }))
172
+ .filter((x) => !x.disabled)
173
+ .map((x) => x.i)
174
+ );
175
+
176
+ const showError = $derived(hasInteracted && !!error);
177
+ const shouldOpen = $derived(
178
+ isFocused && value.length >= minChars && (displayOptions.length > 0 || loading)
179
+ );
180
+
181
+ const ariaDescribedBy = $derived(
182
+ [showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
183
+ );
184
+ const hasFooterContent = $derived(showError || !!hint);
185
+
186
+ $effect(() => {
187
+ if (shouldOpen && !isOpen) {
188
+ isOpen = true;
189
+ positionDropdown();
190
+ } else if (!shouldOpen && isOpen) {
191
+ isOpen = false;
192
+ activeIndex = -1;
193
+ }
194
+ });
195
+
196
+ function positionDropdown() {
197
+ if (!inputRef) return;
198
+ const rect = inputRef.getBoundingClientRect();
199
+ const viewportH = window.innerHeight;
200
+ const spaceBelow = viewportH - rect.bottom;
201
+ const spaceAbove = rect.top;
202
+ const maxH = 260;
203
+
204
+ placeAbove = spaceBelow < Math.min(maxH, 150) && spaceAbove > spaceBelow;
205
+ panelLeft = rect.left;
206
+ panelWidth = rect.width;
207
+
208
+ if (placeAbove) {
209
+ panelMaxHeight = Math.min(maxH, spaceAbove - 8);
210
+ panelTop = rect.top - 2;
211
+ } else {
212
+ panelMaxHeight = Math.min(maxH, spaceBelow - 8);
213
+ panelTop = rect.bottom + 2;
214
+ }
215
+ }
216
+
217
+ function handleInput(e: Event) {
218
+ const target = e.target as HTMLInputElement;
219
+ value = target.value;
220
+ activeIndex = -1;
221
+ oninput?.(e);
222
+
223
+ if (debounceMs && onsearch) {
224
+ clearTimeout(debounceTimer);
225
+ debounceTimer = setTimeout(() => onsearch!(value), debounceMs);
226
+ } else {
227
+ onsearch?.(value);
228
+ }
229
+ }
230
+
231
+ function handleFocus() {
232
+ isFocused = true;
233
+ }
234
+
235
+ function handleBlur(e: FocusEvent) {
236
+ // Delay to allow click on options
237
+ blurTimer = setTimeout(() => {
238
+ isFocused = false;
239
+ hasInteracted = true;
240
+ onblur?.(e);
241
+ }, 150);
242
+ }
243
+
244
+ function selectOption(opt: SearchOption) {
245
+ if (opt.disabled) return;
246
+ value = opt.label;
247
+ isOpen = false;
248
+ activeIndex = -1;
249
+ onselect?.(opt);
250
+ inputRef?.focus();
251
+ }
252
+
253
+ function handleClear() {
254
+ value = '';
255
+ activeIndex = -1;
256
+ onsearch?.('');
257
+ inputRef?.focus();
258
+ }
259
+
260
+ function scrollActiveIntoView() {
261
+ if (!listboxEl || activeIndex < 0) return;
262
+ const active = listboxEl.querySelector(`[data-option-index="${activeIndex}"]`) as HTMLElement;
263
+ if (!active) return;
264
+ const listTop = listboxEl.scrollTop;
265
+ const listHeight = listboxEl.clientHeight;
266
+ const elTop = active.offsetTop;
267
+ const elHeight = active.offsetHeight;
268
+ if (elTop < listTop) {
269
+ listboxEl.scrollTop = elTop;
270
+ } else if (elTop + elHeight > listTop + listHeight) {
271
+ listboxEl.scrollTop = elTop + elHeight - listHeight;
272
+ }
273
+ }
274
+
275
+ function handleKeydown(e: KeyboardEvent) {
276
+ if (!isOpen) return;
277
+
278
+ switch (e.key) {
279
+ case Keys.ArrowDown:
280
+ e.preventDefault();
281
+ {
282
+ const pos = enabledIndices.indexOf(activeIndex);
283
+ if (pos < enabledIndices.length - 1) {
284
+ activeIndex = enabledIndices[pos + 1];
285
+ } else if (pos === -1 && enabledIndices.length > 0) {
286
+ activeIndex = enabledIndices[0];
287
+ }
288
+ tick().then(scrollActiveIntoView);
289
+ }
290
+ break;
291
+ case Keys.ArrowUp:
292
+ e.preventDefault();
293
+ {
294
+ const pos = enabledIndices.indexOf(activeIndex);
295
+ if (pos > 0) {
296
+ activeIndex = enabledIndices[pos - 1];
297
+ }
298
+ tick().then(scrollActiveIntoView);
299
+ }
300
+ break;
301
+ case Keys.Enter:
302
+ e.preventDefault();
303
+ if (activeIndex >= 0 && displayOptions[activeIndex]) {
304
+ selectOption(displayOptions[activeIndex]);
305
+ }
306
+ break;
307
+ case Keys.Escape:
308
+ e.preventDefault();
309
+ isOpen = false;
310
+ activeIndex = -1;
311
+ break;
312
+ case Keys.Home:
313
+ e.preventDefault();
314
+ if (enabledIndices.length > 0) {
315
+ activeIndex = enabledIndices[0];
316
+ tick().then(scrollActiveIntoView);
317
+ }
318
+ break;
319
+ case Keys.End:
320
+ e.preventDefault();
321
+ if (enabledIndices.length > 0) {
322
+ activeIndex = enabledIndices[enabledIndices.length - 1];
323
+ tick().then(scrollActiveIntoView);
324
+ }
325
+ break;
326
+ }
327
+ }
328
+
329
+ function escapeHtml(str: string): string {
330
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
331
+ }
332
+
333
+ function highlightMatch(text: string): string {
334
+ if (!value || value.length < minChars) return escapeHtml(text);
335
+ const idx = text.toLowerCase().indexOf(value.toLowerCase());
336
+ if (idx === -1) return escapeHtml(text);
337
+ return (
338
+ escapeHtml(text.slice(0, idx)) +
339
+ '<mark class="sx-search-highlight">' +
340
+ escapeHtml(text.slice(idx, idx + value.length)) +
341
+ '</mark>' +
342
+ escapeHtml(text.slice(idx + value.length))
343
+ );
344
+ }
345
+
346
+ function getOptionId(index: number) {
347
+ return `${id}-option-${index}`;
348
+ }
349
+
350
+ function handleReposition() {
351
+ if (isOpen) positionDropdown();
352
+ }
353
+
354
+ // Portal dropdown to body
355
+ $effect(() => {
356
+ if (listboxEl && isOpen) {
357
+ document.body.appendChild(listboxEl);
358
+ return () => {
359
+ if (listboxEl?.parentNode === document.body) {
360
+ document.body.removeChild(listboxEl);
361
+ }
362
+ };
363
+ }
364
+ });
365
+
366
+ onMount(() => {
367
+ window.addEventListener('scroll', handleReposition, true);
368
+ window.addEventListener('resize', handleReposition);
369
+ return () => {
370
+ window.removeEventListener('scroll', handleReposition, true);
371
+ window.removeEventListener('resize', handleReposition);
372
+ clearTimeout(debounceTimer);
373
+ clearTimeout(blurTimer);
374
+ if (listboxEl?.parentNode === document.body) {
375
+ document.body.removeChild(listboxEl);
376
+ }
377
+ };
378
+ });
379
+ </script>
380
+
381
+ <div
382
+ class={cn('sx-search-wrapper', `sx-search-${size}`, disabled && 'sx-search-disabled', className)}
383
+ data-testid={testId}
384
+ >
385
+ <label id={labelId} for={id} class={cn('sx-search-label', hideLabel && 'sx-sr-only')}>
386
+ {label}
387
+ {#if required}
388
+ <span class="sx-search-required" aria-hidden="true">*</span>
389
+ {/if}
390
+ </label>
391
+
392
+ <div
393
+ class={cn(
394
+ 'sx-search-field-wrapper',
395
+ isFocused && 'sx-search-focused',
396
+ showError && 'sx-search-error-state'
397
+ )}
398
+ >
399
+ <span class="sx-search-icon" aria-hidden="true">
400
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
401
+ <circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
402
+ </svg>
403
+ </span>
404
+
405
+ <input
406
+ bind:this={inputRef}
407
+ {id}
408
+ type="search"
409
+ role="combobox"
410
+ bind:value
411
+ {placeholder}
412
+ {disabled}
413
+ {required}
414
+ autocomplete="off"
415
+ aria-expanded={isOpen}
416
+ aria-haspopup="listbox"
417
+ aria-controls={isOpen ? listboxId : undefined}
418
+ aria-activedescendant={isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
419
+ aria-labelledby={labelId}
420
+ aria-describedby={ariaDescribedBy}
421
+ aria-invalid={showError || undefined}
422
+ class="sx-search-input"
423
+ oninput={handleInput}
424
+ onfocus={handleFocus}
425
+ onblur={handleBlur}
426
+ onkeydown={handleKeydown}
427
+ />
428
+
429
+ {#if loading}
430
+ <span class="sx-search-loading" aria-hidden="true">
431
+ <svg class="sx-search-spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
432
+ <circle cx="12" cy="12" r="10" stroke-opacity="0.25" /><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
433
+ </svg>
434
+ </span>
435
+ {:else if clearable && value && !disabled}
436
+ <button
437
+ type="button"
438
+ class="sx-search-clear"
439
+ onclick={handleClear}
440
+ aria-label="Clear search"
441
+ tabindex={-1}
442
+ >
443
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
444
+ </button>
445
+ {/if}
446
+ </div>
447
+
448
+ {#if alwaysShowFooter || hasFooterContent}
449
+ <div class="sx-search-footer">
450
+ {#if showError}
451
+ <p id={errorId} class="sx-search-error" role="alert" aria-live="assertive">{error}</p>
452
+ {:else if hint}
453
+ <p id={hintId} class="sx-search-hint">{hint}</p>
454
+ {/if}
455
+ </div>
456
+ {/if}
457
+ </div>
458
+
459
+ <!-- Dropdown panel -->
460
+ {#if isOpen}
461
+ <div
462
+ bind:this={listboxEl}
463
+ id={listboxId}
464
+ class="sx-search-panel"
465
+ style="position:fixed;left:{panelLeft}px;{placeAbove ? `bottom:${window.innerHeight - panelTop}px` : `top:${panelTop}px`};width:{panelWidth}px;max-height:{panelMaxHeight}px;"
466
+ role="listbox"
467
+ aria-labelledby={labelId}
468
+ tabindex="-1"
469
+ onmousedown={(e) => e.preventDefault()}
470
+ >
471
+ {#if loading && displayOptions.length === 0}
472
+ <div class="sx-search-loading-msg">Searching...</div>
473
+ {:else if displayOptions.length === 0}
474
+ <div class="sx-search-empty">No results found</div>
475
+ {:else if filteredGroups.length > 0}
476
+ {@const groupOffsets = filteredGroups.reduce<number[]>((acc, g, i) => {
477
+ acc.push(i === 0 ? 0 : acc[i - 1] + filteredGroups[i - 1].options.length);
478
+ return acc;
479
+ }, [])}
480
+ {#each filteredGroups as group, gi}
481
+ <div role="group" aria-label={group.label}>
482
+ <div class="sx-search-group-label">{group.label}</div>
483
+ {#each group.options as opt, oi}
484
+ {@const globalIdx = groupOffsets[gi] + oi}
485
+ {@const isActive = globalIdx === activeIndex}
486
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
487
+ <div
488
+ id={getOptionId(globalIdx)}
489
+ class={cn(
490
+ 'sx-search-option',
491
+ isActive && 'sx-search-option-active',
492
+ opt.disabled && 'sx-search-option-disabled'
493
+ )}
494
+ role="option"
495
+ tabindex="-1"
496
+ aria-selected={isActive}
497
+ aria-disabled={opt.disabled || undefined}
498
+ data-option-index={globalIdx}
499
+ onmouseenter={() => { if (!opt.disabled) activeIndex = globalIdx; }}
500
+ onmousedown={(e) => { e.preventDefault(); selectOption(opt); }}
501
+ >
502
+ <span class="sx-search-option-label">{@html highlightMatch(opt.label)}</span>
503
+ {#if opt.description}
504
+ <span class="sx-search-option-desc">{opt.description}</span>
505
+ {/if}
506
+ </div>
507
+ {/each}
508
+ </div>
509
+ {/each}
510
+ {:else}
511
+ {#each displayOptions as opt, i}
512
+ {@const isActive = i === activeIndex}
513
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
514
+ <div
515
+ id={getOptionId(i)}
516
+ class={cn(
517
+ 'sx-search-option',
518
+ isActive && 'sx-search-option-active',
519
+ opt.disabled && 'sx-search-option-disabled'
520
+ )}
521
+ role="option"
522
+ tabindex="-1"
523
+ aria-selected={isActive}
524
+ aria-disabled={opt.disabled || undefined}
525
+ data-option-index={i}
526
+ onmouseenter={() => { if (!opt.disabled) activeIndex = i; }}
527
+ onmousedown={(e) => { e.preventDefault(); selectOption(opt); }}
528
+ >
529
+ <span class="sx-search-option-label">{@html highlightMatch(opt.label)}</span>
530
+ {#if opt.description}
531
+ <span class="sx-search-option-desc">{opt.description}</span>
532
+ {/if}
533
+ </div>
534
+ {/each}
535
+ {/if}
536
+ </div>
537
+ {/if}
538
+
539
+ <style>
540
+ .sx-search-wrapper {
541
+ display: flex;
542
+ flex-direction: column;
543
+ gap: var(--sx-space-1);
544
+ font-family: var(--sx-font-body);
545
+ }
546
+
547
+ .sx-search-disabled {
548
+ opacity: 0.5;
549
+ }
550
+
551
+ .sx-search-label {
552
+ font-size: var(--sx-text-sm);
553
+ font-weight: 500;
554
+ color: var(--sx-color-text-secondary);
555
+ }
556
+
557
+ .sx-search-required {
558
+ color: var(--sx-color-red);
559
+ margin-left: 2px;
560
+ }
561
+
562
+ .sx-sr-only {
563
+ position: absolute;
564
+ width: 1px;
565
+ height: 1px;
566
+ padding: 0;
567
+ margin: -1px;
568
+ overflow: hidden;
569
+ clip: rect(0, 0, 0, 0);
570
+ white-space: nowrap;
571
+ border: 0;
572
+ }
573
+
574
+ .sx-search-field-wrapper {
575
+ position: relative;
576
+ display: flex;
577
+ align-items: center;
578
+ background: var(--sx-color-surface);
579
+ border: 1px solid var(--sx-color-border-strong);
580
+ border-radius: var(--sx-radius-md);
581
+ transition: border-color var(--sx-transition-fast), box-shadow var(--sx-transition-fast);
582
+ box-shadow:
583
+ inset 0 1px 3px rgba(0, 0, 0, 0.3),
584
+ inset 0 0 0 1px rgba(0, 0, 0, 0.06);
585
+ }
586
+
587
+ .sx-search-field-wrapper:hover:not(.sx-search-focused):not(.sx-search-error-state) {
588
+ border-color: var(--sx-color-border-hover);
589
+ }
590
+
591
+ .sx-search-focused {
592
+ border-color: var(--sx-color-primary);
593
+ box-shadow:
594
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
595
+ 0 0 0 3px var(--sx-color-primary-ring),
596
+ 0 0 12px -4px rgba(255, 107, 53, 0.15);
597
+ animation: sx-focus-breathe 2s ease-in-out infinite;
598
+ }
599
+
600
+ .sx-search-error-state {
601
+ border-color: var(--sx-color-red);
602
+ box-shadow:
603
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
604
+ 0 0 0 3px var(--sx-color-red-ring);
605
+ }
606
+
607
+ .sx-search-icon {
608
+ display: flex;
609
+ align-items: center;
610
+ padding-left: var(--sx-space-3);
611
+ color: var(--sx-color-text-secondary);
612
+ flex-shrink: 0;
613
+ }
614
+
615
+ .sx-search-input {
616
+ flex: 1;
617
+ min-width: 0;
618
+ border: none;
619
+ background: transparent;
620
+ color: var(--sx-color-text);
621
+ font-family: var(--sx-font-body);
622
+ font-size: var(--sx-text-sm);
623
+ outline: none;
624
+ padding: var(--sx-space-2) var(--sx-space-3);
625
+ /* Remove native search cancel button */
626
+ appearance: none;
627
+ -webkit-appearance: none;
628
+ }
629
+
630
+ .sx-search-input::-webkit-search-cancel-button,
631
+ .sx-search-input::-webkit-search-decoration {
632
+ -webkit-appearance: none;
633
+ display: none;
634
+ }
635
+
636
+ .sx-search-input:focus-visible {
637
+ box-shadow: none;
638
+ }
639
+
640
+ .sx-search-input::placeholder {
641
+ color: var(--sx-color-text-disabled);
642
+ }
643
+
644
+ /* Sizes */
645
+ .sx-search-sm .sx-search-field-wrapper { min-height: 32px; }
646
+ .sx-search-sm .sx-search-input { font-size: var(--sx-text-xs); padding: var(--sx-space-1) var(--sx-space-2); }
647
+ .sx-search-md .sx-search-field-wrapper { min-height: 40px; }
648
+ .sx-search-lg .sx-search-field-wrapper { min-height: 48px; }
649
+ .sx-search-lg .sx-search-input { font-size: var(--sx-text-base); padding: var(--sx-space-3) var(--sx-space-4); }
650
+
651
+ .sx-search-loading {
652
+ display: flex;
653
+ align-items: center;
654
+ padding-right: var(--sx-space-3);
655
+ color: var(--sx-color-primary);
656
+ }
657
+
658
+ .sx-search-spinner {
659
+ animation: sx-spin-search 0.8s linear infinite;
660
+ }
661
+
662
+ @keyframes sx-spin-search {
663
+ to { transform: rotate(360deg); }
664
+ }
665
+
666
+ .sx-search-clear {
667
+ display: flex;
668
+ align-items: center;
669
+ justify-content: center;
670
+ width: 28px;
671
+ height: 28px;
672
+ padding: 0;
673
+ margin-right: var(--sx-space-1);
674
+ border: none;
675
+ border-radius: var(--sx-radius-sm);
676
+ background: transparent;
677
+ color: var(--sx-color-text-secondary);
678
+ cursor: pointer;
679
+ transition: background var(--sx-transition-fast), color var(--sx-transition-fast);
680
+ }
681
+
682
+ .sx-search-clear:hover {
683
+ background: var(--sx-color-surface-2);
684
+ color: var(--sx-color-text);
685
+ }
686
+
687
+ /* Footer */
688
+ .sx-search-footer {
689
+ min-height: 1.25rem;
690
+ }
691
+
692
+ .sx-search-error {
693
+ font-size: var(--sx-text-xs);
694
+ font-weight: 500;
695
+ color: var(--sx-color-red);
696
+ margin: 0;
697
+ }
698
+
699
+ .sx-search-hint {
700
+ font-size: var(--sx-text-xs);
701
+ color: var(--sx-color-text-secondary);
702
+ margin: 0;
703
+ }
704
+
705
+ /* Dropdown panel */
706
+ .sx-search-panel {
707
+ z-index: var(--sx-z-dropdown);
708
+ overflow-y: auto;
709
+ overflow-x: hidden;
710
+ background: var(--sx-color-surface-2);
711
+ border: 1px solid var(--sx-color-border-strong);
712
+ border-radius: var(--sx-radius-md);
713
+ box-shadow: var(--sx-shadow-lg);
714
+ backdrop-filter: var(--sx-glass-blur);
715
+ -webkit-backdrop-filter: var(--sx-glass-blur);
716
+ outline: none;
717
+ padding: var(--sx-space-1) 0;
718
+ font-family: var(--sx-font-body);
719
+ }
720
+
721
+ .sx-search-option {
722
+ display: flex;
723
+ flex-direction: column;
724
+ gap: 2px;
725
+ padding: var(--sx-space-2) var(--sx-space-4);
726
+ font-size: var(--sx-text-sm);
727
+ color: var(--sx-color-text);
728
+ cursor: pointer;
729
+ user-select: none;
730
+ transition: background var(--sx-transition-fast);
731
+ }
732
+
733
+ .sx-search-option-active {
734
+ background: var(--sx-color-primary-hover);
735
+ color: var(--sx-color-primary);
736
+ }
737
+
738
+ .sx-search-option-disabled {
739
+ opacity: 0.4;
740
+ cursor: not-allowed;
741
+ }
742
+
743
+ .sx-search-option-label {
744
+ font-weight: 500;
745
+ }
746
+
747
+ .sx-search-option-desc {
748
+ font-size: var(--sx-text-xs);
749
+ color: var(--sx-color-text-secondary);
750
+ }
751
+
752
+ .sx-search-option-active .sx-search-option-desc {
753
+ color: var(--sx-color-primary);
754
+ opacity: 0.7;
755
+ }
756
+
757
+ :global(.sx-search-highlight) {
758
+ background: rgba(255, 107, 53, 0.15);
759
+ color: var(--sx-color-primary);
760
+ border-radius: 2px;
761
+ padding: 0 1px;
762
+ }
763
+
764
+ .sx-search-group-label {
765
+ padding: var(--sx-space-2) var(--sx-space-4) var(--sx-space-1);
766
+ font-size: var(--sx-text-xs);
767
+ font-weight: 600;
768
+ letter-spacing: 0.05em;
769
+ text-transform: uppercase;
770
+ color: var(--sx-color-text-disabled);
771
+ user-select: none;
772
+ }
773
+
774
+ .sx-search-empty,
775
+ .sx-search-loading-msg {
776
+ padding: var(--sx-space-4);
777
+ text-align: center;
778
+ font-size: var(--sx-text-sm);
779
+ color: var(--sx-color-text-disabled);
780
+ }
781
+
782
+ @media (prefers-reduced-motion: reduce) {
783
+ .sx-search-field-wrapper { transition: none; }
784
+ .sx-search-focused { animation: none; }
785
+ .sx-search-spinner { animation: none; }
786
+ .sx-search-option { transition: none; }
787
+ }
788
+ </style>