@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,820 @@
1
+ <!--
2
+ @component MultiSelect
3
+
4
+ INFRARED — Multi-selection with removable chips, searchable dropdown,
5
+ max selection limit, and keyboard navigation.
6
+ Uses WAI-ARIA combobox + listbox pattern.
7
+
8
+ @example
9
+ <MultiSelect
10
+ label="Capabilities"
11
+ options={[
12
+ { value: 'code', label: 'Code generation' },
13
+ { value: 'search', label: 'Web search' },
14
+ { value: 'vision', label: 'Image analysis' },
15
+ ]}
16
+ bind:values={selectedCaps}
17
+ maxSelections={5}
18
+ />
19
+ -->
20
+ <script lang="ts" module>
21
+ export interface MultiSelectOption {
22
+ value: string;
23
+ label: string;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ export interface MultiSelectGroup {
28
+ label: string;
29
+ options: MultiSelectOption[];
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 MultiSelectSize = 'sm' | 'md' | 'lg';
39
+
40
+ interface Props {
41
+ /** Visible label. */
42
+ label: string;
43
+ /** Flat list of options. */
44
+ options?: MultiSelectOption[];
45
+ /** Grouped options. */
46
+ groups?: MultiSelectGroup[];
47
+ /** Selected values (bindable). */
48
+ values?: string[];
49
+ /** Placeholder when nothing selected. */
50
+ placeholder?: string;
51
+ /** Enable search filtering. */
52
+ searchable?: boolean;
53
+ /** Max number of selections allowed. */
54
+ maxSelections?: number;
55
+ /** Error message. */
56
+ error?: string;
57
+ /** Hint text. */
58
+ hint?: string;
59
+ /** Size variant. */
60
+ size?: MultiSelectSize;
61
+ /** Disabled state. */
62
+ disabled?: boolean;
63
+ /** Required field. */
64
+ required?: boolean;
65
+ /** Hide the visible label. */
66
+ hideLabel?: boolean;
67
+ /** Reserve footer space even when empty. */
68
+ alwaysShowFooter?: boolean;
69
+ /** Additional CSS class. */
70
+ class?: string;
71
+ /** Called when values change. */
72
+ onchange?: (values: string[]) => void;
73
+ /** Test ID. */
74
+ testId?: string;
75
+ }
76
+
77
+ let {
78
+ label,
79
+ options = [],
80
+ groups = [],
81
+ values = $bindable([]),
82
+ placeholder = 'Select options...',
83
+ searchable = true,
84
+ maxSelections,
85
+ error = '',
86
+ hint = '',
87
+ size = 'md',
88
+ disabled = false,
89
+ required = false,
90
+ hideLabel = false,
91
+ alwaysShowFooter = true,
92
+ class: className = '',
93
+ onchange,
94
+ testId
95
+ }: Props = $props();
96
+
97
+ const id = generateId('multiselect');
98
+ const listboxId = `${id}-listbox`;
99
+ const labelId = `${id}-label`;
100
+ const errorId = `${id}-error`;
101
+ const hintId = `${id}-hint`;
102
+
103
+ let triggerEl = $state<HTMLDivElement | null>(null);
104
+ let inputRef = $state<HTMLInputElement | null>(null);
105
+ let listboxEl = $state<HTMLDivElement | null>(null);
106
+ let isOpen = $state(false);
107
+ let activeIndex = $state(-1);
108
+ let searchQuery = $state('');
109
+ let hasInteracted = $state(false);
110
+
111
+ let panelTop = $state(0);
112
+ let panelLeft = $state(0);
113
+ let panelWidth = $state(0);
114
+ let panelMaxHeight = $state(260);
115
+ let placeAbove = $state(false);
116
+
117
+ const flatOptions = $derived<MultiSelectOption[]>(
118
+ groups.length > 0
119
+ ? groups.flatMap((g) => g.options)
120
+ : options
121
+ );
122
+
123
+ const filteredFlat = $derived<MultiSelectOption[]>(
124
+ searchQuery
125
+ ? flatOptions.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase()))
126
+ : flatOptions
127
+ );
128
+
129
+ const filteredGroups = $derived(
130
+ groups.length > 0 && searchQuery
131
+ ? groups
132
+ .map((g) => ({
133
+ ...g,
134
+ options: g.options.filter((o) =>
135
+ o.label.toLowerCase().includes(searchQuery.toLowerCase())
136
+ ),
137
+ }))
138
+ .filter((g) => g.options.length > 0)
139
+ : groups
140
+ );
141
+
142
+ const displayOptions = $derived<MultiSelectOption[]>(
143
+ groups.length > 0
144
+ ? (searchQuery ? filteredGroups : groups).flatMap((g) => g.options)
145
+ : filteredFlat
146
+ );
147
+
148
+ const enabledIndices = $derived(
149
+ displayOptions
150
+ .map((o, i) => ({
151
+ i,
152
+ disabled: o.disabled || (!!maxSelections && values.length >= maxSelections && !values.includes(o.value)),
153
+ }))
154
+ .filter((x) => !x.disabled)
155
+ .map((x) => x.i)
156
+ );
157
+
158
+ const selectedOptions = $derived(
159
+ flatOptions.filter((o) => values.includes(o.value))
160
+ );
161
+
162
+ const showError = $derived(hasInteracted && !!error);
163
+ const atMax = $derived(!!maxSelections && values.length >= maxSelections);
164
+
165
+ const ariaDescribedBy = $derived(
166
+ [showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
167
+ );
168
+ const hasFooterContent = $derived(showError || !!hint);
169
+
170
+ // Clamp activeIndex when filtered list changes
171
+ $effect(() => {
172
+ if (isOpen && activeIndex >= displayOptions.length) {
173
+ activeIndex = -1;
174
+ }
175
+ });
176
+
177
+ function positionDropdown() {
178
+ if (!triggerEl) return;
179
+ const rect = triggerEl.getBoundingClientRect();
180
+ const viewportH = window.innerHeight;
181
+ const spaceBelow = viewportH - rect.bottom;
182
+ const spaceAbove = rect.top;
183
+ const maxH = 260;
184
+
185
+ placeAbove = spaceBelow < Math.min(maxH, 150) && spaceAbove > spaceBelow;
186
+ panelLeft = rect.left;
187
+ panelWidth = rect.width;
188
+
189
+ if (placeAbove) {
190
+ panelMaxHeight = Math.min(maxH, spaceAbove - 8);
191
+ panelTop = rect.top - 2;
192
+ } else {
193
+ panelMaxHeight = Math.min(maxH, spaceBelow - 8);
194
+ panelTop = rect.bottom + 2;
195
+ }
196
+ }
197
+
198
+ function openDropdown() {
199
+ if (disabled) return;
200
+ isOpen = true;
201
+ activeIndex = -1;
202
+ searchQuery = '';
203
+ positionDropdown();
204
+ tick().then(() => inputRef?.focus());
205
+ }
206
+
207
+ function closeDropdown() {
208
+ isOpen = false;
209
+ activeIndex = -1;
210
+ searchQuery = '';
211
+ }
212
+
213
+ function toggleOption(opt: MultiSelectOption) {
214
+ if (opt.disabled) return;
215
+ const isSelected = values.includes(opt.value);
216
+ if (isSelected) {
217
+ values = values.filter((v) => v !== opt.value);
218
+ } else if (!atMax) {
219
+ values = [...values, opt.value];
220
+ }
221
+ hasInteracted = true;
222
+ onchange?.(values);
223
+ }
224
+
225
+ function removeChip(optValue: string) {
226
+ values = values.filter((v) => v !== optValue);
227
+ hasInteracted = true;
228
+ onchange?.(values);
229
+ if (isOpen) inputRef?.focus();
230
+ }
231
+
232
+ function scrollActiveIntoView() {
233
+ if (!listboxEl || activeIndex < 0) return;
234
+ const active = listboxEl.querySelector(`[data-option-index="${activeIndex}"]`) as HTMLElement;
235
+ if (!active) return;
236
+ const listTop = listboxEl.scrollTop;
237
+ const listHeight = listboxEl.clientHeight;
238
+ const elTop = active.offsetTop;
239
+ const elHeight = active.offsetHeight;
240
+ if (elTop < listTop) {
241
+ listboxEl.scrollTop = elTop;
242
+ } else if (elTop + elHeight > listTop + listHeight) {
243
+ listboxEl.scrollTop = elTop + elHeight - listHeight;
244
+ }
245
+ }
246
+
247
+ function handleKeydown(e: KeyboardEvent) {
248
+ if (!isOpen) {
249
+ if (e.key === Keys.Enter || e.key === Keys.Space || e.key === Keys.ArrowDown) {
250
+ e.preventDefault();
251
+ openDropdown();
252
+ }
253
+ return;
254
+ }
255
+
256
+ switch (e.key) {
257
+ case Keys.ArrowDown:
258
+ e.preventDefault();
259
+ {
260
+ const pos = enabledIndices.indexOf(activeIndex);
261
+ if (pos < enabledIndices.length - 1) {
262
+ activeIndex = enabledIndices[pos + 1];
263
+ } else if (pos === -1 && enabledIndices.length > 0) {
264
+ activeIndex = enabledIndices[0];
265
+ }
266
+ tick().then(scrollActiveIntoView);
267
+ }
268
+ break;
269
+ case Keys.ArrowUp:
270
+ e.preventDefault();
271
+ {
272
+ const pos = enabledIndices.indexOf(activeIndex);
273
+ if (pos > 0) {
274
+ activeIndex = enabledIndices[pos - 1];
275
+ }
276
+ tick().then(scrollActiveIntoView);
277
+ }
278
+ break;
279
+ case Keys.Enter:
280
+ e.preventDefault();
281
+ if (activeIndex >= 0 && displayOptions[activeIndex]) {
282
+ toggleOption(displayOptions[activeIndex]);
283
+ }
284
+ break;
285
+ case Keys.Space:
286
+ // Allow typing spaces in the search input
287
+ if (searchable && document.activeElement === inputRef) break;
288
+ e.preventDefault();
289
+ if (activeIndex >= 0 && displayOptions[activeIndex]) {
290
+ toggleOption(displayOptions[activeIndex]);
291
+ }
292
+ break;
293
+ case Keys.Escape:
294
+ e.preventDefault();
295
+ closeDropdown();
296
+ triggerEl?.focus();
297
+ break;
298
+ case 'Backspace':
299
+ if (searchQuery === '' && values.length > 0) {
300
+ removeChip(values[values.length - 1]);
301
+ }
302
+ break;
303
+ case Keys.Home:
304
+ e.preventDefault();
305
+ if (enabledIndices.length > 0) {
306
+ activeIndex = enabledIndices[0];
307
+ tick().then(scrollActiveIntoView);
308
+ }
309
+ break;
310
+ case Keys.End:
311
+ e.preventDefault();
312
+ if (enabledIndices.length > 0) {
313
+ activeIndex = enabledIndices[enabledIndices.length - 1];
314
+ tick().then(scrollActiveIntoView);
315
+ }
316
+ break;
317
+ }
318
+ }
319
+
320
+ function handleClickOutside(e: MouseEvent) {
321
+ const target = e.target as Node;
322
+ if (triggerEl?.contains(target)) return;
323
+ if (listboxEl?.contains(target)) return;
324
+ closeDropdown();
325
+ }
326
+
327
+ function handleReposition() {
328
+ if (isOpen) positionDropdown();
329
+ }
330
+
331
+ function getOptionId(index: number) {
332
+ return `${id}-option-${index}`;
333
+ }
334
+
335
+ function getGlobalIndex(groupIdx: number, optIdx: number): number {
336
+ const srcGroups = searchQuery ? filteredGroups : groups;
337
+ let offset = 0;
338
+ for (let g = 0; g < groupIdx; g++) {
339
+ offset += srcGroups[g].options.length;
340
+ }
341
+ return offset + optIdx;
342
+ }
343
+
344
+ $effect(() => {
345
+ if (listboxEl && isOpen) {
346
+ document.body.appendChild(listboxEl);
347
+ return () => {
348
+ if (listboxEl?.parentNode === document.body) {
349
+ document.body.removeChild(listboxEl);
350
+ }
351
+ };
352
+ }
353
+ });
354
+
355
+ onMount(() => {
356
+ document.addEventListener('mousedown', handleClickOutside);
357
+ window.addEventListener('scroll', handleReposition, true);
358
+ window.addEventListener('resize', handleReposition);
359
+ return () => {
360
+ document.removeEventListener('mousedown', handleClickOutside);
361
+ window.removeEventListener('scroll', handleReposition, true);
362
+ window.removeEventListener('resize', handleReposition);
363
+ if (listboxEl?.parentNode === document.body) {
364
+ document.body.removeChild(listboxEl);
365
+ }
366
+ };
367
+ });
368
+ </script>
369
+
370
+ <div
371
+ class={cn('sx-multi-wrapper', `sx-multi-${size}`, disabled && 'sx-multi-disabled', className)}
372
+ data-testid={testId}
373
+ >
374
+ <!-- svelte-ignore a11y_label_has_associated_control -->
375
+ <label id={labelId} class={cn('sx-multi-label', hideLabel && 'sx-sr-only')}>
376
+ {label}
377
+ {#if required}
378
+ <span class="sx-multi-required" aria-hidden="true">*</span>
379
+ {/if}
380
+ </label>
381
+
382
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
383
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
384
+ <div
385
+ bind:this={triggerEl}
386
+ class={cn(
387
+ 'sx-multi-trigger',
388
+ isOpen && 'sx-multi-trigger-open',
389
+ showError && 'sx-multi-trigger-error',
390
+ disabled && 'sx-multi-trigger-disabled'
391
+ )}
392
+ role="combobox"
393
+ aria-expanded={isOpen}
394
+ aria-haspopup="listbox"
395
+ aria-controls={isOpen ? listboxId : undefined}
396
+ aria-activedescendant={isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
397
+ aria-labelledby={labelId}
398
+ aria-describedby={ariaDescribedBy}
399
+ aria-required={required || undefined}
400
+ aria-invalid={showError || undefined}
401
+ tabindex={disabled ? -1 : 0}
402
+ onclick={() => { if (!isOpen) openDropdown(); }}
403
+ onkeydown={handleKeydown}
404
+ >
405
+ <div class="sx-multi-chips">
406
+ {#each selectedOptions as opt (opt.value)}
407
+ <span class="sx-multi-chip">
408
+ <span class="sx-multi-chip-label">{opt.label}</span>
409
+ <button
410
+ type="button"
411
+ class="sx-multi-chip-remove"
412
+ aria-label={`Remove ${opt.label}`}
413
+ tabindex={-1}
414
+ onclick={(e) => { e.stopPropagation(); removeChip(opt.value); }}
415
+ >
416
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
417
+ </button>
418
+ </span>
419
+ {/each}
420
+
421
+ {#if searchable && isOpen}
422
+ <input
423
+ bind:this={inputRef}
424
+ type="text"
425
+ class="sx-multi-search"
426
+ bind:value={searchQuery}
427
+ placeholder={selectedOptions.length === 0 ? placeholder : ''}
428
+ aria-label="Filter options"
429
+ tabindex={-1}
430
+ />
431
+ {:else if selectedOptions.length === 0}
432
+ <span class="sx-multi-placeholder">{placeholder}</span>
433
+ {/if}
434
+ </div>
435
+
436
+ <span class="sx-multi-chevron" aria-hidden="true">
437
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
438
+ <path d="M3 4.5L6 7.5L9 4.5" />
439
+ </svg>
440
+ </span>
441
+ </div>
442
+
443
+ {#if alwaysShowFooter || hasFooterContent}
444
+ <div class="sx-multi-footer">
445
+ {#if showError}
446
+ <p id={errorId} class="sx-multi-error" role="alert" aria-live="assertive">{error}</p>
447
+ {:else if hint}
448
+ <p id={hintId} class="sx-multi-hint">{hint}</p>
449
+ {/if}
450
+ {#if maxSelections}
451
+ <span class="sx-multi-count">{values.length}/{maxSelections}</span>
452
+ {/if}
453
+ </div>
454
+ {/if}
455
+ </div>
456
+
457
+ <!-- Dropdown panel -->
458
+ {#if isOpen}
459
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
460
+ <div
461
+ bind:this={listboxEl}
462
+ id={listboxId}
463
+ class="sx-multi-panel"
464
+ style="position:fixed;left:{panelLeft}px;{placeAbove ? `bottom:${window.innerHeight - panelTop}px` : `top:${panelTop}px`};width:{panelWidth}px;max-height:{panelMaxHeight}px;"
465
+ role="listbox"
466
+ aria-labelledby={labelId}
467
+ aria-multiselectable="true"
468
+ tabindex="-1"
469
+ onmousedown={(e) => e.preventDefault()}
470
+ >
471
+ {#if displayOptions.length === 0}
472
+ <div class="sx-multi-empty">No options found</div>
473
+ {:else if (searchQuery ? filteredGroups : groups).length > 0}
474
+ {#each (searchQuery ? filteredGroups : groups) as group, gi}
475
+ <div role="group" aria-label={group.label}>
476
+ <div class="sx-multi-group-label">{group.label}</div>
477
+ {#each group.options as opt, oi}
478
+ {@const globalIdx = getGlobalIndex(gi, oi)}
479
+ {@const isActive = globalIdx === activeIndex}
480
+ {@const isSelected = values.includes(opt.value)}
481
+ {@const isMaxed = atMax && !isSelected}
482
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
483
+ <div
484
+ id={getOptionId(globalIdx)}
485
+ class={cn(
486
+ 'sx-multi-option',
487
+ isActive && 'sx-multi-option-active',
488
+ isSelected && 'sx-multi-option-selected',
489
+ (opt.disabled || isMaxed) && 'sx-multi-option-disabled'
490
+ )}
491
+ role="option"
492
+ tabindex="-1"
493
+ aria-selected={isSelected}
494
+ aria-disabled={opt.disabled || isMaxed || undefined}
495
+ data-option-index={globalIdx}
496
+ onmouseenter={() => { if (!opt.disabled && !isMaxed) activeIndex = globalIdx; }}
497
+ onmousedown={(e) => { e.preventDefault(); if (!isMaxed) toggleOption(opt); }}
498
+ >
499
+ <span class="sx-multi-check" aria-hidden="true">
500
+ {#if isSelected}
501
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2.5 6L5 8.5L9.5 3.5" /></svg>
502
+ {/if}
503
+ </span>
504
+ <span class="sx-multi-option-label">{opt.label}</span>
505
+ </div>
506
+ {/each}
507
+ </div>
508
+ {/each}
509
+ {:else}
510
+ {#each displayOptions as opt, i}
511
+ {@const isActive = i === activeIndex}
512
+ {@const isSelected = values.includes(opt.value)}
513
+ {@const isMaxed = atMax && !isSelected}
514
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
515
+ <div
516
+ id={getOptionId(i)}
517
+ class={cn(
518
+ 'sx-multi-option',
519
+ isActive && 'sx-multi-option-active',
520
+ isSelected && 'sx-multi-option-selected',
521
+ (opt.disabled || isMaxed) && 'sx-multi-option-disabled'
522
+ )}
523
+ role="option"
524
+ tabindex="-1"
525
+ aria-selected={isSelected}
526
+ aria-disabled={opt.disabled || isMaxed || undefined}
527
+ data-option-index={i}
528
+ onmouseenter={() => { if (!opt.disabled && !isMaxed) activeIndex = i; }}
529
+ onmousedown={(e) => { e.preventDefault(); if (!isMaxed) toggleOption(opt); }}
530
+ >
531
+ <span class="sx-multi-check" aria-hidden="true">
532
+ {#if isSelected}
533
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2.5 6L5 8.5L9.5 3.5" /></svg>
534
+ {/if}
535
+ </span>
536
+ <span class="sx-multi-option-label">{opt.label}</span>
537
+ </div>
538
+ {/each}
539
+ {/if}
540
+ </div>
541
+ {/if}
542
+
543
+ <style>
544
+ .sx-multi-wrapper {
545
+ display: flex;
546
+ flex-direction: column;
547
+ gap: var(--sx-space-1);
548
+ font-family: var(--sx-font-body);
549
+ }
550
+
551
+ .sx-multi-disabled { opacity: 0.5; }
552
+
553
+ .sx-multi-label {
554
+ font-size: var(--sx-text-sm);
555
+ font-weight: 500;
556
+ color: var(--sx-color-text-secondary);
557
+ }
558
+
559
+ .sx-multi-required { color: var(--sx-color-red); margin-left: 2px; }
560
+
561
+ .sx-sr-only {
562
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
563
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
564
+ }
565
+
566
+ /* Trigger */
567
+ .sx-multi-trigger {
568
+ display: flex;
569
+ align-items: center;
570
+ gap: var(--sx-space-2);
571
+ width: 100%;
572
+ border: 1px solid var(--sx-color-border-strong);
573
+ border-radius: var(--sx-radius-md);
574
+ background: var(--sx-color-surface);
575
+ cursor: pointer;
576
+ transition: border-color var(--sx-transition-fast), box-shadow var(--sx-transition-fast);
577
+ box-shadow:
578
+ inset 0 1px 3px rgba(0, 0, 0, 0.3),
579
+ inset 0 0 0 1px rgba(0, 0, 0, 0.06);
580
+ min-height: 40px;
581
+ padding: var(--sx-space-1) var(--sx-space-3);
582
+ }
583
+
584
+ .sx-multi-trigger:hover:not(.sx-multi-trigger-disabled) {
585
+ border-color: var(--sx-color-border-hover);
586
+ }
587
+
588
+ .sx-multi-trigger:focus-visible {
589
+ outline: none;
590
+ border-color: var(--sx-color-primary);
591
+ box-shadow:
592
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
593
+ 0 0 0 3px var(--sx-color-primary-ring);
594
+ }
595
+
596
+ .sx-multi-trigger-open {
597
+ border-color: var(--sx-color-primary);
598
+ box-shadow:
599
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
600
+ 0 0 0 3px var(--sx-color-primary-ring);
601
+ }
602
+
603
+ .sx-multi-trigger-error {
604
+ border-color: var(--sx-color-red);
605
+ box-shadow:
606
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
607
+ 0 0 0 3px var(--sx-color-red-ring);
608
+ }
609
+
610
+ .sx-multi-trigger-disabled {
611
+ cursor: not-allowed;
612
+ box-shadow: none;
613
+ }
614
+
615
+ .sx-multi-sm .sx-multi-trigger { min-height: 32px; font-size: var(--sx-text-xs); }
616
+ .sx-multi-lg .sx-multi-trigger { min-height: 48px; font-size: var(--sx-text-base); }
617
+
618
+ /* Chips area */
619
+ .sx-multi-chips {
620
+ flex: 1;
621
+ display: flex;
622
+ flex-wrap: wrap;
623
+ gap: var(--sx-space-1);
624
+ align-items: center;
625
+ min-width: 0;
626
+ }
627
+
628
+ .sx-multi-chip {
629
+ display: inline-flex;
630
+ align-items: center;
631
+ gap: var(--sx-space-1);
632
+ padding: 2px var(--sx-space-2);
633
+ background: var(--sx-color-primary-subtle);
634
+ border: 1px solid rgba(255, 107, 53, 0.2);
635
+ border-radius: var(--sx-radius-full);
636
+ font-size: var(--sx-text-xs);
637
+ font-weight: 500;
638
+ color: var(--sx-color-primary);
639
+ animation: sx-badge-pop 150ms var(--sx-ease-spring) both;
640
+ }
641
+
642
+ .sx-multi-chip-label {
643
+ max-width: 150px;
644
+ overflow: hidden;
645
+ text-overflow: ellipsis;
646
+ white-space: nowrap;
647
+ }
648
+
649
+ .sx-multi-chip-remove {
650
+ display: flex;
651
+ align-items: center;
652
+ justify-content: center;
653
+ width: 16px;
654
+ height: 16px;
655
+ padding: 0;
656
+ border: none;
657
+ border-radius: 50%;
658
+ background: transparent;
659
+ color: var(--sx-color-primary);
660
+ cursor: pointer;
661
+ transition: background var(--sx-transition-fast);
662
+ }
663
+
664
+ .sx-multi-chip-remove:hover {
665
+ background: rgba(255, 107, 53, 0.2);
666
+ }
667
+
668
+ .sx-multi-search {
669
+ flex: 1;
670
+ min-width: 60px;
671
+ border: none;
672
+ background: transparent;
673
+ outline: none;
674
+ color: var(--sx-color-text);
675
+ font-family: var(--sx-font-body);
676
+ font-size: var(--sx-text-sm);
677
+ padding: var(--sx-space-1) 0;
678
+ }
679
+
680
+ .sx-multi-search::placeholder {
681
+ color: var(--sx-color-text-disabled);
682
+ }
683
+
684
+ .sx-multi-placeholder {
685
+ color: var(--sx-color-text-disabled);
686
+ font-size: var(--sx-text-sm);
687
+ }
688
+
689
+ .sx-multi-chevron {
690
+ flex-shrink: 0;
691
+ display: flex;
692
+ align-items: center;
693
+ color: var(--sx-color-text-secondary);
694
+ transition: transform var(--sx-transition-fast);
695
+ }
696
+
697
+ .sx-multi-trigger-open .sx-multi-chevron {
698
+ transform: rotate(180deg);
699
+ }
700
+
701
+ /* Footer */
702
+ .sx-multi-footer {
703
+ display: flex;
704
+ justify-content: space-between;
705
+ align-items: center;
706
+ min-height: 1.25rem;
707
+ }
708
+
709
+ .sx-multi-error {
710
+ font-size: var(--sx-text-xs);
711
+ font-weight: 500;
712
+ color: var(--sx-color-red);
713
+ margin: 0;
714
+ }
715
+
716
+ .sx-multi-hint {
717
+ font-size: var(--sx-text-xs);
718
+ color: var(--sx-color-text-secondary);
719
+ margin: 0;
720
+ }
721
+
722
+ .sx-multi-count {
723
+ font-size: var(--sx-text-xs);
724
+ font-family: var(--sx-font-mono);
725
+ color: var(--sx-color-text-secondary);
726
+ }
727
+
728
+ /* Dropdown panel */
729
+ .sx-multi-panel {
730
+ z-index: var(--sx-z-dropdown);
731
+ overflow-y: auto;
732
+ background: var(--sx-color-surface-2);
733
+ border: 1px solid var(--sx-color-border-strong);
734
+ border-radius: var(--sx-radius-md);
735
+ box-shadow: var(--sx-shadow-lg);
736
+ backdrop-filter: var(--sx-glass-blur);
737
+ -webkit-backdrop-filter: var(--sx-glass-blur);
738
+ outline: none;
739
+ padding: var(--sx-space-1) 0;
740
+ font-family: var(--sx-font-body);
741
+ }
742
+
743
+ .sx-multi-option {
744
+ display: flex;
745
+ align-items: center;
746
+ gap: var(--sx-space-2);
747
+ padding: var(--sx-space-2) var(--sx-space-4);
748
+ font-size: var(--sx-text-sm);
749
+ font-weight: 500;
750
+ color: var(--sx-color-text);
751
+ cursor: pointer;
752
+ user-select: none;
753
+ transition: background var(--sx-transition-fast);
754
+ }
755
+
756
+ .sx-multi-option-active {
757
+ background: var(--sx-color-primary-hover);
758
+ color: var(--sx-color-primary);
759
+ }
760
+
761
+ .sx-multi-option-selected:not(.sx-multi-option-active) {
762
+ background: var(--sx-color-primary-subtle);
763
+ }
764
+
765
+ .sx-multi-option-disabled {
766
+ opacity: 0.4;
767
+ cursor: not-allowed;
768
+ }
769
+
770
+ .sx-multi-option-label {
771
+ flex: 1;
772
+ overflow: hidden;
773
+ text-overflow: ellipsis;
774
+ white-space: nowrap;
775
+ }
776
+
777
+ .sx-multi-check {
778
+ flex-shrink: 0;
779
+ display: flex;
780
+ align-items: center;
781
+ justify-content: center;
782
+ width: 16px;
783
+ height: 16px;
784
+ border: 1px solid var(--sx-color-border-strong);
785
+ border-radius: var(--sx-radius-sm);
786
+ background: var(--sx-color-surface);
787
+ }
788
+
789
+ .sx-multi-option-active .sx-multi-check {
790
+ border-color: var(--sx-color-primary);
791
+ }
792
+
793
+ .sx-multi-option-selected .sx-multi-check {
794
+ background: var(--sx-gradient-brand);
795
+ border-color: rgba(255, 107, 53, 0.6);
796
+ color: #fff;
797
+ }
798
+
799
+ .sx-multi-group-label {
800
+ padding: var(--sx-space-2) var(--sx-space-4) var(--sx-space-1);
801
+ font-size: var(--sx-text-xs);
802
+ font-weight: 600;
803
+ letter-spacing: 0.05em;
804
+ text-transform: uppercase;
805
+ color: var(--sx-color-text-disabled);
806
+ user-select: none;
807
+ }
808
+
809
+ .sx-multi-empty {
810
+ padding: var(--sx-space-4);
811
+ text-align: center;
812
+ font-size: var(--sx-text-sm);
813
+ color: var(--sx-color-text-disabled);
814
+ }
815
+
816
+ @media (prefers-reduced-motion: reduce) {
817
+ .sx-multi-trigger, .sx-multi-option, .sx-multi-chevron { transition: none; }
818
+ .sx-multi-chip { animation: none; }
819
+ }
820
+ </style>