@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,1651 @@
1
+ <!--
2
+ @component MultiSelect
3
+
4
+ A fully accessible multi-select component with chips/tags display,
5
+ following WAI-ARIA 1.2 listbox pattern. Supports searchable options,
6
+ grouped options, and comprehensive keyboard navigation.
7
+
8
+ Designed for healthcare applications requiring selection of multiple items
9
+ such as service types, diagnoses, medications, or procedures.
10
+
11
+ @example Basic usage
12
+ <MultiSelect
13
+ label="Service Types"
14
+ options={serviceTypes}
15
+ bind:selected={selectedServices}
16
+ placeholder="Select services..."
17
+ />
18
+
19
+ @example With search and max selections
20
+ <MultiSelect
21
+ label="Diagnoses"
22
+ options={diagnoses}
23
+ bind:selected={selectedDiagnoses}
24
+ searchable
25
+ maxSelections={5}
26
+ placeholder="Search diagnoses..."
27
+ />
28
+ -->
29
+ <script lang="ts">
30
+ import type { Snippet } from 'svelte';
31
+ import { X, ChevronDown, Check, AlertCircle, Search, Info, Loader2 } from 'lucide-svelte';
32
+ import { cn } from '../../utils/cn.js';
33
+ import { generateId, Keys } from '../../utils/keyboard.js';
34
+ import { announce, prefersReducedMotion } from '../../utils/accessibility.js';
35
+
36
+ // ============================================================================
37
+ // Types
38
+ // ============================================================================
39
+
40
+ type InputSize = 'sm' | 'md' | 'lg';
41
+
42
+ /**
43
+ * Multi-select option item.
44
+ */
45
+ export interface MultiSelectOption {
46
+ /** Unique value for the option */
47
+ value: string;
48
+ /** Display label for the option */
49
+ label: string;
50
+ /** Whether the option is disabled */
51
+ disabled?: boolean;
52
+ /** Optional description shown below the label */
53
+ description?: string;
54
+ /** Optional metadata for custom rendering */
55
+ metadata?: Record<string, unknown>;
56
+ }
57
+
58
+ /**
59
+ * Grouped multi-select options.
60
+ */
61
+ export interface MultiSelectOptionGroup {
62
+ /** Group label */
63
+ label: string;
64
+ /** Options in this group */
65
+ options: MultiSelectOption[];
66
+ }
67
+
68
+ type MultiSelectOptions = MultiSelectOption[] | MultiSelectOptionGroup[];
69
+
70
+ interface Props {
71
+ /** Unique identifier for the component */
72
+ id?: string;
73
+ /** Form field name */
74
+ name?: string;
75
+ /** Label text for the field */
76
+ label: string;
77
+ /** Currently selected options (bindable) */
78
+ selected?: MultiSelectOption[];
79
+ /** Placeholder text when no selections */
80
+ placeholder?: string;
81
+ /** Options to display (flat or grouped) */
82
+ options?: MultiSelectOptions;
83
+ /** Whether the field is required */
84
+ required?: boolean;
85
+ /** Whether the field is disabled */
86
+ disabled?: boolean;
87
+ /** Whether the field is read-only */
88
+ readonly?: boolean;
89
+ /** Error message to display */
90
+ error?: string;
91
+ /** Hint text to display below the component */
92
+ hint?: string;
93
+ /** Success message when valid */
94
+ successMessage?: string;
95
+ /** Size of the component */
96
+ size?: InputSize;
97
+ /** Whether to hide the label visually (still accessible) */
98
+ hideLabel?: boolean;
99
+ /** Additional CSS classes for the wrapper */
100
+ class?: string;
101
+ /** Enable search/filter functionality */
102
+ searchable?: boolean;
103
+ /** Search placeholder text */
104
+ searchPlaceholder?: string;
105
+ /** Maximum number of selections allowed */
106
+ maxSelections?: number;
107
+ /** Whether to close dropdown after each selection */
108
+ closeOnSelect?: boolean;
109
+ /** Show loading spinner during async operations */
110
+ loading?: boolean;
111
+ /** Message to show when no options match search */
112
+ emptyMessage?: string;
113
+ /** Whether to show "Select All" option */
114
+ showSelectAll?: boolean;
115
+ /** Label for "Select All" */
116
+ selectAllLabel?: string;
117
+ /** Whether to show selected count badge */
118
+ showCount?: boolean;
119
+ /** Custom chip rendering */
120
+ chipContent?: Snippet<[MultiSelectOption]>;
121
+ /** Custom option rendering */
122
+ optionContent?: Snippet<[MultiSelectOption]>;
123
+ /** Custom empty state content */
124
+ emptyContent?: Snippet;
125
+ /** Called when selection changes */
126
+ onchange?: (selected: MultiSelectOption[]) => void;
127
+ /** Called when an option is selected */
128
+ onselect?: (option: MultiSelectOption) => void;
129
+ /** Called when an option is deselected */
130
+ ondeselect?: (option: MultiSelectOption) => void;
131
+ /** Called when search query changes */
132
+ onsearch?: (query: string) => void;
133
+ /** Test ID for e2e testing */
134
+ testId?: string;
135
+ /** Show validation state immediately */
136
+ validateOnMount?: boolean;
137
+ }
138
+
139
+ let {
140
+ id = generateId('multiselect'),
141
+ name,
142
+ label,
143
+ selected = $bindable([]),
144
+ placeholder = 'Select options...',
145
+ options = [],
146
+ required = false,
147
+ disabled = false,
148
+ readonly = false,
149
+ error = '',
150
+ hint = '',
151
+ successMessage = '',
152
+ size = 'md',
153
+ hideLabel = false,
154
+ class: className = '',
155
+ searchable = false,
156
+ searchPlaceholder = 'Search...',
157
+ maxSelections,
158
+ closeOnSelect = false,
159
+ loading = false,
160
+ emptyMessage = 'No options found',
161
+ showSelectAll = false,
162
+ selectAllLabel = 'Select All',
163
+ showCount = true,
164
+ chipContent,
165
+ optionContent,
166
+ emptyContent,
167
+ onchange,
168
+ onselect,
169
+ ondeselect,
170
+ onsearch,
171
+ testId,
172
+ validateOnMount = false
173
+ }: Props = $props();
174
+
175
+ // ============================================================================
176
+ // State
177
+ // ============================================================================
178
+
179
+ let containerRef: HTMLDivElement | undefined = $state();
180
+ let inputRef: HTMLInputElement | HTMLButtonElement | undefined = $state();
181
+ let listboxRef: HTMLDivElement | undefined = $state();
182
+ let isOpen = $state(false);
183
+ let isFocused = $state(false);
184
+ let hasInteracted = $state(false);
185
+ let searchQuery = $state('');
186
+ let activeIndex = $state(-1);
187
+ let focusedChipIndex = $state(-1);
188
+
189
+ // ============================================================================
190
+ // Derived State
191
+ // ============================================================================
192
+
193
+ // Flatten grouped options
194
+ const flatOptions = $derived.by(() => {
195
+ if (options.length === 0) return [];
196
+ if ('options' in options[0]) {
197
+ return (options as MultiSelectOptionGroup[]).flatMap((g) => g.options);
198
+ }
199
+ return options as MultiSelectOption[];
200
+ });
201
+
202
+ // Check if options are grouped
203
+ const isGrouped = $derived(options.length > 0 && 'options' in options[0]);
204
+
205
+ // Filter options based on search
206
+ const filteredOptions = $derived.by(() => {
207
+ if (!searchable || !searchQuery.trim()) {
208
+ return options;
209
+ }
210
+ const query = searchQuery.toLowerCase().trim();
211
+
212
+ if (isGrouped) {
213
+ const groups = options as MultiSelectOptionGroup[];
214
+ return groups
215
+ .map((group) => ({
216
+ ...group,
217
+ options: group.options.filter(
218
+ (opt) =>
219
+ opt.label.toLowerCase().includes(query) ||
220
+ opt.description?.toLowerCase().includes(query)
221
+ )
222
+ }))
223
+ .filter((group) => group.options.length > 0);
224
+ }
225
+
226
+ return (options as MultiSelectOption[]).filter(
227
+ (opt) =>
228
+ opt.label.toLowerCase().includes(query) ||
229
+ opt.description?.toLowerCase().includes(query)
230
+ );
231
+ });
232
+
233
+ // Flatten filtered options for navigation
234
+ const filteredFlatOptions = $derived.by(() => {
235
+ if (!Array.isArray(filteredOptions) || filteredOptions.length === 0) return [];
236
+ if ('options' in filteredOptions[0]) {
237
+ return (filteredOptions as MultiSelectOptionGroup[]).flatMap((g) => g.options);
238
+ }
239
+ return filteredOptions as MultiSelectOption[];
240
+ });
241
+
242
+ // Filter to only enabled options for navigation
243
+ const enabledOptions = $derived(
244
+ filteredFlatOptions.filter((o) => !o.disabled && !isSelected(o))
245
+ );
246
+
247
+ // All enabled options (including selected, for keyboard nav)
248
+ const allEnabledOptions = $derived(filteredFlatOptions.filter((o) => !o.disabled));
249
+
250
+ // Selected values set for quick lookup
251
+ const selectedValues = $derived(new Set(selected.map((o) => o.value)));
252
+
253
+ // Check if at max selections
254
+ const atMaxSelections = $derived(
255
+ maxSelections !== undefined && selected.length >= maxSelections
256
+ );
257
+
258
+ // Check if all options are selected
259
+ const allSelected = $derived(
260
+ flatOptions.length > 0 &&
261
+ flatOptions.filter((o) => !o.disabled).every((o) => selectedValues.has(o.value))
262
+ );
263
+
264
+ // Validation display logic
265
+ const shouldValidate = $derived(hasInteracted || validateOnMount);
266
+ const showError = $derived(shouldValidate && !!error);
267
+ const showSuccess = $derived(shouldValidate && !error && selected.length > 0 && !!successMessage);
268
+ const isValid = $derived(shouldValidate && !error && selected.length > 0 && !!successMessage);
269
+
270
+ // Show empty state
271
+ const showEmpty = $derived(!loading && filteredFlatOptions.length === 0);
272
+
273
+ // IDs for accessibility
274
+ const listboxId = $derived(`${id}-listbox`);
275
+ const errorId = $derived(`${id}-error`);
276
+ const hintId = $derived(`${id}-hint`);
277
+ const labelId = $derived(`${id}-label`);
278
+ const activeDescendantId = $derived(
279
+ activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined
280
+ );
281
+
282
+ // Build aria-describedby
283
+ const ariaDescribedBy = $derived(
284
+ [showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
285
+ );
286
+
287
+ // ============================================================================
288
+ // Helpers
289
+ // ============================================================================
290
+
291
+ function isSelected(option: MultiSelectOption): boolean {
292
+ return selectedValues.has(option.value);
293
+ }
294
+
295
+ function getOptionIndex(option: MultiSelectOption): number {
296
+ return filteredFlatOptions.findIndex((o) => o.value === option.value);
297
+ }
298
+
299
+ function scrollToOption(index: number) {
300
+ if (!listboxRef || index < 0) return;
301
+ const optionEl = listboxRef.querySelector(`[data-index="${index}"]`);
302
+ if (optionEl && typeof optionEl.scrollIntoView === 'function') {
303
+ optionEl.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
304
+ }
305
+ }
306
+
307
+ // ============================================================================
308
+ // Selection Logic
309
+ // ============================================================================
310
+
311
+ function toggleOption(option: MultiSelectOption) {
312
+ if (option.disabled) return;
313
+
314
+ if (isSelected(option)) {
315
+ // Deselect
316
+ selected = selected.filter((o) => o.value !== option.value);
317
+ announce(`Removed ${option.label}`, 'polite');
318
+ ondeselect?.(option);
319
+ } else {
320
+ // Check max selections
321
+ if (atMaxSelections) {
322
+ announce(`Maximum ${maxSelections} selections reached`, 'assertive');
323
+ return;
324
+ }
325
+ // Select
326
+ selected = [...selected, option];
327
+ announce(`Added ${option.label}`, 'polite');
328
+ onselect?.(option);
329
+ }
330
+
331
+ onchange?.(selected);
332
+
333
+ if (closeOnSelect) {
334
+ isOpen = false;
335
+ activeIndex = -1;
336
+ }
337
+ }
338
+
339
+ function removeChip(option: MultiSelectOption, e?: MouseEvent) {
340
+ e?.preventDefault();
341
+ e?.stopPropagation();
342
+
343
+ if (disabled || readonly) return;
344
+
345
+ selected = selected.filter((o) => o.value !== option.value);
346
+ announce(`Removed ${option.label}`, 'polite');
347
+ ondeselect?.(option);
348
+ onchange?.(selected);
349
+
350
+ // Return focus to input
351
+ inputRef?.focus();
352
+ }
353
+
354
+ function selectAll() {
355
+ if (disabled || readonly) return;
356
+
357
+ if (allSelected) {
358
+ // Deselect all
359
+ selected = [];
360
+ announce('All options deselected', 'polite');
361
+ } else {
362
+ // Select all non-disabled options (respecting maxSelections)
363
+ const selectableOptions = flatOptions.filter((o) => !o.disabled);
364
+ if (maxSelections !== undefined) {
365
+ selected = selectableOptions.slice(0, maxSelections);
366
+ if (selectableOptions.length > maxSelections) {
367
+ announce(`Selected first ${maxSelections} options (maximum reached)`, 'polite');
368
+ } else {
369
+ announce('All options selected', 'polite');
370
+ }
371
+ } else {
372
+ selected = selectableOptions;
373
+ announce('All options selected', 'polite');
374
+ }
375
+ }
376
+ onchange?.(selected);
377
+ }
378
+
379
+ function clearAll() {
380
+ if (disabled || readonly) return;
381
+ selected = [];
382
+ announce('All selections cleared', 'polite');
383
+ onchange?.(selected);
384
+ inputRef?.focus();
385
+ }
386
+
387
+ // ============================================================================
388
+ // Event Handlers
389
+ // ============================================================================
390
+
391
+ function handleContainerClick(e: MouseEvent) {
392
+ if (disabled || readonly) return;
393
+
394
+ // Don't open if clicking on chip remove button
395
+ const target = e.target as HTMLElement;
396
+ if (target.closest('.chip-remove')) return;
397
+
398
+ isOpen = true;
399
+ inputRef?.focus();
400
+ }
401
+
402
+ function handleInputFocus() {
403
+ isFocused = true;
404
+ focusedChipIndex = -1;
405
+ }
406
+
407
+ function handleInputBlur(e: FocusEvent) {
408
+ // Don't close if focus moves within the component
409
+ const relatedTarget = e.relatedTarget as HTMLElement | null;
410
+ if (relatedTarget && containerRef?.contains(relatedTarget)) {
411
+ return;
412
+ }
413
+
414
+ isFocused = false;
415
+ hasInteracted = true;
416
+ focusedChipIndex = -1;
417
+
418
+ // Delay closing to allow click events
419
+ setTimeout(() => {
420
+ if (!isFocused) {
421
+ isOpen = false;
422
+ activeIndex = -1;
423
+ searchQuery = '';
424
+ }
425
+ }, 150);
426
+ }
427
+
428
+ function handleSearchInput(e: Event) {
429
+ const target = e.target as HTMLInputElement;
430
+ searchQuery = target.value;
431
+ activeIndex = 0;
432
+ onsearch?.(searchQuery);
433
+ }
434
+
435
+ function handleKeyDown(e: KeyboardEvent) {
436
+ if (disabled || readonly) return;
437
+
438
+ // Handle chip navigation when not in dropdown
439
+ if (focusedChipIndex >= 0) {
440
+ handleChipKeyDown(e);
441
+ return;
442
+ }
443
+
444
+ switch (e.key) {
445
+ case Keys.ArrowDown:
446
+ e.preventDefault();
447
+ if (!isOpen) {
448
+ isOpen = true;
449
+ activeIndex = 0;
450
+ } else if (allEnabledOptions.length > 0) {
451
+ activeIndex = Math.min(activeIndex + 1, allEnabledOptions.length - 1);
452
+ const targetOption = allEnabledOptions[activeIndex];
453
+ const actualIndex = getOptionIndex(targetOption);
454
+ scrollToOption(actualIndex);
455
+ }
456
+ break;
457
+
458
+ case Keys.ArrowUp:
459
+ e.preventDefault();
460
+ if (isOpen && allEnabledOptions.length > 0) {
461
+ activeIndex = Math.max(activeIndex - 1, 0);
462
+ const targetOption = allEnabledOptions[activeIndex];
463
+ const actualIndex = getOptionIndex(targetOption);
464
+ scrollToOption(actualIndex);
465
+ }
466
+ break;
467
+
468
+ case Keys.ArrowLeft:
469
+ // Navigate to chips if at start of input
470
+ if (searchable && inputRef && inputRef instanceof HTMLInputElement && inputRef.selectionStart === 0 && selected.length > 0) {
471
+ e.preventDefault();
472
+ focusedChipIndex = selected.length - 1;
473
+ }
474
+ break;
475
+
476
+ case Keys.Home:
477
+ if (isOpen && allEnabledOptions.length > 0) {
478
+ e.preventDefault();
479
+ activeIndex = 0;
480
+ scrollToOption(getOptionIndex(allEnabledOptions[0]));
481
+ }
482
+ break;
483
+
484
+ case Keys.End:
485
+ if (isOpen && allEnabledOptions.length > 0) {
486
+ e.preventDefault();
487
+ activeIndex = allEnabledOptions.length - 1;
488
+ scrollToOption(getOptionIndex(allEnabledOptions[activeIndex]));
489
+ }
490
+ break;
491
+
492
+ case Keys.Enter:
493
+ if (isOpen && activeIndex >= 0 && activeIndex < allEnabledOptions.length) {
494
+ e.preventDefault();
495
+ toggleOption(allEnabledOptions[activeIndex]);
496
+ }
497
+ break;
498
+
499
+ case Keys.Space:
500
+ if (!searchable) {
501
+ e.preventDefault();
502
+ if (isOpen && activeIndex >= 0 && activeIndex < allEnabledOptions.length) {
503
+ toggleOption(allEnabledOptions[activeIndex]);
504
+ } else if (!isOpen) {
505
+ isOpen = true;
506
+ activeIndex = 0;
507
+ }
508
+ }
509
+ break;
510
+
511
+ case Keys.Escape:
512
+ if (isOpen) {
513
+ e.preventDefault();
514
+ isOpen = false;
515
+ activeIndex = -1;
516
+ searchQuery = '';
517
+ }
518
+ break;
519
+
520
+ case Keys.Backspace:
521
+ // Remove last chip if input is empty
522
+ if (searchable && searchQuery === '' && selected.length > 0) {
523
+ removeChip(selected[selected.length - 1]);
524
+ }
525
+ break;
526
+
527
+ case Keys.Tab:
528
+ // Close dropdown on tab
529
+ isOpen = false;
530
+ activeIndex = -1;
531
+ searchQuery = '';
532
+ break;
533
+ }
534
+ }
535
+
536
+ function handleChipKeyDown(e: KeyboardEvent) {
537
+ switch (e.key) {
538
+ case Keys.ArrowLeft:
539
+ e.preventDefault();
540
+ if (focusedChipIndex > 0) {
541
+ focusedChipIndex--;
542
+ }
543
+ break;
544
+
545
+ case Keys.ArrowRight:
546
+ e.preventDefault();
547
+ if (focusedChipIndex < selected.length - 1) {
548
+ focusedChipIndex++;
549
+ } else {
550
+ // Move focus back to input
551
+ focusedChipIndex = -1;
552
+ inputRef?.focus();
553
+ }
554
+ break;
555
+
556
+ case Keys.Backspace:
557
+ case Keys.Delete:
558
+ e.preventDefault();
559
+ const removedOption = selected[focusedChipIndex];
560
+ removeChip(removedOption);
561
+ // Adjust focus
562
+ if (selected.length === 0) {
563
+ focusedChipIndex = -1;
564
+ inputRef?.focus();
565
+ } else if (focusedChipIndex >= selected.length) {
566
+ focusedChipIndex = selected.length - 1;
567
+ }
568
+ break;
569
+
570
+ case Keys.Escape:
571
+ e.preventDefault();
572
+ focusedChipIndex = -1;
573
+ inputRef?.focus();
574
+ break;
575
+ }
576
+ }
577
+
578
+ function handleOptionClick(option: MultiSelectOption, e: MouseEvent) {
579
+ e.preventDefault();
580
+ e.stopPropagation();
581
+ toggleOption(option);
582
+ inputRef?.focus();
583
+ }
584
+
585
+ function handleOptionMouseEnter(index: number) {
586
+ const option = filteredFlatOptions[index];
587
+ if (!option?.disabled) {
588
+ const enabledIndex = allEnabledOptions.findIndex((o) => o.value === option.value);
589
+ if (enabledIndex >= 0) {
590
+ activeIndex = enabledIndex;
591
+ }
592
+ }
593
+ }
594
+
595
+ function handleToggleClick(e: MouseEvent) {
596
+ e.preventDefault();
597
+ e.stopPropagation();
598
+ if (!disabled && !readonly) {
599
+ isOpen = !isOpen;
600
+ if (isOpen) {
601
+ inputRef?.focus();
602
+ }
603
+ }
604
+ }
605
+
606
+ // ============================================================================
607
+ // Effects
608
+ // ============================================================================
609
+
610
+ // Focus chip when focusedChipIndex changes
611
+ $effect(() => {
612
+ if (focusedChipIndex >= 0 && containerRef) {
613
+ const chip = containerRef.querySelector(`[data-chip-index="${focusedChipIndex}"]`) as HTMLElement;
614
+ chip?.focus();
615
+ }
616
+ });
617
+ </script>
618
+
619
+ <div
620
+ class={cn(
621
+ 'multiselect-wrapper',
622
+ `multiselect-${size}`,
623
+ disabled && 'multiselect-disabled',
624
+ className
625
+ )}
626
+ >
627
+ <!-- Label -->
628
+ <label id={labelId} for={id} class={cn('multiselect-label', hideLabel && 'sr-only')}>
629
+ {label}
630
+ {#if required}
631
+ <span class="multiselect-required" aria-hidden="true">*</span>
632
+ {/if}
633
+ {#if hint && !hideLabel}
634
+ <span class="multiselect-hint-icon" title={hint}>
635
+ <Info size={14} />
636
+ </span>
637
+ {/if}
638
+ </label>
639
+
640
+ <!-- Main container -->
641
+ <div class="multiselect-container" bind:this={containerRef}>
642
+ <!-- Input area with chips -->
643
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
644
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
645
+ <div
646
+ class={cn(
647
+ 'multiselect-input-area',
648
+ isFocused && 'multiselect-input-area-focused',
649
+ showError && 'multiselect-input-area-error',
650
+ isValid && 'multiselect-input-area-valid',
651
+ disabled && 'multiselect-input-area-disabled'
652
+ )}
653
+ onclick={handleContainerClick}
654
+ >
655
+ <!-- Selected chips -->
656
+ <div class="multiselect-chips">
657
+ {#each selected as option, index (option.value)}
658
+ <span
659
+ class={cn('multiselect-chip', focusedChipIndex === index && 'multiselect-chip-focused')}
660
+ data-chip-index={index}
661
+ tabindex={focusedChipIndex === index ? 0 : -1}
662
+ role="button"
663
+ aria-label={`${option.label}, press Delete to remove`}
664
+ >
665
+ {#if chipContent}
666
+ {@render chipContent(option)}
667
+ {:else}
668
+ <span class="multiselect-chip-label">{option.label}</span>
669
+ {/if}
670
+ {#if !disabled && !readonly}
671
+ <button
672
+ type="button"
673
+ class="chip-remove"
674
+ onclick={(e) => removeChip(option, e)}
675
+ aria-label={`Remove ${option.label}`}
676
+ tabindex={-1}
677
+ >
678
+ <X size={size === 'sm' ? 12 : 14} />
679
+ </button>
680
+ {/if}
681
+ </span>
682
+ {/each}
683
+
684
+ <!-- Input / Placeholder -->
685
+ {#if searchable}
686
+ <input
687
+ bind:this={inputRef}
688
+ {id}
689
+ {name}
690
+ type="text"
691
+ role="combobox"
692
+ value={searchQuery}
693
+ placeholder={selected.length === 0 ? placeholder : searchPlaceholder}
694
+ disabled={disabled}
695
+ readonly={readonly}
696
+ aria-expanded={isOpen}
697
+ aria-controls={listboxId}
698
+ aria-haspopup="listbox"
699
+ aria-activedescendant={activeDescendantId}
700
+ aria-describedby={ariaDescribedBy}
701
+ aria-labelledby={labelId}
702
+ aria-required={required}
703
+ aria-invalid={showError}
704
+ data-testid={testId}
705
+ class="multiselect-search-input"
706
+ oninput={handleSearchInput}
707
+ onfocus={handleInputFocus}
708
+ onblur={handleInputBlur}
709
+ onkeydown={handleKeyDown}
710
+ />
711
+ {:else}
712
+ <button
713
+ bind:this={inputRef}
714
+ {id}
715
+ type="button"
716
+ role="combobox"
717
+ disabled={disabled}
718
+ aria-expanded={isOpen}
719
+ aria-controls={listboxId}
720
+ aria-haspopup="listbox"
721
+ aria-activedescendant={activeDescendantId}
722
+ aria-describedby={ariaDescribedBy}
723
+ aria-labelledby={labelId}
724
+ aria-required={required}
725
+ aria-invalid={showError}
726
+ data-testid={testId}
727
+ class="multiselect-trigger-button"
728
+ onfocus={handleInputFocus}
729
+ onblur={handleInputBlur}
730
+ onkeydown={handleKeyDown}
731
+ >
732
+ {#if selected.length === 0}
733
+ <span class="multiselect-placeholder">{placeholder}</span>
734
+ {/if}
735
+ </button>
736
+ {/if}
737
+ </div>
738
+
739
+ <!-- Right side controls -->
740
+ <div class="multiselect-controls">
741
+ {#if loading}
742
+ <span class="multiselect-loading" aria-hidden="true">
743
+ <Loader2 size={size === 'sm' ? 14 : 16} class="multiselect-spinner" />
744
+ </span>
745
+ {:else if showError}
746
+ <span class="multiselect-icon-error" aria-hidden="true">
747
+ <AlertCircle size={size === 'sm' ? 14 : 16} />
748
+ </span>
749
+ {:else if isValid}
750
+ <span class="multiselect-icon-success" aria-hidden="true">
751
+ <Check size={size === 'sm' ? 14 : 16} />
752
+ </span>
753
+ {/if}
754
+
755
+ {#if showCount && selected.length > 0}
756
+ <span class="multiselect-count" aria-label={`${selected.length} selected`}>
757
+ {selected.length}
758
+ </span>
759
+ {/if}
760
+
761
+ {#if selected.length > 0 && !disabled && !readonly}
762
+ <button
763
+ type="button"
764
+ class="multiselect-clear-button"
765
+ onclick={(e) => { e.stopPropagation(); clearAll(); }}
766
+ aria-label="Clear all selections"
767
+ tabindex={-1}
768
+ >
769
+ <X size={size === 'sm' ? 14 : 16} />
770
+ </button>
771
+ {/if}
772
+
773
+ <button
774
+ type="button"
775
+ class="multiselect-toggle-button"
776
+ onclick={handleToggleClick}
777
+ aria-label={isOpen ? 'Close options' : 'Open options'}
778
+ aria-expanded={isOpen}
779
+ tabindex={-1}
780
+ {disabled}
781
+ >
782
+ <ChevronDown
783
+ size={size === 'sm' ? 16 : 18}
784
+ class={cn('multiselect-chevron', isOpen && 'multiselect-chevron-open')}
785
+ />
786
+ </button>
787
+ </div>
788
+ </div>
789
+
790
+ <!-- Dropdown listbox -->
791
+ {#if isOpen}
792
+ <div
793
+ bind:this={listboxRef}
794
+ id={listboxId}
795
+ role="listbox"
796
+ aria-label={`${label} options`}
797
+ aria-multiselectable="true"
798
+ class="multiselect-listbox"
799
+ >
800
+ <!-- Search indicator if searchable -->
801
+ {#if searchable && searchQuery}
802
+ <div class="multiselect-search-indicator">
803
+ <Search size={14} />
804
+ <span>Showing results for "{searchQuery}"</span>
805
+ </div>
806
+ {/if}
807
+
808
+ <!-- Select All option -->
809
+ {#if showSelectAll && !searchQuery && flatOptions.length > 0}
810
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
811
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
812
+ <div
813
+ role="option"
814
+ aria-selected={allSelected}
815
+ class={cn('multiselect-option', 'multiselect-option-select-all')}
816
+ onclick={() => selectAll()}
817
+ >
818
+ <span class="multiselect-option-checkbox">
819
+ {#if allSelected}
820
+ <Check size={14} />
821
+ {/if}
822
+ </span>
823
+ <span class="multiselect-option-label">{selectAllLabel}</span>
824
+ <span class="multiselect-option-count">({flatOptions.filter(o => !o.disabled).length})</span>
825
+ </div>
826
+ <div class="multiselect-divider"></div>
827
+ {/if}
828
+
829
+ {#if loading}
830
+ <div class="multiselect-loading-state" role="status" aria-live="polite">
831
+ <Loader2 size={20} class="multiselect-spinner" />
832
+ <span>Loading options...</span>
833
+ </div>
834
+ {:else if showEmpty}
835
+ <div class="multiselect-empty" role="status" aria-live="polite">
836
+ {#if emptyContent}
837
+ {@render emptyContent()}
838
+ {:else}
839
+ <span>{emptyMessage}</span>
840
+ {/if}
841
+ </div>
842
+ {:else if isGrouped}
843
+ {#each filteredOptions as group, groupIndex}
844
+ {@const typedGroup = group as MultiSelectOptionGroup}
845
+ <div class="multiselect-group" role="group" aria-labelledby={`${id}-group-${groupIndex}`}>
846
+ <div id={`${id}-group-${groupIndex}`} class="multiselect-group-label">
847
+ {typedGroup.label}
848
+ </div>
849
+ {#each typedGroup.options as option}
850
+ {@const optionIndex = getOptionIndex(option)}
851
+ {@const enabledIndex = allEnabledOptions.findIndex((o) => o.value === option.value)}
852
+ {@const isActive = enabledIndex === activeIndex}
853
+ {@const selected = isSelected(option)}
854
+ {@const isDisabled = option.disabled || (!selected && atMaxSelections)}
855
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
856
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
857
+ <div
858
+ id={`${id}-option-${optionIndex}`}
859
+ role="option"
860
+ aria-selected={selected}
861
+ aria-disabled={isDisabled}
862
+ data-index={optionIndex}
863
+ class={cn(
864
+ 'multiselect-option',
865
+ isActive && 'multiselect-option-active',
866
+ selected && 'multiselect-option-selected',
867
+ isDisabled && 'multiselect-option-disabled'
868
+ )}
869
+ onclick={(e) => !isDisabled && handleOptionClick(option, e)}
870
+ onmouseenter={() => handleOptionMouseEnter(optionIndex)}
871
+ >
872
+ <span class="multiselect-option-checkbox">
873
+ {#if selected}
874
+ <Check size={14} />
875
+ {/if}
876
+ </span>
877
+ {#if optionContent}
878
+ {@render optionContent(option)}
879
+ {:else}
880
+ <div class="multiselect-option-content">
881
+ <span class="multiselect-option-label">{option.label}</span>
882
+ {#if option.description}
883
+ <span class="multiselect-option-description">{option.description}</span>
884
+ {/if}
885
+ </div>
886
+ {/if}
887
+ </div>
888
+ {/each}
889
+ </div>
890
+ {/each}
891
+ {:else}
892
+ {#each filteredFlatOptions as option, index}
893
+ {@const enabledIndex = allEnabledOptions.findIndex((o) => o.value === option.value)}
894
+ {@const isActive = enabledIndex === activeIndex}
895
+ {@const selected = isSelected(option)}
896
+ {@const isDisabled = option.disabled || (!selected && atMaxSelections)}
897
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
898
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
899
+ <div
900
+ id={`${id}-option-${index}`}
901
+ role="option"
902
+ aria-selected={selected}
903
+ aria-disabled={isDisabled}
904
+ data-index={index}
905
+ class={cn(
906
+ 'multiselect-option',
907
+ isActive && 'multiselect-option-active',
908
+ selected && 'multiselect-option-selected',
909
+ isDisabled && 'multiselect-option-disabled'
910
+ )}
911
+ onclick={(e) => !isDisabled && handleOptionClick(option, e)}
912
+ onmouseenter={() => handleOptionMouseEnter(index)}
913
+ >
914
+ <span class="multiselect-option-checkbox">
915
+ {#if selected}
916
+ <Check size={14} />
917
+ {/if}
918
+ </span>
919
+ {#if optionContent}
920
+ {@render optionContent(option)}
921
+ {:else}
922
+ <div class="multiselect-option-content">
923
+ <span class="multiselect-option-label">{option.label}</span>
924
+ {#if option.description}
925
+ <span class="multiselect-option-description">{option.description}</span>
926
+ {/if}
927
+ </div>
928
+ {/if}
929
+ </div>
930
+ {/each}
931
+ {/if}
932
+
933
+ {#if maxSelections !== undefined && !showEmpty}
934
+ <div class="multiselect-max-info">
935
+ {selected.length} / {maxSelections} selected
936
+ </div>
937
+ {/if}
938
+ </div>
939
+ {/if}
940
+ </div>
941
+
942
+ <!-- Screen reader announcements -->
943
+ {#if isOpen && !loading && filteredFlatOptions.length > 0}
944
+ <div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
945
+ {filteredFlatOptions.length} option{filteredFlatOptions.length === 1 ? '' : 's'} available,
946
+ {selected.length} selected
947
+ </div>
948
+ {/if}
949
+
950
+ <!-- Footer messages -->
951
+ <div class="multiselect-footer">
952
+ {#if showError}
953
+ <p id={errorId} class="multiselect-message multiselect-message-error" role="alert" aria-live="assertive">
954
+ {error}
955
+ </p>
956
+ {:else if showSuccess}
957
+ <p class="multiselect-message multiselect-message-success">
958
+ {successMessage}
959
+ </p>
960
+ {:else if hint && isFocused}
961
+ <p id={hintId} class="multiselect-message multiselect-message-hint">
962
+ {hint}
963
+ </p>
964
+ {/if}
965
+ </div>
966
+ </div>
967
+
968
+ <style>
969
+ /* ========================================
970
+ BASE WRAPPER
971
+ ======================================== */
972
+ .multiselect-wrapper {
973
+ display: flex;
974
+ flex-direction: column;
975
+ gap: 0.375rem;
976
+ position: relative;
977
+ }
978
+
979
+ .multiselect-disabled {
980
+ opacity: 0.6;
981
+ }
982
+
983
+ /* ========================================
984
+ LABEL
985
+ ======================================== */
986
+ .multiselect-label {
987
+ display: flex;
988
+ align-items: center;
989
+ gap: 0.375rem;
990
+ font-weight: 500;
991
+ color: var(--ui-text-primary);
992
+ }
993
+
994
+ .multiselect-sm .multiselect-label {
995
+ font-size: 0.75rem;
996
+ }
997
+
998
+ .multiselect-md .multiselect-label,
999
+ .multiselect-lg .multiselect-label {
1000
+ font-size: 0.875rem;
1001
+ }
1002
+
1003
+ .multiselect-required {
1004
+ color: rgb(var(--ui-color-error));
1005
+ }
1006
+
1007
+ :global([data-theme='dark']) .multiselect-required {
1008
+ color: rgb(var(--ui-color-error-light));
1009
+ }
1010
+
1011
+ @media (prefers-color-scheme: dark) {
1012
+ :global(:root:not([data-theme='light'])) .multiselect-required {
1013
+ color: rgb(var(--ui-color-error-light));
1014
+ }
1015
+ }
1016
+
1017
+ .multiselect-hint-icon {
1018
+ color: var(--ui-text-tertiary);
1019
+ cursor: help;
1020
+ }
1021
+
1022
+ /* ========================================
1023
+ CONTAINER
1024
+ ======================================== */
1025
+ .multiselect-container {
1026
+ position: relative;
1027
+ }
1028
+
1029
+ /* ========================================
1030
+ INPUT AREA
1031
+ ======================================== */
1032
+ .multiselect-input-area {
1033
+ display: flex;
1034
+ align-items: center;
1035
+ gap: 0.5rem;
1036
+ min-height: 2.5rem;
1037
+ padding: 0.375rem 0.5rem;
1038
+ border: 2px solid var(--ui-border-default);
1039
+ border-radius: 0.5rem;
1040
+ background: var(--ui-bg-secondary);
1041
+ cursor: pointer;
1042
+ transition: all 0.2s ease;
1043
+ }
1044
+
1045
+ .multiselect-sm .multiselect-input-area {
1046
+ min-height: 2rem;
1047
+ padding: 0.25rem 0.375rem;
1048
+ }
1049
+
1050
+ .multiselect-lg .multiselect-input-area {
1051
+ min-height: 3rem;
1052
+ padding: 0.5rem 0.625rem;
1053
+ }
1054
+
1055
+ .multiselect-input-area-focused {
1056
+ outline: none;
1057
+ background: var(--ui-bg-primary);
1058
+ border-color: rgb(var(--ui-color-primary));
1059
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary) / 0.4);
1060
+ }
1061
+
1062
+ :global([data-theme='dark']) .multiselect-input-area-focused {
1063
+ border-color: rgb(var(--ui-color-primary-light));
1064
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
1065
+ }
1066
+
1067
+ @media (prefers-color-scheme: dark) {
1068
+ :global(:root:not([data-theme='light'])) .multiselect-input-area-focused {
1069
+ border-color: rgb(var(--ui-color-primary-light));
1070
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
1071
+ }
1072
+ }
1073
+
1074
+ .multiselect-input-area-disabled {
1075
+ cursor: not-allowed;
1076
+ background: var(--ui-bg-tertiary);
1077
+ }
1078
+
1079
+ .multiselect-input-area-error {
1080
+ border-color: rgb(var(--ui-color-error));
1081
+ }
1082
+
1083
+ :global([data-theme='dark']) .multiselect-input-area-error {
1084
+ border-color: rgb(var(--ui-color-error-light));
1085
+ }
1086
+
1087
+ @media (prefers-color-scheme: dark) {
1088
+ :global(:root:not([data-theme='light'])) .multiselect-input-area-error {
1089
+ border-color: rgb(var(--ui-color-error-light));
1090
+ }
1091
+ }
1092
+
1093
+ .multiselect-input-area-valid {
1094
+ border-color: rgb(var(--ui-color-success));
1095
+ }
1096
+
1097
+ :global([data-theme='dark']) .multiselect-input-area-valid {
1098
+ border-color: rgb(var(--ui-color-success-light));
1099
+ }
1100
+
1101
+ @media (prefers-color-scheme: dark) {
1102
+ :global(:root:not([data-theme='light'])) .multiselect-input-area-valid {
1103
+ border-color: rgb(var(--ui-color-success-light));
1104
+ }
1105
+ }
1106
+
1107
+ /* ========================================
1108
+ CHIPS
1109
+ ======================================== */
1110
+ .multiselect-chips {
1111
+ display: flex;
1112
+ flex-wrap: wrap;
1113
+ gap: 0.375rem;
1114
+ flex: 1;
1115
+ min-width: 0;
1116
+ align-items: center;
1117
+ }
1118
+
1119
+ .multiselect-chip {
1120
+ display: inline-flex;
1121
+ align-items: center;
1122
+ gap: 0.25rem;
1123
+ padding: 0.125rem 0.375rem 0.125rem 0.5rem;
1124
+ background: rgb(var(--ui-color-primary) / 0.1);
1125
+ color: rgb(var(--ui-color-primary));
1126
+ border-radius: 9999px;
1127
+ font-size: 0.8125rem;
1128
+ font-weight: 500;
1129
+ max-width: 200px;
1130
+ transition: all 0.15s ease;
1131
+ }
1132
+
1133
+ :global([data-theme='dark']) .multiselect-chip {
1134
+ background: rgb(var(--ui-color-primary-light) / 0.15);
1135
+ color: rgb(var(--ui-color-primary-light));
1136
+ }
1137
+
1138
+ @media (prefers-color-scheme: dark) {
1139
+ :global(:root:not([data-theme='light'])) .multiselect-chip {
1140
+ background: rgb(var(--ui-color-primary-light) / 0.15);
1141
+ color: rgb(var(--ui-color-primary-light));
1142
+ }
1143
+ }
1144
+
1145
+ .multiselect-sm .multiselect-chip {
1146
+ font-size: 0.75rem;
1147
+ padding: 0.0625rem 0.25rem 0.0625rem 0.375rem;
1148
+ }
1149
+
1150
+ .multiselect-chip-focused {
1151
+ outline: 2px solid rgb(var(--ui-color-primary));
1152
+ outline-offset: 1px;
1153
+ }
1154
+
1155
+ :global([data-theme='dark']) .multiselect-chip-focused {
1156
+ outline-color: rgb(var(--ui-color-primary-light));
1157
+ }
1158
+
1159
+ @media (prefers-color-scheme: dark) {
1160
+ :global(:root:not([data-theme='light'])) .multiselect-chip-focused {
1161
+ outline-color: rgb(var(--ui-color-primary-light));
1162
+ }
1163
+ }
1164
+
1165
+ .multiselect-chip-label {
1166
+ overflow: hidden;
1167
+ text-overflow: ellipsis;
1168
+ white-space: nowrap;
1169
+ }
1170
+
1171
+ .chip-remove {
1172
+ display: flex;
1173
+ align-items: center;
1174
+ justify-content: center;
1175
+ padding: 0.125rem;
1176
+ background: transparent;
1177
+ border: none;
1178
+ color: inherit;
1179
+ cursor: pointer;
1180
+ border-radius: 50%;
1181
+ opacity: 0.7;
1182
+ transition: all 0.15s ease;
1183
+ }
1184
+
1185
+ .chip-remove:hover {
1186
+ opacity: 1;
1187
+ background: rgb(var(--ui-color-primary) / 0.2);
1188
+ }
1189
+
1190
+ :global([data-theme='dark']) .chip-remove:hover {
1191
+ background: rgb(var(--ui-color-primary-light) / 0.25);
1192
+ }
1193
+
1194
+ @media (prefers-color-scheme: dark) {
1195
+ :global(:root:not([data-theme='light'])) .chip-remove:hover {
1196
+ background: rgb(var(--ui-color-primary-light) / 0.25);
1197
+ }
1198
+ }
1199
+
1200
+ /* ========================================
1201
+ SEARCH INPUT
1202
+ ======================================== */
1203
+ .multiselect-search-input {
1204
+ flex: 1;
1205
+ min-width: 60px;
1206
+ border: none;
1207
+ background: transparent;
1208
+ color: var(--ui-text-primary);
1209
+ font-size: 0.875rem;
1210
+ outline: none;
1211
+ }
1212
+
1213
+ .multiselect-search-input::placeholder {
1214
+ color: var(--ui-text-tertiary);
1215
+ }
1216
+
1217
+ .multiselect-sm .multiselect-search-input {
1218
+ font-size: 0.8125rem;
1219
+ }
1220
+
1221
+ .multiselect-lg .multiselect-search-input {
1222
+ font-size: 1rem;
1223
+ }
1224
+
1225
+ .multiselect-trigger-button {
1226
+ flex: 1;
1227
+ min-width: 0;
1228
+ border: none;
1229
+ background: transparent;
1230
+ color: var(--ui-text-primary);
1231
+ font-size: 0.875rem;
1232
+ text-align: left;
1233
+ cursor: pointer;
1234
+ outline: none;
1235
+ }
1236
+
1237
+ .multiselect-placeholder {
1238
+ color: var(--ui-text-tertiary);
1239
+ }
1240
+
1241
+ /* ========================================
1242
+ CONTROLS
1243
+ ======================================== */
1244
+ .multiselect-controls {
1245
+ display: flex;
1246
+ align-items: center;
1247
+ gap: 0.25rem;
1248
+ flex-shrink: 0;
1249
+ }
1250
+
1251
+ .multiselect-loading,
1252
+ .multiselect-icon-error,
1253
+ .multiselect-icon-success {
1254
+ display: flex;
1255
+ align-items: center;
1256
+ justify-content: center;
1257
+ color: var(--ui-text-tertiary);
1258
+ }
1259
+
1260
+ .multiselect-icon-error {
1261
+ color: rgb(var(--ui-color-error));
1262
+ }
1263
+
1264
+ :global([data-theme='dark']) .multiselect-icon-error {
1265
+ color: rgb(var(--ui-color-error-light));
1266
+ }
1267
+
1268
+ @media (prefers-color-scheme: dark) {
1269
+ :global(:root:not([data-theme='light'])) .multiselect-icon-error {
1270
+ color: rgb(var(--ui-color-error-light));
1271
+ }
1272
+ }
1273
+
1274
+ .multiselect-icon-success {
1275
+ color: rgb(var(--ui-color-success));
1276
+ }
1277
+
1278
+ :global([data-theme='dark']) .multiselect-icon-success {
1279
+ color: rgb(var(--ui-color-success-light));
1280
+ }
1281
+
1282
+ @media (prefers-color-scheme: dark) {
1283
+ :global(:root:not([data-theme='light'])) .multiselect-icon-success {
1284
+ color: rgb(var(--ui-color-success-light));
1285
+ }
1286
+ }
1287
+
1288
+ .multiselect-spinner {
1289
+ animation: spin 1s linear infinite;
1290
+ }
1291
+
1292
+ @keyframes spin {
1293
+ from { transform: rotate(0deg); }
1294
+ to { transform: rotate(360deg); }
1295
+ }
1296
+
1297
+ @media (prefers-reduced-motion: reduce) {
1298
+ .multiselect-spinner {
1299
+ animation: none;
1300
+ }
1301
+ }
1302
+
1303
+ .multiselect-count {
1304
+ display: flex;
1305
+ align-items: center;
1306
+ justify-content: center;
1307
+ min-width: 1.25rem;
1308
+ height: 1.25rem;
1309
+ padding: 0 0.375rem;
1310
+ background: rgb(var(--ui-color-primary));
1311
+ color: white;
1312
+ font-size: 0.6875rem;
1313
+ font-weight: 600;
1314
+ border-radius: 9999px;
1315
+ }
1316
+
1317
+ :global([data-theme='dark']) .multiselect-count {
1318
+ background: rgb(var(--ui-color-primary-light));
1319
+ }
1320
+
1321
+ @media (prefers-color-scheme: dark) {
1322
+ :global(:root:not([data-theme='light'])) .multiselect-count {
1323
+ background: rgb(var(--ui-color-primary-light));
1324
+ }
1325
+ }
1326
+
1327
+ .multiselect-clear-button,
1328
+ .multiselect-toggle-button {
1329
+ display: flex;
1330
+ align-items: center;
1331
+ justify-content: center;
1332
+ padding: 0.25rem;
1333
+ border: none;
1334
+ background: transparent;
1335
+ color: var(--ui-text-tertiary);
1336
+ cursor: pointer;
1337
+ border-radius: 0.25rem;
1338
+ transition: all 0.15s ease;
1339
+ }
1340
+
1341
+ .multiselect-clear-button:hover,
1342
+ .multiselect-toggle-button:hover {
1343
+ color: var(--ui-text-primary);
1344
+ background: var(--ui-bg-tertiary);
1345
+ }
1346
+
1347
+ .multiselect-toggle-button:disabled {
1348
+ cursor: not-allowed;
1349
+ opacity: 0.5;
1350
+ }
1351
+
1352
+ .multiselect-chevron {
1353
+ transition: transform 0.2s ease;
1354
+ }
1355
+
1356
+ .multiselect-chevron-open {
1357
+ transform: rotate(180deg);
1358
+ }
1359
+
1360
+ /* ========================================
1361
+ LISTBOX DROPDOWN
1362
+ ======================================== */
1363
+ .multiselect-listbox {
1364
+ position: absolute;
1365
+ top: 100%;
1366
+ left: 0;
1367
+ right: 0;
1368
+ z-index: 50;
1369
+ margin-top: 0.25rem;
1370
+ max-height: 18rem;
1371
+ overflow-y: auto;
1372
+ background: var(--ui-bg-primary);
1373
+ border: 2px solid var(--ui-border-default);
1374
+ border-radius: 0.5rem;
1375
+ box-shadow: var(--ui-shadow-lg);
1376
+ animation: slideDown 0.15s ease-out;
1377
+ }
1378
+
1379
+ @keyframes slideDown {
1380
+ from {
1381
+ opacity: 0;
1382
+ transform: translateY(-4px);
1383
+ }
1384
+ to {
1385
+ opacity: 1;
1386
+ transform: translateY(0);
1387
+ }
1388
+ }
1389
+
1390
+ @media (prefers-reduced-motion: reduce) {
1391
+ .multiselect-listbox {
1392
+ animation: none;
1393
+ }
1394
+ }
1395
+
1396
+ /* ========================================
1397
+ SEARCH INDICATOR
1398
+ ======================================== */
1399
+ .multiselect-search-indicator {
1400
+ display: flex;
1401
+ align-items: center;
1402
+ gap: 0.5rem;
1403
+ padding: 0.5rem 0.75rem;
1404
+ font-size: 0.75rem;
1405
+ color: var(--ui-text-tertiary);
1406
+ background: var(--ui-bg-secondary);
1407
+ border-bottom: 1px solid var(--ui-border-default);
1408
+ }
1409
+
1410
+ /* ========================================
1411
+ OPTIONS
1412
+ ======================================== */
1413
+ .multiselect-option {
1414
+ display: flex;
1415
+ align-items: flex-start;
1416
+ gap: 0.5rem;
1417
+ padding: 0.625rem 0.75rem;
1418
+ cursor: pointer;
1419
+ transition: background-color 0.1s ease;
1420
+ }
1421
+
1422
+ .multiselect-option:hover,
1423
+ .multiselect-option-active {
1424
+ background: var(--ui-bg-secondary);
1425
+ }
1426
+
1427
+ .multiselect-option-selected {
1428
+ background: rgb(var(--ui-color-primary) / 0.08);
1429
+ }
1430
+
1431
+ :global([data-theme='dark']) .multiselect-option-selected {
1432
+ background: rgb(var(--ui-color-primary-light) / 0.12);
1433
+ }
1434
+
1435
+ @media (prefers-color-scheme: dark) {
1436
+ :global(:root:not([data-theme='light'])) .multiselect-option-selected {
1437
+ background: rgb(var(--ui-color-primary-light) / 0.12);
1438
+ }
1439
+ }
1440
+
1441
+ .multiselect-option-active.multiselect-option-selected {
1442
+ background: rgb(var(--ui-color-primary) / 0.12);
1443
+ }
1444
+
1445
+ :global([data-theme='dark']) .multiselect-option-active.multiselect-option-selected {
1446
+ background: rgb(var(--ui-color-primary-light) / 0.18);
1447
+ }
1448
+
1449
+ @media (prefers-color-scheme: dark) {
1450
+ :global(:root:not([data-theme='light'])) .multiselect-option-active.multiselect-option-selected {
1451
+ background: rgb(var(--ui-color-primary-light) / 0.18);
1452
+ }
1453
+ }
1454
+
1455
+ .multiselect-option-disabled {
1456
+ opacity: 0.5;
1457
+ cursor: not-allowed;
1458
+ }
1459
+
1460
+ .multiselect-option-disabled:hover {
1461
+ background: transparent;
1462
+ }
1463
+
1464
+ .multiselect-option-select-all {
1465
+ font-weight: 500;
1466
+ }
1467
+
1468
+ .multiselect-option-checkbox {
1469
+ display: flex;
1470
+ align-items: center;
1471
+ justify-content: center;
1472
+ width: 1.125rem;
1473
+ height: 1.125rem;
1474
+ border: 2px solid var(--ui-border-default);
1475
+ border-radius: 0.25rem;
1476
+ flex-shrink: 0;
1477
+ background: var(--ui-bg-primary);
1478
+ color: white;
1479
+ transition: all 0.15s ease;
1480
+ }
1481
+
1482
+ .multiselect-option-selected .multiselect-option-checkbox {
1483
+ background: rgb(var(--ui-color-primary));
1484
+ border-color: rgb(var(--ui-color-primary));
1485
+ }
1486
+
1487
+ :global([data-theme='dark']) .multiselect-option-selected .multiselect-option-checkbox {
1488
+ background: rgb(var(--ui-color-primary-light));
1489
+ border-color: rgb(var(--ui-color-primary-light));
1490
+ }
1491
+
1492
+ @media (prefers-color-scheme: dark) {
1493
+ :global(:root:not([data-theme='light'])) .multiselect-option-selected .multiselect-option-checkbox {
1494
+ background: rgb(var(--ui-color-primary-light));
1495
+ border-color: rgb(var(--ui-color-primary-light));
1496
+ }
1497
+ }
1498
+
1499
+ .multiselect-option-content {
1500
+ display: flex;
1501
+ flex-direction: column;
1502
+ gap: 0.125rem;
1503
+ min-width: 0;
1504
+ flex: 1;
1505
+ }
1506
+
1507
+ .multiselect-option-label {
1508
+ font-weight: 500;
1509
+ color: var(--ui-text-primary);
1510
+ white-space: nowrap;
1511
+ overflow: hidden;
1512
+ text-overflow: ellipsis;
1513
+ }
1514
+
1515
+ .multiselect-option-description {
1516
+ font-size: 0.75rem;
1517
+ color: var(--ui-text-tertiary);
1518
+ white-space: nowrap;
1519
+ overflow: hidden;
1520
+ text-overflow: ellipsis;
1521
+ }
1522
+
1523
+ .multiselect-option-count {
1524
+ font-size: 0.75rem;
1525
+ color: var(--ui-text-tertiary);
1526
+ margin-left: auto;
1527
+ }
1528
+
1529
+ /* ========================================
1530
+ GROUPS
1531
+ ======================================== */
1532
+ .multiselect-group {
1533
+ padding-top: 0.25rem;
1534
+ }
1535
+
1536
+ .multiselect-group:not(:first-child) {
1537
+ border-top: 1px solid var(--ui-border-default);
1538
+ margin-top: 0.25rem;
1539
+ }
1540
+
1541
+ .multiselect-group-label {
1542
+ padding: 0.5rem 0.75rem 0.25rem;
1543
+ font-size: 0.6875rem;
1544
+ font-weight: 600;
1545
+ text-transform: uppercase;
1546
+ letter-spacing: 0.05em;
1547
+ color: var(--ui-text-tertiary);
1548
+ }
1549
+
1550
+ /* ========================================
1551
+ DIVIDER
1552
+ ======================================== */
1553
+ .multiselect-divider {
1554
+ height: 1px;
1555
+ background: var(--ui-border-default);
1556
+ margin: 0.25rem 0;
1557
+ }
1558
+
1559
+ /* ========================================
1560
+ STATES
1561
+ ======================================== */
1562
+ .multiselect-loading-state,
1563
+ .multiselect-empty {
1564
+ display: flex;
1565
+ align-items: center;
1566
+ justify-content: center;
1567
+ gap: 0.5rem;
1568
+ padding: 1.5rem;
1569
+ color: var(--ui-text-tertiary);
1570
+ font-size: 0.875rem;
1571
+ }
1572
+
1573
+ .multiselect-max-info {
1574
+ padding: 0.5rem 0.75rem;
1575
+ font-size: 0.75rem;
1576
+ color: var(--ui-text-tertiary);
1577
+ text-align: center;
1578
+ background: var(--ui-bg-secondary);
1579
+ border-top: 1px solid var(--ui-border-default);
1580
+ }
1581
+
1582
+ /* ========================================
1583
+ FOOTER & MESSAGES
1584
+ ======================================== */
1585
+ .multiselect-footer {
1586
+ min-height: 1.25rem;
1587
+ }
1588
+
1589
+ .multiselect-message {
1590
+ font-size: 0.75rem;
1591
+ animation: slideIn 0.2s ease-out;
1592
+ }
1593
+
1594
+ @keyframes slideIn {
1595
+ from {
1596
+ opacity: 0;
1597
+ transform: translateY(-4px);
1598
+ }
1599
+ to {
1600
+ opacity: 1;
1601
+ transform: translateY(0);
1602
+ }
1603
+ }
1604
+
1605
+ .multiselect-message-error {
1606
+ color: rgb(var(--ui-color-error));
1607
+ }
1608
+
1609
+ :global([data-theme='dark']) .multiselect-message-error {
1610
+ color: rgb(var(--ui-color-error-light));
1611
+ }
1612
+
1613
+ @media (prefers-color-scheme: dark) {
1614
+ :global(:root:not([data-theme='light'])) .multiselect-message-error {
1615
+ color: rgb(var(--ui-color-error-light));
1616
+ }
1617
+ }
1618
+
1619
+ .multiselect-message-success {
1620
+ color: rgb(var(--ui-color-success));
1621
+ }
1622
+
1623
+ :global([data-theme='dark']) .multiselect-message-success {
1624
+ color: rgb(var(--ui-color-success-light));
1625
+ }
1626
+
1627
+ @media (prefers-color-scheme: dark) {
1628
+ :global(:root:not([data-theme='light'])) .multiselect-message-success {
1629
+ color: rgb(var(--ui-color-success-light));
1630
+ }
1631
+ }
1632
+
1633
+ .multiselect-message-hint {
1634
+ color: var(--ui-text-tertiary);
1635
+ }
1636
+
1637
+ /* ========================================
1638
+ UTILITIES
1639
+ ======================================== */
1640
+ .sr-only {
1641
+ position: absolute;
1642
+ width: 1px;
1643
+ height: 1px;
1644
+ padding: 0;
1645
+ margin: -1px;
1646
+ overflow: hidden;
1647
+ clip: rect(0, 0, 0, 0);
1648
+ white-space: nowrap;
1649
+ border: 0;
1650
+ }
1651
+ </style>