@salmexio/ui 0.2.0 → 0.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 (42) hide show
  1. package/dist/dialogs/ContextMenu/ContextMenu.svelte +521 -0
  2. package/dist/dialogs/ContextMenu/ContextMenu.svelte.d.ts +53 -0
  3. package/dist/dialogs/ContextMenu/ContextMenu.svelte.d.ts.map +1 -0
  4. package/dist/dialogs/ContextMenu/index.d.ts +3 -0
  5. package/dist/dialogs/ContextMenu/index.d.ts.map +1 -0
  6. package/dist/dialogs/ContextMenu/index.js +1 -0
  7. package/dist/dialogs/index.d.ts +2 -0
  8. package/dist/dialogs/index.d.ts.map +1 -1
  9. package/dist/dialogs/index.js +1 -0
  10. package/dist/feedback/Alert/Alert.svelte +1 -62
  11. package/dist/feedback/Alert/Alert.svelte.d.ts +1 -1
  12. package/dist/feedback/Alert/Alert.svelte.d.ts.map +1 -1
  13. package/dist/forms/Select/Select.svelte +883 -0
  14. package/dist/forms/Select/Select.svelte.d.ts +68 -0
  15. package/dist/forms/Select/Select.svelte.d.ts.map +1 -0
  16. package/dist/forms/Select/index.d.ts +3 -0
  17. package/dist/forms/Select/index.d.ts.map +1 -0
  18. package/dist/forms/Select/index.js +1 -0
  19. package/dist/forms/index.d.ts +2 -0
  20. package/dist/forms/index.d.ts.map +1 -1
  21. package/dist/forms/index.js +1 -0
  22. package/dist/layout/Card/Card.svelte +29 -169
  23. package/dist/layout/Card/Card.svelte.d.ts +3 -9
  24. package/dist/layout/Card/Card.svelte.d.ts.map +1 -1
  25. package/dist/navigation/CommandPalette/CommandPalette.svelte +574 -0
  26. package/dist/navigation/CommandPalette/CommandPalette.svelte.d.ts +47 -0
  27. package/dist/navigation/CommandPalette/CommandPalette.svelte.d.ts.map +1 -0
  28. package/dist/navigation/CommandPalette/index.d.ts +3 -0
  29. package/dist/navigation/CommandPalette/index.d.ts.map +1 -0
  30. package/dist/navigation/CommandPalette/index.js +1 -0
  31. package/dist/navigation/index.d.ts +2 -0
  32. package/dist/navigation/index.d.ts.map +1 -1
  33. package/dist/navigation/index.js +1 -0
  34. package/dist/primitives/Badge/Badge.svelte +45 -9
  35. package/dist/primitives/Badge/Badge.svelte.d.ts +0 -2
  36. package/dist/primitives/Badge/Badge.svelte.d.ts.map +1 -1
  37. package/dist/primitives/Button/Button.svelte +40 -14
  38. package/dist/primitives/Button/Button.svelte.d.ts +1 -1
  39. package/dist/primitives/Button/Button.svelte.d.ts.map +1 -1
  40. package/dist/styles/tokens.css +4 -4
  41. package/dist/windowing/Window/Window.svelte +3 -3
  42. package/package.json +1 -1
@@ -0,0 +1,883 @@
1
+ <!--
2
+ @component Select
3
+
4
+ Win2K × Basquiat — Dropdown select with sunken trigger field, raised panel,
5
+ keyboard-first navigation, type-ahead search, option groups, and multi-select.
6
+
7
+ Follows WAI-ARIA APG Select-Only Combobox pattern: DOM focus stays on
8
+ the trigger button; visual focus in the listbox is communicated via
9
+ aria-activedescendant. The dropdown is portaled to document.body via
10
+ $effect to escape transforms/overflow on ancestor elements.
11
+
12
+ @example
13
+ <Select
14
+ label="Country"
15
+ options={[{ value: 'us', label: 'United States' }, { value: 'uk', label: 'United Kingdom' }]}
16
+ bind:value={selectedCountry}
17
+ />
18
+ -->
19
+ <script lang="ts" module>
20
+ export interface SelectOption {
21
+ value: string;
22
+ label: string;
23
+ disabled?: boolean;
24
+ }
25
+
26
+ export interface SelectGroup {
27
+ label: string;
28
+ options: SelectOption[];
29
+ }
30
+ </script>
31
+
32
+ <script lang="ts">
33
+ import { cn } from '../../utils/cn.js';
34
+ import { Keys } from '../../utils/keyboard.js';
35
+ import { onMount, tick } from 'svelte';
36
+
37
+ type SelectSize = 'sm' | 'md' | 'lg';
38
+
39
+ interface Props {
40
+ /** Visible label (required for a11y) */
41
+ label: string;
42
+ /** Flat list of options */
43
+ options?: SelectOption[];
44
+ /** Grouped options (mutually exclusive with options) */
45
+ groups?: SelectGroup[];
46
+ /** Selected value (single mode) */
47
+ value?: string;
48
+ /** Selected values (multi mode) */
49
+ values?: string[];
50
+ /** Enable multi-select with checkboxes */
51
+ multiple?: boolean;
52
+ /** Placeholder when nothing selected */
53
+ placeholder?: string;
54
+ /** Error message */
55
+ error?: string;
56
+ /** Hint text */
57
+ hint?: string;
58
+ /** Size variant */
59
+ size?: SelectSize;
60
+ /** Disabled state */
61
+ disabled?: boolean;
62
+ /** Required field */
63
+ required?: boolean;
64
+ /** Hide label visually (still accessible) */
65
+ hideLabel?: boolean;
66
+ /** Additional CSS class */
67
+ class?: string;
68
+ /** Called when value changes (single mode) */
69
+ onchange?: (value: string) => void;
70
+ /** Called when values change (multi mode) */
71
+ onchangemulti?: (values: string[]) => void;
72
+ /** Test ID */
73
+ testId?: string;
74
+ }
75
+
76
+ let {
77
+ label,
78
+ options = [],
79
+ groups = [],
80
+ value = $bindable(''),
81
+ values = $bindable([]),
82
+ multiple = false,
83
+ placeholder = 'Select an option',
84
+ error = '',
85
+ hint = '',
86
+ size = 'md',
87
+ disabled = false,
88
+ required = false,
89
+ hideLabel = false,
90
+ class: className = '',
91
+ onchange,
92
+ onchangemulti,
93
+ testId
94
+ }: Props = $props();
95
+
96
+ const id = `select-${Math.random().toString(36).slice(2, 9)}`;
97
+ const listboxId = `${id}-listbox`;
98
+ const labelId = `${id}-label`;
99
+ const errorId = `${id}-error`;
100
+ const hintId = `${id}-hint`;
101
+
102
+ let isOpen = $state(false);
103
+ let activeIndex = $state(-1);
104
+ let typeAhead = $state('');
105
+ let typeAheadTimer: ReturnType<typeof setTimeout> | undefined;
106
+ let triggerEl = $state<HTMLButtonElement | null>(null);
107
+ let listboxEl = $state<HTMLDivElement | null>(null);
108
+ let hasInteracted = $state(false);
109
+
110
+ // Fixed-position coordinates for the dropdown
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
+ // Flatten all options for keyboard navigation
118
+ const flatOptions = $derived<SelectOption[]>(
119
+ groups.length > 0
120
+ ? groups.flatMap((g) => g.options)
121
+ : options
122
+ );
123
+
124
+ const enabledIndices = $derived(
125
+ flatOptions.map((o, i) => ({ i, disabled: o.disabled })).filter((x) => !x.disabled).map((x) => x.i)
126
+ );
127
+
128
+ const selectedOption = $derived(
129
+ multiple ? null : flatOptions.find((o) => o.value === value)
130
+ );
131
+
132
+ const selectedLabels = $derived(
133
+ multiple
134
+ ? flatOptions.filter((o) => values.includes(o.value)).map((o) => o.label)
135
+ : []
136
+ );
137
+
138
+ const displayText = $derived(
139
+ multiple
140
+ ? selectedLabels.length > 0
141
+ ? selectedLabels.length <= 2
142
+ ? selectedLabels.join(', ')
143
+ : `${selectedLabels.length} selected`
144
+ : placeholder
145
+ : selectedOption?.label ?? placeholder
146
+ );
147
+
148
+ const showError = $derived(hasInteracted && !!error);
149
+
150
+ const ariaDescribedBy = $derived(
151
+ [showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
152
+ );
153
+
154
+ function positionDropdown() {
155
+ if (!triggerEl) return;
156
+ const rect = triggerEl.getBoundingClientRect();
157
+ const viewportH = window.innerHeight;
158
+ const spaceBelow = viewportH - rect.bottom;
159
+ const spaceAbove = rect.top;
160
+ const maxH = 260;
161
+
162
+ // Prefer below; flip above if not enough room below but more room above
163
+ placeAbove = spaceBelow < Math.min(maxH, 150) && spaceAbove > spaceBelow;
164
+
165
+ panelLeft = rect.left;
166
+ panelWidth = rect.width;
167
+
168
+ if (placeAbove) {
169
+ panelMaxHeight = Math.min(maxH, spaceAbove - 8);
170
+ panelTop = rect.top - 2; // 2px gap
171
+ } else {
172
+ panelMaxHeight = Math.min(maxH, spaceBelow - 8);
173
+ panelTop = rect.bottom + 2; // 2px gap
174
+ }
175
+ }
176
+
177
+ function openDropdown() {
178
+ if (disabled) return;
179
+ isOpen = true;
180
+ // Set active to current value or first enabled
181
+ const currentIdx = flatOptions.findIndex((o) => o.value === value);
182
+ activeIndex = currentIdx >= 0 ? currentIdx : (enabledIndices[0] ?? -1);
183
+
184
+ positionDropdown();
185
+
186
+ // After render, scroll active option into view within the listbox
187
+ tick().then(() => scrollActiveIntoView());
188
+ }
189
+
190
+ function closeDropdown() {
191
+ isOpen = false;
192
+ activeIndex = -1;
193
+ }
194
+
195
+ function toggle() {
196
+ if (isOpen) closeDropdown();
197
+ else openDropdown();
198
+ }
199
+
200
+ function selectOption(opt: SelectOption) {
201
+ if (opt.disabled) return;
202
+ if (multiple) {
203
+ const next = values.includes(opt.value)
204
+ ? values.filter((v) => v !== opt.value)
205
+ : [...values, opt.value];
206
+ values = next;
207
+ onchangemulti?.(next);
208
+ } else {
209
+ value = opt.value;
210
+ onchange?.(opt.value);
211
+ closeDropdown();
212
+ }
213
+ hasInteracted = true;
214
+ // Focus stays on trigger per APG pattern
215
+ triggerEl?.focus();
216
+ }
217
+
218
+ function scrollActiveIntoView() {
219
+ if (!listboxEl || activeIndex < 0) return;
220
+ const active = listboxEl.querySelector(`[data-option-index="${activeIndex}"]`) as HTMLElement;
221
+ if (!active) return;
222
+ // Manual scroll math — only scrolls the listbox, never the page
223
+ const listTop = listboxEl.scrollTop;
224
+ const listHeight = listboxEl.clientHeight;
225
+ const elTop = active.offsetTop;
226
+ const elHeight = active.offsetHeight;
227
+ if (elTop < listTop) {
228
+ listboxEl.scrollTop = elTop;
229
+ } else if (elTop + elHeight > listTop + listHeight) {
230
+ listboxEl.scrollTop = elTop + elHeight - listHeight;
231
+ }
232
+ }
233
+
234
+ function navigateUp() {
235
+ const pos = enabledIndices.indexOf(activeIndex);
236
+ if (pos > 0) {
237
+ activeIndex = enabledIndices[pos - 1];
238
+ scrollActiveIntoView();
239
+ }
240
+ }
241
+
242
+ function navigateDown() {
243
+ const pos = enabledIndices.indexOf(activeIndex);
244
+ if (pos < enabledIndices.length - 1) {
245
+ activeIndex = enabledIndices[pos + 1];
246
+ scrollActiveIntoView();
247
+ }
248
+ }
249
+
250
+ function handleTypeAhead(char: string) {
251
+ typeAhead += char.toLowerCase();
252
+ clearTimeout(typeAheadTimer);
253
+ typeAheadTimer = setTimeout(() => (typeAhead = ''), 500);
254
+
255
+ // If all chars are the same, cycle through matches starting with that char
256
+ const allSame = typeAhead.split('').every((c) => c === typeAhead[0]);
257
+ if (allSame && typeAhead.length > 1) {
258
+ const singleChar = typeAhead[0];
259
+ const matches = enabledIndices.filter((i) =>
260
+ flatOptions[i].label.toLowerCase().startsWith(singleChar)
261
+ );
262
+ if (matches.length > 0) {
263
+ const currentMatchPos = matches.indexOf(activeIndex);
264
+ const nextPos = (currentMatchPos + 1) % matches.length;
265
+ activeIndex = matches[nextPos];
266
+ scrollActiveIntoView();
267
+ }
268
+ } else {
269
+ // Multi-char prefix search
270
+ const match = enabledIndices.find((i) =>
271
+ flatOptions[i].label.toLowerCase().startsWith(typeAhead)
272
+ );
273
+ if (match !== undefined) {
274
+ activeIndex = match;
275
+ scrollActiveIntoView();
276
+ }
277
+ }
278
+ }
279
+
280
+ // Single unified keydown handler on the trigger (DOM focus always stays here)
281
+ function handleKeydown(e: KeyboardEvent) {
282
+ if (!isOpen) {
283
+ // --- CLOSED STATE ---
284
+ switch (e.key) {
285
+ case Keys.Enter:
286
+ case Keys.Space:
287
+ case Keys.ArrowDown:
288
+ e.preventDefault();
289
+ openDropdown();
290
+ return;
291
+ case Keys.ArrowUp:
292
+ e.preventDefault();
293
+ openDropdown();
294
+ // Jump to last enabled
295
+ activeIndex = enabledIndices[enabledIndices.length - 1] ?? -1;
296
+ tick().then(() => scrollActiveIntoView());
297
+ return;
298
+ case Keys.Home:
299
+ e.preventDefault();
300
+ openDropdown();
301
+ activeIndex = enabledIndices[0] ?? -1;
302
+ tick().then(() => scrollActiveIntoView());
303
+ return;
304
+ case Keys.End:
305
+ e.preventDefault();
306
+ openDropdown();
307
+ activeIndex = enabledIndices[enabledIndices.length - 1] ?? -1;
308
+ tick().then(() => scrollActiveIntoView());
309
+ return;
310
+ default:
311
+ // Type-ahead opens the dropdown
312
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
313
+ e.preventDefault();
314
+ openDropdown();
315
+ handleTypeAhead(e.key);
316
+ }
317
+ return;
318
+ }
319
+ }
320
+
321
+ // --- OPEN STATE ---
322
+ switch (e.key) {
323
+ case Keys.ArrowDown:
324
+ e.preventDefault();
325
+ navigateDown();
326
+ break;
327
+ case Keys.ArrowUp:
328
+ e.preventDefault();
329
+ navigateUp();
330
+ break;
331
+ case Keys.Home:
332
+ e.preventDefault();
333
+ activeIndex = enabledIndices[0] ?? -1;
334
+ scrollActiveIntoView();
335
+ break;
336
+ case Keys.End:
337
+ e.preventDefault();
338
+ activeIndex = enabledIndices[enabledIndices.length - 1] ?? -1;
339
+ scrollActiveIntoView();
340
+ break;
341
+ case Keys.Enter:
342
+ case Keys.Space:
343
+ e.preventDefault();
344
+ if (activeIndex >= 0) selectOption(flatOptions[activeIndex]);
345
+ break;
346
+ case Keys.Escape:
347
+ e.preventDefault();
348
+ closeDropdown();
349
+ break;
350
+ case Keys.Tab:
351
+ // Per APG: Tab selects current option and closes
352
+ if (activeIndex >= 0 && !multiple) {
353
+ selectOption(flatOptions[activeIndex]);
354
+ } else {
355
+ closeDropdown();
356
+ }
357
+ break;
358
+ default:
359
+ // Type-ahead search while open
360
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
361
+ e.preventDefault();
362
+ handleTypeAhead(e.key);
363
+ }
364
+ }
365
+ }
366
+
367
+ function handleOptionMouseDown(e: MouseEvent, opt: SelectOption) {
368
+ // preventDefault keeps focus on trigger (crucial for the combobox pattern)
369
+ e.preventDefault();
370
+ selectOption(opt);
371
+ }
372
+
373
+ function handleOptionMouseEnter(index: number, opt: SelectOption) {
374
+ if (!opt.disabled) activeIndex = index;
375
+ }
376
+
377
+ function handleClickOutside(e: MouseEvent) {
378
+ const target = e.target as Node;
379
+ if (triggerEl?.contains(target)) return;
380
+ if (listboxEl?.contains(target)) return;
381
+ closeDropdown();
382
+ }
383
+
384
+ function getOptionId(index: number) {
385
+ return `${id}-option-${index}`;
386
+ }
387
+
388
+ function getGlobalIndex(groupIdx: number, optIdx: number): number {
389
+ let offset = 0;
390
+ for (let g = 0; g < groupIdx; g++) {
391
+ offset += groups[g].options.length;
392
+ }
393
+ return offset + optIdx;
394
+ }
395
+
396
+ // Reposition on scroll/resize when open
397
+ function handleReposition() {
398
+ if (isOpen) positionDropdown();
399
+ }
400
+
401
+ // Portal: move listbox DOM node to document.body to escape transform/overflow ancestors
402
+ $effect(() => {
403
+ if (listboxEl && isOpen) {
404
+ document.body.appendChild(listboxEl);
405
+ return () => {
406
+ // Svelte will clean up the node; if still in body, remove it
407
+ if (listboxEl?.parentNode === document.body) {
408
+ document.body.removeChild(listboxEl);
409
+ }
410
+ };
411
+ }
412
+ });
413
+
414
+ onMount(() => {
415
+ document.addEventListener('mousedown', handleClickOutside);
416
+ window.addEventListener('scroll', handleReposition, true);
417
+ window.addEventListener('resize', handleReposition);
418
+
419
+ return () => {
420
+ document.removeEventListener('mousedown', handleClickOutside);
421
+ window.removeEventListener('scroll', handleReposition, true);
422
+ window.removeEventListener('resize', handleReposition);
423
+ clearTimeout(typeAheadTimer);
424
+ // Clean up portaled node if still in body
425
+ if (listboxEl?.parentNode === document.body) {
426
+ document.body.removeChild(listboxEl);
427
+ }
428
+ };
429
+ });
430
+ </script>
431
+
432
+ <div
433
+ class={cn('salmex-select-wrapper', `salmex-select-${size}`, className)}
434
+ data-testid={testId}
435
+ >
436
+ <!-- Label -->
437
+ <label
438
+ id={labelId}
439
+ for={id}
440
+ class={cn('salmex-select-label', hideLabel && 'salmex-sr-only')}
441
+ >
442
+ {label}
443
+ {#if required}<span class="salmex-select-required" aria-hidden="true">*</span>{/if}
444
+ </label>
445
+
446
+ <!-- Trigger button — DOM focus always stays here -->
447
+ <button
448
+ bind:this={triggerEl}
449
+ {id}
450
+ type="button"
451
+ class={cn(
452
+ 'salmex-select-trigger',
453
+ isOpen && 'salmex-select-trigger-open',
454
+ showError && 'salmex-select-trigger-error',
455
+ disabled && 'salmex-select-trigger-disabled'
456
+ )}
457
+ role="combobox"
458
+ aria-expanded={isOpen}
459
+ aria-haspopup="listbox"
460
+ aria-controls={isOpen ? listboxId : undefined}
461
+ aria-activedescendant={isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
462
+ aria-labelledby={labelId}
463
+ aria-describedby={ariaDescribedBy}
464
+ aria-required={required}
465
+ aria-invalid={showError}
466
+ {disabled}
467
+ onclick={toggle}
468
+ onkeydown={handleKeydown}
469
+ >
470
+ <span class="salmex-select-value" class:salmex-select-placeholder={!selectedOption && selectedLabels.length === 0}>
471
+ {displayText}
472
+ </span>
473
+ <span class="salmex-select-chevron" aria-hidden="true">
474
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
475
+ <path d="M3 4.5L6 7.5L9 4.5" />
476
+ </svg>
477
+ </span>
478
+ </button>
479
+
480
+ <!-- Footer: error / hint -->
481
+ <div class="salmex-select-footer">
482
+ {#if showError}
483
+ <p id={errorId} class="salmex-select-error" role="alert" aria-live="assertive">{error}</p>
484
+ {:else if hint}
485
+ <p id={hintId} class="salmex-select-hint">{hint}</p>
486
+ {/if}
487
+ </div>
488
+ </div>
489
+
490
+ <!-- Dropdown panel — fixed positioning to escape overflow/stacking contexts -->
491
+ {#if isOpen}
492
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
493
+ <div
494
+ bind:this={listboxEl}
495
+ id={listboxId}
496
+ class="salmex-select-panel"
497
+ style="position:fixed;left:{panelLeft}px;{placeAbove ? `bottom:${window.innerHeight - panelTop}px` : `top:${panelTop}px`};width:{panelWidth}px;max-height:{panelMaxHeight}px;"
498
+ role="listbox"
499
+ aria-labelledby={labelId}
500
+ aria-multiselectable={multiple || undefined}
501
+ tabindex="-1"
502
+ onmousedown={(e) => e.preventDefault()}
503
+ >
504
+ {#if groups.length > 0}
505
+ {#each groups as group, gi}
506
+ <div role="group" aria-label={group.label}>
507
+ <div class="salmex-select-group-label">{group.label}</div>
508
+ {#each group.options as opt, oi}
509
+ {@const globalIdx = getGlobalIndex(gi, oi)}
510
+ {@const isActive = globalIdx === activeIndex}
511
+ {@const isSelected = multiple ? values.includes(opt.value) : opt.value === value}
512
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
513
+ <div
514
+ id={getOptionId(globalIdx)}
515
+ class={cn(
516
+ 'salmex-select-option',
517
+ isActive && 'salmex-select-option-active',
518
+ isSelected && 'salmex-select-option-selected',
519
+ opt.disabled && 'salmex-select-option-disabled'
520
+ )}
521
+ role="option"
522
+ tabindex="-1"
523
+ aria-selected={isSelected}
524
+ aria-disabled={opt.disabled || undefined}
525
+ data-option-index={globalIdx}
526
+ onmouseenter={() => handleOptionMouseEnter(globalIdx, opt)}
527
+ onmousedown={(e) => handleOptionMouseDown(e, opt)}
528
+ >
529
+ {#if multiple}
530
+ <span class="salmex-select-check" aria-hidden="true">
531
+ {#if isSelected}
532
+ <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>
533
+ {/if}
534
+ </span>
535
+ {/if}
536
+ <span class="salmex-select-option-label">{opt.label}</span>
537
+ {#if !multiple && isSelected}
538
+ <span class="salmex-select-checkmark" aria-hidden="true">
539
+ <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>
540
+ </span>
541
+ {/if}
542
+ </div>
543
+ {/each}
544
+ </div>
545
+ {/each}
546
+ {:else}
547
+ {#each flatOptions as opt, i}
548
+ {@const isActive = i === activeIndex}
549
+ {@const isSelected = multiple ? values.includes(opt.value) : opt.value === value}
550
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
551
+ <div
552
+ id={getOptionId(i)}
553
+ class={cn(
554
+ 'salmex-select-option',
555
+ isActive && 'salmex-select-option-active',
556
+ isSelected && 'salmex-select-option-selected',
557
+ opt.disabled && 'salmex-select-option-disabled'
558
+ )}
559
+ role="option"
560
+ tabindex="-1"
561
+ aria-selected={isSelected}
562
+ aria-disabled={opt.disabled || undefined}
563
+ data-option-index={i}
564
+ onmouseenter={() => handleOptionMouseEnter(i, opt)}
565
+ onmousedown={(e) => handleOptionMouseDown(e, opt)}
566
+ >
567
+ {#if multiple}
568
+ <span class="salmex-select-check" aria-hidden="true">
569
+ {#if isSelected}
570
+ <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>
571
+ {/if}
572
+ </span>
573
+ {/if}
574
+ <span class="salmex-select-option-label">{opt.label}</span>
575
+ {#if !multiple && isSelected}
576
+ <span class="salmex-select-checkmark" aria-hidden="true">
577
+ <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>
578
+ </span>
579
+ {/if}
580
+ </div>
581
+ {/each}
582
+ {/if}
583
+ </div>
584
+ {/if}
585
+
586
+ <style>
587
+ /* ========================================
588
+ WRAPPER
589
+ ======================================== */
590
+ .salmex-select-wrapper {
591
+ display: flex;
592
+ flex-direction: column;
593
+ gap: var(--salmex-space-1);
594
+ font-family: var(--salmex-font-system);
595
+ }
596
+
597
+ /* ========================================
598
+ LABEL
599
+ ======================================== */
600
+ .salmex-select-label {
601
+ font-size: var(--salmex-font-size-sm);
602
+ font-weight: 700;
603
+ text-transform: uppercase;
604
+ letter-spacing: 0.3px;
605
+ color: rgb(var(--salmex-text-primary));
606
+ }
607
+
608
+ .salmex-select-required {
609
+ color: rgb(var(--salmex-street-red));
610
+ margin-left: 2px;
611
+ }
612
+
613
+ .salmex-sr-only {
614
+ position: absolute;
615
+ width: 1px;
616
+ height: 1px;
617
+ padding: 0;
618
+ margin: -1px;
619
+ overflow: hidden;
620
+ clip: rect(0, 0, 0, 0);
621
+ white-space: nowrap;
622
+ border: 0;
623
+ }
624
+
625
+ /* ========================================
626
+ TRIGGER — Sunken field matching TextInput
627
+ ======================================== */
628
+ .salmex-select-trigger {
629
+ display: flex;
630
+ align-items: center;
631
+ justify-content: space-between;
632
+ gap: var(--salmex-space-2);
633
+ width: 100%;
634
+ border: 2px solid rgb(var(--salmex-border-dark));
635
+ background: rgb(var(--salmex-bg-primary));
636
+ color: rgb(var(--salmex-text-primary));
637
+ font-family: var(--salmex-font-system);
638
+ font-weight: 600;
639
+ cursor: pointer;
640
+ text-align: left;
641
+ transition: all var(--salmex-transition-fast);
642
+ /* Sunken inset — same as TextInput */
643
+ box-shadow:
644
+ inset 2px 2px 0 rgb(var(--salmex-button-shadow)),
645
+ inset -1px -1px 0 rgb(var(--salmex-button-highlight));
646
+ }
647
+
648
+ .salmex-select-trigger:hover:not(:disabled) {
649
+ border-color: rgb(var(--salmex-text-primary));
650
+ }
651
+
652
+ .salmex-select-trigger:focus-visible {
653
+ outline: none;
654
+ border-color: rgb(var(--salmex-text-primary));
655
+ box-shadow:
656
+ inset 2px 2px 0 rgb(var(--salmex-button-shadow)),
657
+ inset -1px -1px 0 rgb(var(--salmex-button-highlight)),
658
+ 0 0 0 2px rgb(var(--salmex-midnight-black)),
659
+ 0 0 0 5px rgb(var(--salmex-crown-yellow));
660
+ }
661
+
662
+ :global([data-theme='dark']) .salmex-select-trigger:focus-visible {
663
+ box-shadow:
664
+ inset 2px 2px 0 rgb(var(--salmex-button-shadow)),
665
+ inset -1px -1px 0 rgb(var(--salmex-button-highlight)),
666
+ 0 0 0 3px rgb(var(--salmex-crown-yellow));
667
+ }
668
+
669
+ .salmex-select-trigger-open {
670
+ border-color: rgb(var(--salmex-text-primary));
671
+ }
672
+
673
+ .salmex-select-trigger-error {
674
+ border-color: rgb(var(--salmex-street-red));
675
+ }
676
+
677
+ .salmex-select-trigger-disabled {
678
+ opacity: 0.5;
679
+ cursor: not-allowed;
680
+ filter: grayscale(0.5);
681
+ }
682
+
683
+ /* ========================================
684
+ SIZES
685
+ ======================================== */
686
+ .salmex-select-sm .salmex-select-trigger {
687
+ min-height: 28px;
688
+ padding: 0 var(--salmex-space-3);
689
+ font-size: var(--salmex-font-size-xs);
690
+ }
691
+
692
+ .salmex-select-md .salmex-select-trigger {
693
+ min-height: 36px;
694
+ padding: 0 var(--salmex-space-4);
695
+ font-size: var(--salmex-font-size-base);
696
+ }
697
+
698
+ .salmex-select-lg .salmex-select-trigger {
699
+ min-height: 44px;
700
+ padding: 0 var(--salmex-space-5);
701
+ font-size: var(--salmex-font-size-md);
702
+ }
703
+
704
+ /* ========================================
705
+ VALUE DISPLAY
706
+ ======================================== */
707
+ .salmex-select-value {
708
+ flex: 1;
709
+ overflow: hidden;
710
+ text-overflow: ellipsis;
711
+ white-space: nowrap;
712
+ }
713
+
714
+ .salmex-select-placeholder {
715
+ color: rgb(var(--salmex-text-disabled));
716
+ }
717
+
718
+ .salmex-select-chevron {
719
+ flex-shrink: 0;
720
+ display: flex;
721
+ align-items: center;
722
+ transition: transform var(--salmex-transition-fast);
723
+ color: rgb(var(--salmex-text-secondary));
724
+ }
725
+
726
+ .salmex-select-trigger-open .salmex-select-chevron {
727
+ transform: rotate(180deg);
728
+ }
729
+
730
+ /* ========================================
731
+ DROPDOWN PANEL — Raised, bold shadow, fixed position
732
+ ======================================== */
733
+ .salmex-select-panel {
734
+ z-index: var(--salmex-z-dropdown);
735
+ overflow-y: auto;
736
+ overflow-x: hidden;
737
+ background: rgb(var(--salmex-bg-primary));
738
+ border: 3px solid rgb(var(--salmex-border-dark));
739
+ box-shadow:
740
+ inset 1px 1px 0 rgb(var(--salmex-button-highlight)),
741
+ inset -1px -1px 0 rgb(var(--salmex-button-shadow)),
742
+ 5px 5px 0 rgb(0 0 0 / 0.35);
743
+ outline: none;
744
+ padding: var(--salmex-space-1) 0;
745
+ font-family: var(--salmex-font-system);
746
+ }
747
+
748
+ :global([data-theme='dark']) .salmex-select-panel {
749
+ box-shadow:
750
+ inset 1px 1px 0 rgb(var(--salmex-button-highlight)),
751
+ inset -1px -1px 0 rgb(var(--salmex-button-shadow)),
752
+ 5px 5px 0 rgb(0 0 0 / 0.7);
753
+ }
754
+
755
+ /* ========================================
756
+ OPTION
757
+ ======================================== */
758
+ .salmex-select-option {
759
+ display: flex;
760
+ align-items: center;
761
+ gap: var(--salmex-space-2);
762
+ padding: var(--salmex-space-2) var(--salmex-space-4);
763
+ font-size: var(--salmex-font-size-sm);
764
+ font-weight: 600;
765
+ color: rgb(var(--salmex-text-primary));
766
+ cursor: pointer;
767
+ user-select: none;
768
+ transition: background var(--salmex-transition-fast);
769
+ }
770
+
771
+ .salmex-select-option-active {
772
+ background: rgb(var(--salmex-electric-blue));
773
+ color: rgb(var(--salmex-chalk-white));
774
+ }
775
+
776
+ :global([data-theme='dark']) .salmex-select-option-active {
777
+ background: rgb(var(--salmex-primary-light));
778
+ color: rgb(var(--salmex-midnight-black));
779
+ }
780
+
781
+ .salmex-select-option-selected:not(.salmex-select-option-active) {
782
+ background: rgb(var(--salmex-electric-blue) / 0.1);
783
+ }
784
+
785
+ :global([data-theme='dark']) .salmex-select-option-selected:not(.salmex-select-option-active) {
786
+ background: rgb(var(--salmex-primary-light) / 0.15);
787
+ }
788
+
789
+ .salmex-select-option-disabled {
790
+ opacity: 0.4;
791
+ cursor: not-allowed;
792
+ }
793
+
794
+ .salmex-select-option-label {
795
+ flex: 1;
796
+ overflow: hidden;
797
+ text-overflow: ellipsis;
798
+ white-space: nowrap;
799
+ }
800
+
801
+ /* Checkmark for single select */
802
+ .salmex-select-checkmark {
803
+ flex-shrink: 0;
804
+ display: flex;
805
+ align-items: center;
806
+ color: rgb(var(--salmex-electric-blue));
807
+ }
808
+
809
+ .salmex-select-option-active .salmex-select-checkmark {
810
+ color: rgb(var(--salmex-chalk-white));
811
+ }
812
+
813
+ :global([data-theme='dark']) .salmex-select-option-active .salmex-select-checkmark {
814
+ color: rgb(var(--salmex-midnight-black));
815
+ }
816
+
817
+ /* Checkbox square for multi select */
818
+ .salmex-select-check {
819
+ flex-shrink: 0;
820
+ display: flex;
821
+ align-items: center;
822
+ justify-content: center;
823
+ width: 16px;
824
+ height: 16px;
825
+ border: 2px solid rgb(var(--salmex-border-dark));
826
+ background: rgb(var(--salmex-bg-primary));
827
+ box-shadow:
828
+ inset 1px 1px 0 rgb(var(--salmex-button-shadow)),
829
+ inset -1px -1px 0 rgb(var(--salmex-button-highlight));
830
+ }
831
+
832
+ .salmex-select-option-active .salmex-select-check {
833
+ border-color: rgb(var(--salmex-chalk-white));
834
+ }
835
+
836
+ :global([data-theme='dark']) .salmex-select-option-active .salmex-select-check {
837
+ border-color: rgb(var(--salmex-midnight-black));
838
+ }
839
+
840
+ /* ========================================
841
+ GROUP LABELS
842
+ ======================================== */
843
+ .salmex-select-group-label {
844
+ padding: var(--salmex-space-2) var(--salmex-space-4) var(--salmex-space-1);
845
+ font-size: var(--salmex-font-size-xs);
846
+ font-weight: 700;
847
+ text-transform: uppercase;
848
+ letter-spacing: 0.4px;
849
+ color: rgb(var(--salmex-text-secondary));
850
+ user-select: none;
851
+ }
852
+
853
+ /* ========================================
854
+ FOOTER — Error / Hint
855
+ ======================================== */
856
+ .salmex-select-footer {
857
+ min-height: 18px;
858
+ }
859
+
860
+ .salmex-select-error {
861
+ font-size: var(--salmex-font-size-xs);
862
+ font-weight: 600;
863
+ color: rgb(var(--salmex-street-red));
864
+ margin: 0;
865
+ }
866
+
867
+ .salmex-select-hint {
868
+ font-size: var(--salmex-font-size-xs);
869
+ color: rgb(var(--salmex-text-secondary));
870
+ margin: 0;
871
+ }
872
+
873
+ /* ========================================
874
+ REDUCED MOTION
875
+ ======================================== */
876
+ @media (prefers-reduced-motion: reduce) {
877
+ .salmex-select-trigger,
878
+ .salmex-select-option,
879
+ .salmex-select-chevron {
880
+ transition: none;
881
+ }
882
+ }
883
+ </style>