sh3-core 0.12.0 → 0.13.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 (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 +124 -0
  32. package/dist/primitives/widgets/Field.svelte.d.ts +19 -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 +79 -0
  36. package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -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 +86 -0
  42. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -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 +167 -0
  48. package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -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 +124 -0
  54. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -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 +82 -0
  60. package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -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 +163 -0
  66. package/dist/primitives/widgets/Select.svelte.d.ts +14 -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 +205 -0
  72. package/dist/primitives/widgets/Slider.svelte.d.ts +15 -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 +58 -0
  76. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
  77. package/dist/primitives/widgets/Textarea.svelte +81 -0
  78. package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -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 +32 -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,205 @@
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
+ }: {
16
+ value?: number;
17
+ min?: number;
18
+ max?: number;
19
+ step?: number;
20
+ ticks?: number[];
21
+ showValue?: boolean;
22
+ disabled?: boolean;
23
+ invalid?: boolean;
24
+ size?: 'sm' | 'md';
25
+ orientation?: 'horizontal' | 'vertical';
26
+ } = $props();
27
+
28
+ const pct = $derived(valueToPercent(value, min, max));
29
+ </script>
30
+
31
+ <div class="sh3-slider sh3-slider--{orientation}"
32
+ class:sh3-slider--sm={size === 'sm'}
33
+ class:sh3-slider--invalid={invalid}>
34
+ <div class="sh3-slider__track">
35
+ <div class="sh3-slider__fill" style:--pct="{pct}%"></div>
36
+ {#if ticks}
37
+ {#each ticks as t}
38
+ <span class="sh3-slider__tick" style:--tick-pct="{valueToPercent(t, min, max)}%"></span>
39
+ {/each}
40
+ {/if}
41
+ </div>
42
+ <input
43
+ type="range"
44
+ class="sh3-slider__native"
45
+ {min}
46
+ {max}
47
+ {step}
48
+ {disabled}
49
+ aria-invalid={invalid || undefined}
50
+ bind:value
51
+ />
52
+ {#if showValue}
53
+ <output class="sh3-slider__value" style:--pct="{pct}%">{value}</output>
54
+ {/if}
55
+ </div>
56
+
57
+ <style>
58
+ .sh3-slider {
59
+ --thumb-size: 14px;
60
+ position: relative;
61
+ display: inline-block;
62
+ }
63
+ .sh3-slider--sm { --thumb-size: 12px; }
64
+ .sh3-slider--horizontal { width: 200px; height: var(--thumb-size); }
65
+ .sh3-slider--vertical { width: var(--thumb-size); height: 200px; }
66
+
67
+ .sh3-slider__track {
68
+ position: absolute;
69
+ background: var(--shell-track-bg);
70
+ border: 1px solid var(--shell-track-border);
71
+ border-radius: var(--shell-widget-radius-pill);
72
+ pointer-events: none;
73
+ }
74
+ .sh3-slider--horizontal .sh3-slider__track {
75
+ top: 50%; left: 0; right: 0;
76
+ height: 4px;
77
+ transform: translateY(-50%);
78
+ }
79
+ .sh3-slider--vertical .sh3-slider__track {
80
+ left: 50%; top: 0; bottom: 0;
81
+ width: 4px;
82
+ transform: translateX(-50%);
83
+ }
84
+
85
+ .sh3-slider__fill {
86
+ position: absolute;
87
+ background: var(--shell-track-fill);
88
+ border-radius: inherit;
89
+ }
90
+ .sh3-slider--horizontal .sh3-slider__fill {
91
+ left: 0; top: 0; bottom: 0;
92
+ width: var(--pct);
93
+ }
94
+ .sh3-slider--vertical .sh3-slider__fill {
95
+ bottom: 0; left: 0; right: 0;
96
+ height: var(--pct);
97
+ }
98
+
99
+ .sh3-slider__tick {
100
+ position: absolute;
101
+ background: var(--shell-tick-bg);
102
+ }
103
+ .sh3-slider--horizontal .sh3-slider__tick {
104
+ width: 1px; height: 8px; top: 50%;
105
+ transform: translate(-50%, -50%);
106
+ left: var(--tick-pct);
107
+ }
108
+ .sh3-slider--vertical .sh3-slider__tick {
109
+ width: 8px; height: 1px; left: 50%;
110
+ transform: translate(-50%, 50%);
111
+ bottom: var(--tick-pct);
112
+ }
113
+
114
+ .sh3-slider__native {
115
+ position: absolute;
116
+ inset: 0;
117
+ width: 100%;
118
+ height: 100%;
119
+ margin: 0;
120
+ background: transparent;
121
+ -webkit-appearance: none;
122
+ appearance: none;
123
+ cursor: pointer;
124
+ }
125
+ .sh3-slider--vertical .sh3-slider__native {
126
+ writing-mode: vertical-lr;
127
+ direction: rtl;
128
+ }
129
+ /* Browser default applies opacity:0.5 to <input>:disabled at the
130
+ element level; that composites onto the thumb regardless of any
131
+ pseudo-element opacity overrides. Pin to 1 here so the disabled
132
+ thumb stays opaque and the muted-token recolor is what shows. */
133
+ .sh3-slider__native:disabled {
134
+ cursor: not-allowed;
135
+ opacity: 1;
136
+ }
137
+ .sh3-slider--invalid .sh3-slider__track { border-color: var(--shell-error); }
138
+
139
+ /* Disabled state: shift the fill + thumb to muted/border tokens
140
+ instead of using opacity, so the thumb stays opaque (no see-through). */
141
+ .sh3-slider:has(.sh3-slider__native:disabled) .sh3-slider__fill {
142
+ background: var(--shell-border-strong);
143
+ }
144
+
145
+ .sh3-slider__native::-webkit-slider-thumb {
146
+ -webkit-appearance: none;
147
+ width: var(--thumb-size);
148
+ height: var(--thumb-size);
149
+ border-radius: 50%;
150
+ background: var(--shell-thumb-bg);
151
+ border: 2px solid var(--shell-thumb-border);
152
+ box-shadow: var(--shell-thumb-shadow);
153
+ cursor: pointer;
154
+ opacity: 1;
155
+ }
156
+ .sh3-slider__native::-moz-range-thumb {
157
+ width: var(--thumb-size);
158
+ height: var(--thumb-size);
159
+ border-radius: 50%;
160
+ background: var(--shell-thumb-bg);
161
+ border: 2px solid var(--shell-thumb-border);
162
+ box-shadow: var(--shell-thumb-shadow);
163
+ cursor: pointer;
164
+ opacity: 1;
165
+ }
166
+ .sh3-slider__native:disabled::-webkit-slider-thumb {
167
+ background: var(--shell-fg-subtle);
168
+ border-color: var(--shell-border-strong);
169
+ cursor: not-allowed;
170
+ opacity: 1;
171
+ }
172
+ .sh3-slider__native:disabled::-moz-range-thumb {
173
+ background: var(--shell-fg-subtle);
174
+ border-color: var(--shell-border-strong);
175
+ cursor: not-allowed;
176
+ opacity: 1;
177
+ }
178
+ .sh3-slider__native::-webkit-slider-runnable-track,
179
+ .sh3-slider__native::-moz-range-track {
180
+ background: transparent;
181
+ }
182
+ .sh3-slider__native:focus-visible::-webkit-slider-thumb {
183
+ box-shadow: var(--shell-focus-ring), var(--shell-thumb-shadow);
184
+ }
185
+ .sh3-slider__native:focus-visible::-moz-range-thumb {
186
+ box-shadow: var(--shell-focus-ring), var(--shell-thumb-shadow);
187
+ }
188
+
189
+ .sh3-slider__value {
190
+ position: absolute;
191
+ font-size: 0.75rem;
192
+ color: var(--shell-fg-muted);
193
+ pointer-events: none;
194
+ transform: translate(-50%, 0);
195
+ }
196
+ .sh3-slider--horizontal .sh3-slider__value {
197
+ top: calc(100% + 4px);
198
+ left: var(--pct);
199
+ }
200
+ .sh3-slider--vertical .sh3-slider__value {
201
+ left: calc(100% + 6px);
202
+ bottom: var(--pct);
203
+ transform: translate(0, 50%);
204
+ }
205
+ </style>
@@ -0,0 +1,15 @@
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
+ };
13
+ declare const Slider: import("svelte").Component<$$ComponentProps, {}, "value">;
14
+ type Slider = ReturnType<typeof Slider>;
15
+ 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,58 @@
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
+ }: {
14
+ orientation?: 'horizontal' | 'vertical';
15
+ channels: Channel[];
16
+ values?: Record<string, number>;
17
+ showValues?: boolean;
18
+ disabled?: boolean;
19
+ size?: 'sm' | 'md';
20
+ } = $props();
21
+ </script>
22
+
23
+ <div class="sh3-sg sh3-sg--{orientation}">
24
+ {#each channels as ch (ch.id)}
25
+ <div class="sh3-sg__channel">
26
+ <span class="sh3-sg__label">{ch.label}</span>
27
+ <Slider
28
+ {orientation}
29
+ min={ch.min ?? 0}
30
+ max={ch.max ?? 100}
31
+ step={ch.step ?? 1}
32
+ {disabled}
33
+ {size}
34
+ showValue={showValues}
35
+ bind:value={
36
+ () => values[ch.id] ?? 0,
37
+ (n: number) => values = { ...values, [ch.id]: n }
38
+ }
39
+ />
40
+ </div>
41
+ {/each}
42
+ </div>
43
+
44
+ <style>
45
+ .sh3-sg { display: inline-flex; gap: var(--shell-pad-md); }
46
+ .sh3-sg--vertical { flex-direction: row; align-items: end; }
47
+ .sh3-sg--horizontal { flex-direction: column; }
48
+ .sh3-sg__channel {
49
+ display: flex; gap: 4px; align-items: center;
50
+ }
51
+ .sh3-sg--vertical .sh3-sg__channel { flex-direction: column-reverse; align-items: center; }
52
+ .sh3-sg__label {
53
+ font-size: 0.75rem;
54
+ color: var(--shell-fg-muted);
55
+ min-width: 60px;
56
+ }
57
+ .sh3-sg--vertical .sh3-sg__label { min-width: 0; text-align: center; }
58
+ </style>
@@ -0,0 +1,18 @@
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
+ };
16
+ declare const SliderGroup: import("svelte").Component<$$ComponentProps, {}, "values">;
17
+ type SliderGroup = ReturnType<typeof SliderGroup>;
18
+ export default SliderGroup;
@@ -0,0 +1,81 @@
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
+ }: {
15
+ value?: string;
16
+ label?: string;
17
+ placeholder?: string;
18
+ helper?: string;
19
+ error?: string;
20
+ disabled?: boolean;
21
+ invalid?: boolean;
22
+ size?: 'sm' | 'md';
23
+ required?: boolean;
24
+ rows?: number;
25
+ resize?: 'none' | 'vertical' | 'both';
26
+ } = $props();
27
+
28
+ const showError = $derived(invalid && !!error);
29
+ const helperText = $derived(showError ? error : helper);
30
+ </script>
31
+
32
+ <label class="sh3-textarea" class:sh3-textarea--invalid={invalid} class:sh3-textarea--sm={size === 'sm'}>
33
+ {#if label}<span class="sh3-textarea__label">{label}{#if required}<span aria-hidden="true"> *</span>{/if}</span>{/if}
34
+ <textarea
35
+ class="sh3-textarea__input"
36
+ style:resize
37
+ {placeholder}
38
+ {disabled}
39
+ {required}
40
+ {rows}
41
+ aria-invalid={invalid || undefined}
42
+ bind:value
43
+ ></textarea>
44
+ {#if helperText}<span class="sh3-textarea__helper" class:sh3-textarea__helper--error={showError}>{helperText}</span>{/if}
45
+ </label>
46
+
47
+ <style>
48
+ .sh3-textarea {
49
+ display: inline-flex;
50
+ flex-direction: column;
51
+ gap: 4px;
52
+ font-family: var(--shell-font-ui);
53
+ font-size: 0.8125rem;
54
+ }
55
+ .sh3-textarea__label {
56
+ color: var(--shell-fg-muted);
57
+ font-size: 0.75rem;
58
+ }
59
+ .sh3-textarea__input {
60
+ background: var(--shell-input-bg);
61
+ color: var(--shell-fg);
62
+ border: 1px solid var(--shell-border);
63
+ border-radius: var(--shell-widget-radius);
64
+ padding: var(--shell-pad-sm) var(--shell-field-pad-x);
65
+ font: inherit;
66
+ outline: none;
67
+ transition: border-color var(--shell-motion-fast) var(--shell-ease-standard);
68
+ }
69
+ .sh3-textarea__input:focus {
70
+ border-color: var(--shell-input-border-focus);
71
+ box-shadow: var(--shell-focus-ring);
72
+ }
73
+ .sh3-textarea--invalid .sh3-textarea__input {
74
+ border-color: var(--shell-error);
75
+ }
76
+ .sh3-textarea__helper {
77
+ color: var(--shell-fg-muted);
78
+ font-size: 0.75rem;
79
+ }
80
+ .sh3-textarea__helper--error { color: var(--shell-error); }
81
+ </style>
@@ -0,0 +1,16 @@
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
+ };
14
+ declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value">;
15
+ type Textarea = ReturnType<typeof Textarea>;
16
+ 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;
@@ -78,10 +78,6 @@
78
78
  let focusLocked = $state(false);
79
79
  let targetShard = $state<string | null>(null);
80
80
 
81
- function toggleFocusLock(): void {
82
- focusLocked = !focusLocked;
83
- }
84
-
85
81
  // Toolbar slot registry
86
82
  const toolbarRegistry = new ToolbarSlotRegistry();
87
83
  toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
@@ -101,6 +97,7 @@
101
97
  const found = getActiveViewId(child);
102
98
  if (found !== null) return found;
103
99
  }
100
+ return null;
104
101
  }
105
102
  // slot node
106
103
  return node.viewId ?? null;