sh3-core 0.12.0 → 0.13.1

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 (88) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/actions/MenuButton.svelte +2 -1
  3. package/dist/actions/contextMenuModel.js +8 -0
  4. package/dist/actions/contextMenuModel.test.js +22 -2
  5. package/dist/actions/listeners.js +17 -6
  6. package/dist/actions/listeners.test.js +42 -2
  7. package/dist/api.d.ts +16 -0
  8. package/dist/api.js +14 -0
  9. package/dist/apps/lifecycle.js +3 -0
  10. package/dist/apps/lifecycle.test.js +45 -0
  11. package/dist/host.js +12 -0
  12. package/dist/navigation/back-stack.d.ts +29 -0
  13. package/dist/navigation/back-stack.js +87 -0
  14. package/dist/navigation/back-stack.test.d.ts +1 -0
  15. package/dist/navigation/back-stack.test.js +145 -0
  16. package/dist/navigation/index.d.ts +2 -0
  17. package/dist/navigation/index.js +6 -0
  18. package/dist/navigation/platform-web.d.ts +3 -0
  19. package/dist/navigation/platform-web.js +54 -0
  20. package/dist/navigation/platform-web.test.d.ts +1 -0
  21. package/dist/navigation/platform-web.test.js +96 -0
  22. package/dist/overlays/modal.js +7 -0
  23. package/dist/overlays/modal.test.js +35 -0
  24. package/dist/overlays/popup.js +7 -0
  25. package/dist/overlays/popup.test.js +33 -0
  26. package/dist/platform/index.d.ts +15 -0
  27. package/dist/platform/index.js +47 -0
  28. package/dist/primitives/base.css +17 -6
  29. package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
  30. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
  31. package/dist/primitives/widgets/Field.svelte +127 -0
  32. package/dist/primitives/widgets/Field.svelte.d.ts +20 -0
  33. package/dist/primitives/widgets/FilePicker.d.ts +3 -0
  34. package/dist/primitives/widgets/FilePicker.js +19 -0
  35. package/dist/primitives/widgets/FilePicker.svelte +82 -0
  36. package/dist/primitives/widgets/FilePicker.svelte.d.ts +14 -0
  37. package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
  38. package/dist/primitives/widgets/FilePicker.test.js +44 -0
  39. package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
  40. package/dist/primitives/widgets/IconToggleGroup.js +8 -0
  41. package/dist/primitives/widgets/IconToggleGroup.svelte +89 -0
  42. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +17 -0
  43. package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
  44. package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
  45. package/dist/primitives/widgets/NumberInput.d.ts +6 -0
  46. package/dist/primitives/widgets/NumberInput.js +19 -0
  47. package/dist/primitives/widgets/NumberInput.svelte +176 -0
  48. package/dist/primitives/widgets/NumberInput.svelte.d.ts +18 -0
  49. package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
  50. package/dist/primitives/widgets/NumberInput.test.js +28 -0
  51. package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
  52. package/dist/primitives/widgets/RangeSlider.js +7 -0
  53. package/dist/primitives/widgets/RangeSlider.svelte +128 -0
  54. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +14 -0
  55. package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
  56. package/dist/primitives/widgets/RangeSlider.test.js +14 -0
  57. package/dist/primitives/widgets/Segmented.d.ts +9 -0
  58. package/dist/primitives/widgets/Segmented.js +28 -0
  59. package/dist/primitives/widgets/Segmented.svelte +89 -0
  60. package/dist/primitives/widgets/Segmented.svelte.d.ts +11 -0
  61. package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
  62. package/dist/primitives/widgets/Segmented.test.js +24 -0
  63. package/dist/primitives/widgets/Select.d.ts +11 -0
  64. package/dist/primitives/widgets/Select.js +42 -0
  65. package/dist/primitives/widgets/Select.svelte +167 -0
  66. package/dist/primitives/widgets/Select.svelte.d.ts +15 -0
  67. package/dist/primitives/widgets/Select.test.d.ts +1 -0
  68. package/dist/primitives/widgets/Select.test.js +68 -0
  69. package/dist/primitives/widgets/Slider.d.ts +6 -0
  70. package/dist/primitives/widgets/Slider.js +19 -0
  71. package/dist/primitives/widgets/Slider.svelte +208 -0
  72. package/dist/primitives/widgets/Slider.svelte.d.ts +16 -0
  73. package/dist/primitives/widgets/Slider.test.d.ts +1 -0
  74. package/dist/primitives/widgets/Slider.test.js +31 -0
  75. package/dist/primitives/widgets/SliderGroup.svelte +61 -0
  76. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +19 -0
  77. package/dist/primitives/widgets/Textarea.svelte +84 -0
  78. package/dist/primitives/widgets/Textarea.svelte.d.ts +17 -0
  79. package/dist/primitives/widgets/_select-listbox.svelte +228 -0
  80. package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
  81. package/dist/shell-shard/Terminal.svelte +1 -4
  82. package/dist/shell-shard/verbs/index.js +2 -0
  83. package/dist/shell-shard/verbs/reset.d.ts +2 -0
  84. package/dist/shell-shard/verbs/reset.js +26 -0
  85. package/dist/tokens.css +34 -0
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/package.json +1 -1
@@ -0,0 +1,208 @@
1
+ <script lang="ts">
2
+ import { valueToPercent } from './Slider';
3
+
4
+ let {
5
+ value = $bindable(0),
6
+ min = 0,
7
+ max = 100,
8
+ step = 1,
9
+ ticks,
10
+ showValue = false,
11
+ disabled = false,
12
+ invalid = false,
13
+ size = 'md',
14
+ orientation = 'horizontal',
15
+ onchange,
16
+ }: {
17
+ value?: number;
18
+ min?: number;
19
+ max?: number;
20
+ step?: number;
21
+ ticks?: number[];
22
+ showValue?: boolean;
23
+ disabled?: boolean;
24
+ invalid?: boolean;
25
+ size?: 'sm' | 'md';
26
+ orientation?: 'horizontal' | 'vertical';
27
+ onchange?: (next: number) => void;
28
+ } = $props();
29
+
30
+ const pct = $derived(valueToPercent(value, min, max));
31
+ </script>
32
+
33
+ <div class="sh3-slider sh3-slider--{orientation}"
34
+ class:sh3-slider--sm={size === 'sm'}
35
+ class:sh3-slider--invalid={invalid}>
36
+ <div class="sh3-slider__track">
37
+ <div class="sh3-slider__fill" style:--pct="{pct}%"></div>
38
+ {#if ticks}
39
+ {#each ticks as t}
40
+ <span class="sh3-slider__tick" style:--tick-pct="{valueToPercent(t, min, max)}%"></span>
41
+ {/each}
42
+ {/if}
43
+ </div>
44
+ <input
45
+ type="range"
46
+ class="sh3-slider__native"
47
+ {min}
48
+ {max}
49
+ {step}
50
+ {disabled}
51
+ aria-invalid={invalid || undefined}
52
+ bind:value
53
+ onchange={() => onchange?.(value)}
54
+ />
55
+ {#if showValue}
56
+ <output class="sh3-slider__value" style:--pct="{pct}%">{value}</output>
57
+ {/if}
58
+ </div>
59
+
60
+ <style>
61
+ .sh3-slider {
62
+ --thumb-size: 14px;
63
+ position: relative;
64
+ display: inline-block;
65
+ }
66
+ .sh3-slider--sm { --thumb-size: 12px; }
67
+ .sh3-slider--horizontal { width: 200px; height: var(--thumb-size); }
68
+ .sh3-slider--vertical { width: var(--thumb-size); height: 200px; }
69
+
70
+ .sh3-slider__track {
71
+ position: absolute;
72
+ background: var(--shell-track-bg);
73
+ border: 1px solid var(--shell-track-border);
74
+ border-radius: var(--shell-widget-radius-pill);
75
+ pointer-events: none;
76
+ }
77
+ .sh3-slider--horizontal .sh3-slider__track {
78
+ top: 50%; left: 0; right: 0;
79
+ height: 4px;
80
+ transform: translateY(-50%);
81
+ }
82
+ .sh3-slider--vertical .sh3-slider__track {
83
+ left: 50%; top: 0; bottom: 0;
84
+ width: 4px;
85
+ transform: translateX(-50%);
86
+ }
87
+
88
+ .sh3-slider__fill {
89
+ position: absolute;
90
+ background: var(--shell-track-fill);
91
+ border-radius: inherit;
92
+ }
93
+ .sh3-slider--horizontal .sh3-slider__fill {
94
+ left: 0; top: 0; bottom: 0;
95
+ width: var(--pct);
96
+ }
97
+ .sh3-slider--vertical .sh3-slider__fill {
98
+ bottom: 0; left: 0; right: 0;
99
+ height: var(--pct);
100
+ }
101
+
102
+ .sh3-slider__tick {
103
+ position: absolute;
104
+ background: var(--shell-tick-bg);
105
+ }
106
+ .sh3-slider--horizontal .sh3-slider__tick {
107
+ width: 1px; height: 8px; top: 50%;
108
+ transform: translate(-50%, -50%);
109
+ left: var(--tick-pct);
110
+ }
111
+ .sh3-slider--vertical .sh3-slider__tick {
112
+ width: 8px; height: 1px; left: 50%;
113
+ transform: translate(-50%, 50%);
114
+ bottom: var(--tick-pct);
115
+ }
116
+
117
+ .sh3-slider__native {
118
+ position: absolute;
119
+ inset: 0;
120
+ width: 100%;
121
+ height: 100%;
122
+ margin: 0;
123
+ background: transparent;
124
+ -webkit-appearance: none;
125
+ appearance: none;
126
+ cursor: pointer;
127
+ }
128
+ .sh3-slider--vertical .sh3-slider__native {
129
+ writing-mode: vertical-lr;
130
+ direction: rtl;
131
+ }
132
+ /* Browser default applies opacity:0.5 to <input>:disabled at the
133
+ element level; that composites onto the thumb regardless of any
134
+ pseudo-element opacity overrides. Pin to 1 here so the disabled
135
+ thumb stays opaque and the muted-token recolor is what shows. */
136
+ .sh3-slider__native:disabled {
137
+ cursor: not-allowed;
138
+ opacity: 1;
139
+ }
140
+ .sh3-slider--invalid .sh3-slider__track { border-color: var(--shell-error); }
141
+
142
+ /* Disabled state: shift the fill + thumb to muted/border tokens
143
+ instead of using opacity, so the thumb stays opaque (no see-through). */
144
+ .sh3-slider:has(.sh3-slider__native:disabled) .sh3-slider__fill {
145
+ background: var(--shell-border-strong);
146
+ }
147
+
148
+ .sh3-slider__native::-webkit-slider-thumb {
149
+ -webkit-appearance: none;
150
+ width: var(--thumb-size);
151
+ height: var(--thumb-size);
152
+ border-radius: 50%;
153
+ background: var(--shell-thumb-bg);
154
+ border: 2px solid var(--shell-thumb-border);
155
+ box-shadow: var(--shell-thumb-shadow);
156
+ cursor: pointer;
157
+ opacity: 1;
158
+ }
159
+ .sh3-slider__native::-moz-range-thumb {
160
+ width: var(--thumb-size);
161
+ height: var(--thumb-size);
162
+ border-radius: 50%;
163
+ background: var(--shell-thumb-bg);
164
+ border: 2px solid var(--shell-thumb-border);
165
+ box-shadow: var(--shell-thumb-shadow);
166
+ cursor: pointer;
167
+ opacity: 1;
168
+ }
169
+ .sh3-slider__native:disabled::-webkit-slider-thumb {
170
+ background: var(--shell-fg-subtle);
171
+ border-color: var(--shell-border-strong);
172
+ cursor: not-allowed;
173
+ opacity: 1;
174
+ }
175
+ .sh3-slider__native:disabled::-moz-range-thumb {
176
+ background: var(--shell-fg-subtle);
177
+ border-color: var(--shell-border-strong);
178
+ cursor: not-allowed;
179
+ opacity: 1;
180
+ }
181
+ .sh3-slider__native::-webkit-slider-runnable-track,
182
+ .sh3-slider__native::-moz-range-track {
183
+ background: transparent;
184
+ }
185
+ .sh3-slider__native:focus-visible::-webkit-slider-thumb {
186
+ box-shadow: var(--shell-focus-ring), var(--shell-thumb-shadow);
187
+ }
188
+ .sh3-slider__native:focus-visible::-moz-range-thumb {
189
+ box-shadow: var(--shell-focus-ring), var(--shell-thumb-shadow);
190
+ }
191
+
192
+ .sh3-slider__value {
193
+ position: absolute;
194
+ font-size: 0.75rem;
195
+ color: var(--shell-fg-muted);
196
+ pointer-events: none;
197
+ transform: translate(-50%, 0);
198
+ }
199
+ .sh3-slider--horizontal .sh3-slider__value {
200
+ top: calc(100% + 4px);
201
+ left: var(--pct);
202
+ }
203
+ .sh3-slider--vertical .sh3-slider__value {
204
+ left: calc(100% + 6px);
205
+ bottom: var(--pct);
206
+ transform: translate(0, 50%);
207
+ }
208
+ </style>
@@ -0,0 +1,16 @@
1
+ type $$ComponentProps = {
2
+ value?: number;
3
+ min?: number;
4
+ max?: number;
5
+ step?: number;
6
+ ticks?: number[];
7
+ showValue?: boolean;
8
+ disabled?: boolean;
9
+ invalid?: boolean;
10
+ size?: 'sm' | 'md';
11
+ orientation?: 'horizontal' | 'vertical';
12
+ onchange?: (next: number) => void;
13
+ };
14
+ declare const Slider: import("svelte").Component<$$ComponentProps, {}, "value">;
15
+ type Slider = ReturnType<typeof Slider>;
16
+ export default Slider;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { valueToPercent, percentToValue, snapToStep } from './Slider';
3
+ describe('Slider value-position math', () => {
4
+ it('valueToPercent at min', () => {
5
+ expect(valueToPercent(0, 0, 100)).toBe(0);
6
+ });
7
+ it('valueToPercent at max', () => {
8
+ expect(valueToPercent(100, 0, 100)).toBe(100);
9
+ });
10
+ it('valueToPercent in middle', () => {
11
+ expect(valueToPercent(50, 0, 100)).toBe(50);
12
+ });
13
+ it('valueToPercent with negative range', () => {
14
+ expect(valueToPercent(0, -10, 10)).toBe(50);
15
+ });
16
+ it('percentToValue at 50%', () => {
17
+ expect(percentToValue(50, 0, 100)).toBe(50);
18
+ });
19
+ it('percentToValue at 0%', () => {
20
+ expect(percentToValue(0, -10, 10)).toBe(-10);
21
+ });
22
+ it('snapToStep snaps to grid', () => {
23
+ expect(snapToStep(2.7, 0, 1)).toBe(3);
24
+ expect(snapToStep(2.3, 0, 1)).toBe(2);
25
+ expect(snapToStep(2.7, 0, 0.5)).toBe(2.5);
26
+ });
27
+ it('snapToStep clamps to min/max', () => {
28
+ expect(snapToStep(-5, 0, 1, { min: 0, max: 10 })).toBe(0);
29
+ expect(snapToStep(15, 0, 1, { min: 0, max: 10 })).toBe(10);
30
+ });
31
+ });
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import Slider from './Slider.svelte';
3
+
4
+ type Channel = { id: string; label: string; min?: number; max?: number; step?: number };
5
+
6
+ let {
7
+ orientation = 'horizontal',
8
+ channels,
9
+ values = $bindable<Record<string, number>>({}),
10
+ showValues = false,
11
+ disabled = false,
12
+ size = 'md',
13
+ onchange,
14
+ }: {
15
+ orientation?: 'horizontal' | 'vertical';
16
+ channels: Channel[];
17
+ values?: Record<string, number>;
18
+ showValues?: boolean;
19
+ disabled?: boolean;
20
+ size?: 'sm' | 'md';
21
+ onchange?: (next: Record<string, number>) => void;
22
+ } = $props();
23
+ </script>
24
+
25
+ <div class="sh3-sg sh3-sg--{orientation}">
26
+ {#each channels as ch (ch.id)}
27
+ <div class="sh3-sg__channel">
28
+ <span class="sh3-sg__label">{ch.label}</span>
29
+ <Slider
30
+ {orientation}
31
+ min={ch.min ?? 0}
32
+ max={ch.max ?? 100}
33
+ step={ch.step ?? 1}
34
+ {disabled}
35
+ {size}
36
+ showValue={showValues}
37
+ bind:value={
38
+ () => values[ch.id] ?? 0,
39
+ (n: number) => values = { ...values, [ch.id]: n }
40
+ }
41
+ onchange={() => onchange?.(values)}
42
+ />
43
+ </div>
44
+ {/each}
45
+ </div>
46
+
47
+ <style>
48
+ .sh3-sg { display: inline-flex; gap: var(--shell-pad-md); }
49
+ .sh3-sg--vertical { flex-direction: row; align-items: end; }
50
+ .sh3-sg--horizontal { flex-direction: column; }
51
+ .sh3-sg__channel {
52
+ display: flex; gap: 4px; align-items: center;
53
+ }
54
+ .sh3-sg--vertical .sh3-sg__channel { flex-direction: column-reverse; align-items: center; }
55
+ .sh3-sg__label {
56
+ font-size: 0.75rem;
57
+ color: var(--shell-fg-muted);
58
+ min-width: 60px;
59
+ }
60
+ .sh3-sg--vertical .sh3-sg__label { min-width: 0; text-align: center; }
61
+ </style>
@@ -0,0 +1,19 @@
1
+ type Channel = {
2
+ id: string;
3
+ label: string;
4
+ min?: number;
5
+ max?: number;
6
+ step?: number;
7
+ };
8
+ type $$ComponentProps = {
9
+ orientation?: 'horizontal' | 'vertical';
10
+ channels: Channel[];
11
+ values?: Record<string, number>;
12
+ showValues?: boolean;
13
+ disabled?: boolean;
14
+ size?: 'sm' | 'md';
15
+ onchange?: (next: Record<string, number>) => void;
16
+ };
17
+ declare const SliderGroup: import("svelte").Component<$$ComponentProps, {}, "values">;
18
+ type SliderGroup = ReturnType<typeof SliderGroup>;
19
+ export default SliderGroup;
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ let {
3
+ value = $bindable(''),
4
+ label,
5
+ placeholder,
6
+ helper,
7
+ error,
8
+ disabled = false,
9
+ invalid = false,
10
+ size = 'md',
11
+ required = false,
12
+ rows = 3,
13
+ resize = 'vertical',
14
+ onchange,
15
+ }: {
16
+ value?: string;
17
+ label?: string;
18
+ placeholder?: string;
19
+ helper?: string;
20
+ error?: string;
21
+ disabled?: boolean;
22
+ invalid?: boolean;
23
+ size?: 'sm' | 'md';
24
+ required?: boolean;
25
+ rows?: number;
26
+ resize?: 'none' | 'vertical' | 'both';
27
+ onchange?: (next: string) => void;
28
+ } = $props();
29
+
30
+ const showError = $derived(invalid && !!error);
31
+ const helperText = $derived(showError ? error : helper);
32
+ </script>
33
+
34
+ <label class="sh3-textarea" class:sh3-textarea--invalid={invalid} class:sh3-textarea--sm={size === 'sm'}>
35
+ {#if label}<span class="sh3-textarea__label">{label}{#if required}<span aria-hidden="true"> *</span>{/if}</span>{/if}
36
+ <textarea
37
+ class="sh3-textarea__input"
38
+ style:resize
39
+ {placeholder}
40
+ {disabled}
41
+ {required}
42
+ {rows}
43
+ aria-invalid={invalid || undefined}
44
+ bind:value
45
+ onblur={() => onchange?.(value)}
46
+ ></textarea>
47
+ {#if helperText}<span class="sh3-textarea__helper" class:sh3-textarea__helper--error={showError}>{helperText}</span>{/if}
48
+ </label>
49
+
50
+ <style>
51
+ .sh3-textarea {
52
+ display: inline-flex;
53
+ flex-direction: column;
54
+ gap: 4px;
55
+ font-family: var(--shell-font-ui);
56
+ font-size: 0.8125rem;
57
+ }
58
+ .sh3-textarea__label {
59
+ color: var(--shell-fg-muted);
60
+ font-size: 0.75rem;
61
+ }
62
+ .sh3-textarea__input {
63
+ background: var(--shell-input-bg);
64
+ color: var(--shell-fg);
65
+ border: 1px solid var(--shell-border);
66
+ border-radius: var(--shell-widget-radius);
67
+ padding: var(--shell-pad-sm) var(--shell-field-pad-x);
68
+ font: inherit;
69
+ outline: none;
70
+ transition: border-color var(--shell-motion-fast) var(--shell-ease-standard);
71
+ }
72
+ .sh3-textarea__input:focus {
73
+ border-color: var(--shell-input-border-focus);
74
+ box-shadow: var(--shell-focus-ring);
75
+ }
76
+ .sh3-textarea--invalid .sh3-textarea__input {
77
+ border-color: var(--shell-error);
78
+ }
79
+ .sh3-textarea__helper {
80
+ color: var(--shell-fg-muted);
81
+ font-size: 0.75rem;
82
+ }
83
+ .sh3-textarea__helper--error { color: var(--shell-error); }
84
+ </style>
@@ -0,0 +1,17 @@
1
+ type $$ComponentProps = {
2
+ value?: string;
3
+ label?: string;
4
+ placeholder?: string;
5
+ helper?: string;
6
+ error?: string;
7
+ disabled?: boolean;
8
+ invalid?: boolean;
9
+ size?: 'sm' | 'md';
10
+ required?: boolean;
11
+ rows?: number;
12
+ resize?: 'none' | 'vertical' | 'both';
13
+ onchange?: (next: string) => void;
14
+ };
15
+ declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value">;
16
+ type Textarea = ReturnType<typeof Textarea>;
17
+ export default Textarea;
@@ -0,0 +1,228 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import {
4
+ type SelectOption,
5
+ shouldShowSearch,
6
+ filterOptions,
7
+ matchTypeAhead,
8
+ } from './Select';
9
+
10
+ let {
11
+ options,
12
+ getValue,
13
+ multiple,
14
+ onSelect,
15
+ onClose,
16
+ close,
17
+ }: {
18
+ options: SelectOption[];
19
+ /** Live read of the selected value(s). Returning a closure (rather
20
+ * than passing value as a snapshot) is what lets the listbox
21
+ * re-render checkmarks across multiple selections without unmounting. */
22
+ getValue: () => string | string[];
23
+ multiple: boolean;
24
+ onSelect: (v: string) => void;
25
+ /** Called by listbox-initiated close paths so the trigger can
26
+ * restore its open-flag and focus. Outside-click and Escape
27
+ * paths are handled by the popup manager via Select's wrap. */
28
+ onClose?: () => void;
29
+ close: () => void;
30
+ } = $props();
31
+
32
+ const value = $derived(getValue());
33
+
34
+ function dismiss() {
35
+ onClose?.();
36
+ close();
37
+ }
38
+
39
+ const showSearch = $derived(shouldShowSearch(options));
40
+
41
+ function initialActiveIdx(): number {
42
+ if (multiple) return 0;
43
+ const v = typeof value === 'string' ? value : '';
44
+ const idx = options.findIndex((o) => o.value === v && !o.disabled);
45
+ return idx >= 0 ? idx : 0;
46
+ }
47
+
48
+ let query = $state('');
49
+ let activeIdx = $state(initialActiveIdx());
50
+ let listEl = $state<HTMLDivElement | undefined>(undefined);
51
+ let searchEl = $state<HTMLInputElement | undefined>(undefined);
52
+ let typeAheadBuffer = '';
53
+ let typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
54
+
55
+ const filtered = $derived(filterOptions(options, query));
56
+
57
+ $effect(() => {
58
+ if (activeIdx >= filtered.length) activeIdx = 0;
59
+ void scrollActiveIntoView();
60
+ });
61
+
62
+ async function scrollActiveIntoView() {
63
+ await tick();
64
+ if (!listEl) return;
65
+ const el = listEl.querySelector<HTMLElement>(`[data-idx="${activeIdx}"]`);
66
+ el?.scrollIntoView({ block: 'nearest' });
67
+ }
68
+
69
+ function isSelected(o: SelectOption): boolean {
70
+ if (multiple && Array.isArray(value)) return value.includes(o.value);
71
+ return value === o.value;
72
+ }
73
+
74
+ function commit(idx: number) {
75
+ const o = filtered[idx];
76
+ if (!o || o.disabled) return;
77
+ onSelect(o.value);
78
+ if (!multiple) dismiss();
79
+ }
80
+
81
+ function onListKey(e: KeyboardEvent) {
82
+ if (filtered.length === 0) return;
83
+ switch (e.key) {
84
+ case 'ArrowDown':
85
+ e.preventDefault();
86
+ activeIdx = (activeIdx + 1) % filtered.length;
87
+ break;
88
+ case 'ArrowUp':
89
+ e.preventDefault();
90
+ activeIdx = (activeIdx - 1 + filtered.length) % filtered.length;
91
+ break;
92
+ case 'Home':
93
+ e.preventDefault();
94
+ activeIdx = 0;
95
+ break;
96
+ case 'End':
97
+ e.preventDefault();
98
+ activeIdx = filtered.length - 1;
99
+ break;
100
+ case 'Enter':
101
+ e.preventDefault();
102
+ commit(activeIdx);
103
+ break;
104
+ case ' ':
105
+ case 'Space':
106
+ e.preventDefault();
107
+ commit(activeIdx);
108
+ break;
109
+ case 'Escape':
110
+ e.preventDefault();
111
+ dismiss();
112
+ break;
113
+ default:
114
+ if (showSearch && document.activeElement === searchEl) return;
115
+ if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return;
116
+ typeAheadBuffer += e.key;
117
+ if (typeAheadTimer) clearTimeout(typeAheadTimer);
118
+ typeAheadTimer = setTimeout(() => { typeAheadBuffer = ''; }, 500);
119
+ const idx = matchTypeAhead(filtered, typeAheadBuffer, activeIdx);
120
+ if (idx >= 0) activeIdx = idx;
121
+ }
122
+ }
123
+ </script>
124
+
125
+ <div
126
+ class="sh3-listbox"
127
+ role="listbox"
128
+ aria-multiselectable={multiple}
129
+ tabindex="-1"
130
+ onkeydown={onListKey}
131
+ >
132
+ {#if showSearch}
133
+ <input
134
+ bind:this={searchEl}
135
+ class="sh3-listbox__search"
136
+ type="search"
137
+ placeholder="Filter…"
138
+ bind:value={query}
139
+ />
140
+ {/if}
141
+ <div class="sh3-listbox__list" bind:this={listEl}>
142
+ {#each filtered as opt, i}
143
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
144
+ <div
145
+ role="option"
146
+ tabindex="-1"
147
+ aria-selected={isSelected(opt)}
148
+ aria-disabled={opt.disabled}
149
+ data-idx={i}
150
+ class:sh3-listbox__opt--active={i === activeIdx}
151
+ class:sh3-listbox__opt--selected={isSelected(opt)}
152
+ onclick={() => commit(i)}
153
+ onmouseenter={() => activeIdx = i}
154
+ >
155
+ {#if multiple}
156
+ <span class="sh3-listbox__check" class:sh3-listbox__check--on={isSelected(opt)} aria-hidden="true"></span>
157
+ {/if}
158
+ <span class="sh3-listbox__label">{opt.label}</span>
159
+ </div>
160
+ {/each}
161
+ {#if filtered.length === 0}
162
+ <div class="sh3-listbox__empty">No matches</div>
163
+ {/if}
164
+ </div>
165
+ </div>
166
+
167
+ <style>
168
+ .sh3-listbox {
169
+ background: var(--shell-bg-elevated);
170
+ border: 1px solid var(--shell-border);
171
+ border-radius: var(--shell-widget-radius);
172
+ box-shadow: var(--shell-shadow-sm);
173
+ min-width: 180px;
174
+ max-width: 320px;
175
+ color: var(--shell-fg);
176
+ font-size: 0.8125rem;
177
+ overflow: hidden;
178
+ }
179
+ .sh3-listbox__search {
180
+ width: 100%;
181
+ height: 26px;
182
+ padding: 0 8px;
183
+ background: var(--shell-input-bg);
184
+ border: none;
185
+ border-bottom: 1px solid var(--shell-border);
186
+ color: var(--shell-fg);
187
+ font: inherit;
188
+ outline: none;
189
+ }
190
+ .sh3-listbox__list {
191
+ max-height: 280px;
192
+ overflow-y: auto;
193
+ padding: 4px 0;
194
+ }
195
+ .sh3-listbox__list > div {
196
+ display: flex; align-items: center; gap: 6px;
197
+ padding: 4px 10px;
198
+ cursor: pointer;
199
+ }
200
+ .sh3-listbox__opt--active { background: var(--shell-bg); }
201
+ .sh3-listbox__opt--selected { color: var(--shell-accent); }
202
+ .sh3-listbox__opt--active.sh3-listbox__opt--selected { background: var(--shell-accent); color: var(--shell-fg-on-accent); }
203
+ .sh3-listbox__list > div[aria-disabled="true"] {
204
+ color: var(--shell-fg-subtle);
205
+ cursor: not-allowed;
206
+ }
207
+ .sh3-listbox__check {
208
+ flex-shrink: 0;
209
+ width: 12px;
210
+ height: 12px;
211
+ }
212
+ .sh3-listbox__check--on::before {
213
+ content: "";
214
+ display: block;
215
+ width: 100%;
216
+ height: 100%;
217
+ background: var(--shell-accent);
218
+ clip-path: polygon(14% 44%, 0 60%, 40% 100%, 100% 20%, 85% 8%, 38% 70%);
219
+ }
220
+ .sh3-listbox__opt--active.sh3-listbox__opt--selected .sh3-listbox__check--on::before {
221
+ background: var(--shell-fg-on-accent);
222
+ }
223
+ .sh3-listbox__empty {
224
+ padding: 6px 10px;
225
+ color: var(--shell-fg-muted);
226
+ font-style: italic;
227
+ }
228
+ </style>
@@ -0,0 +1,18 @@
1
+ import { type SelectOption } from './Select';
2
+ type $$ComponentProps = {
3
+ options: SelectOption[];
4
+ /** Live read of the selected value(s). Returning a closure (rather
5
+ * than passing value as a snapshot) is what lets the listbox
6
+ * re-render checkmarks across multiple selections without unmounting. */
7
+ getValue: () => string | string[];
8
+ multiple: boolean;
9
+ onSelect: (v: string) => void;
10
+ /** Called by listbox-initiated close paths so the trigger can
11
+ * restore its open-flag and focus. Outside-click and Escape
12
+ * paths are handled by the popup manager via Select's wrap. */
13
+ onClose?: () => void;
14
+ close: () => void;
15
+ };
16
+ declare const SelectListbox: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type SelectListbox = ReturnType<typeof SelectListbox>;
18
+ export default SelectListbox;