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,79 @@
1
+ <script lang="ts">
2
+ import { extractValue, displayName, type FilePickerValue } from './FilePicker';
3
+
4
+ let {
5
+ value = $bindable<FilePickerValue>(null),
6
+ multiple = false,
7
+ accept,
8
+ disabled = false,
9
+ invalid = false,
10
+ size = 'md',
11
+ buttonLabel = 'Choose file...',
12
+ }: {
13
+ value?: FilePickerValue;
14
+ multiple?: boolean;
15
+ accept?: string;
16
+ disabled?: boolean;
17
+ invalid?: boolean;
18
+ size?: 'sm' | 'md';
19
+ buttonLabel?: string;
20
+ } = $props();
21
+
22
+ function onChange(e: Event) {
23
+ const target = e.currentTarget as HTMLInputElement;
24
+ value = extractValue(target.files, multiple);
25
+ }
26
+
27
+ const display = $derived(displayName(value));
28
+ </script>
29
+
30
+ <label class="sh3-fp" class:sh3-fp--sm={size === 'sm'} class:sh3-fp--invalid={invalid}>
31
+ <input
32
+ class="sh3-fp__native"
33
+ type="file"
34
+ {accept}
35
+ {multiple}
36
+ {disabled}
37
+ onchange={onChange}
38
+ />
39
+ <span class="sh3-fp__btn" tabindex="-1">{buttonLabel}</span>
40
+ <span class="sh3-fp__name">{display || 'no file'}</span>
41
+ </label>
42
+
43
+ <style>
44
+ .sh3-fp {
45
+ display: inline-flex;
46
+ align-items: stretch;
47
+ height: var(--shell-field-height-md);
48
+ border: 1px solid var(--shell-border);
49
+ border-radius: var(--shell-widget-radius);
50
+ background: var(--shell-input-bg);
51
+ overflow: hidden;
52
+ cursor: pointer;
53
+ font-size: 0.8125rem;
54
+ }
55
+ .sh3-fp--sm { height: var(--shell-field-height-sm); }
56
+ .sh3-fp--invalid { border-color: var(--shell-error); }
57
+ .sh3-fp:focus-within { box-shadow: var(--shell-focus-ring); border-color: var(--shell-input-border-focus); }
58
+
59
+ .sh3-fp__native {
60
+ position: absolute;
61
+ width: 1px; height: 1px;
62
+ opacity: 0;
63
+ pointer-events: none;
64
+ }
65
+ .sh3-fp__btn {
66
+ display: inline-flex; align-items: center;
67
+ padding: 0 var(--shell-field-pad-x);
68
+ background: var(--shell-bg-elevated);
69
+ color: var(--shell-fg);
70
+ border-right: 1px solid var(--shell-border);
71
+ }
72
+ .sh3-fp__name {
73
+ display: inline-flex; align-items: center;
74
+ padding: 0 var(--shell-field-pad-x);
75
+ color: var(--shell-fg-muted);
76
+ flex: 1; min-width: 0;
77
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
78
+ }
79
+ </style>
@@ -0,0 +1,13 @@
1
+ import { type FilePickerValue } from './FilePicker';
2
+ type $$ComponentProps = {
3
+ value?: FilePickerValue;
4
+ multiple?: boolean;
5
+ accept?: string;
6
+ disabled?: boolean;
7
+ invalid?: boolean;
8
+ size?: 'sm' | 'md';
9
+ buttonLabel?: string;
10
+ };
11
+ declare const FilePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
12
+ type FilePicker = ReturnType<typeof FilePicker>;
13
+ export default FilePicker;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { extractValue, displayName } from './FilePicker';
3
+ // Test stubs — File and DataTransfer aren't available in node test env, so
4
+ // we structurally fake FileList/File. The helpers only read `length`, indexed
5
+ // access, and `.name` so this is sufficient.
6
+ function mkFile(name) {
7
+ return { name };
8
+ }
9
+ function mkList(...files) {
10
+ const list = files;
11
+ Object.defineProperty(list, 'item', {
12
+ value: (i) => { var _a; return (_a = files[i]) !== null && _a !== void 0 ? _a : null; },
13
+ });
14
+ return list;
15
+ }
16
+ describe('FilePicker value extraction', () => {
17
+ it('returns null when FileList is empty (single)', () => {
18
+ expect(extractValue(mkList(), false)).toBe(null);
19
+ });
20
+ it('returns first File in single mode', () => {
21
+ const r = extractValue(mkList(mkFile('a.txt'), mkFile('b.txt')), false);
22
+ expect(r.name).toBe('a.txt');
23
+ });
24
+ it('returns array in multiple mode', () => {
25
+ const r = extractValue(mkList(mkFile('a.txt'), mkFile('b.txt')), true);
26
+ expect(r).toHaveLength(2);
27
+ expect(r[0].name).toBe('a.txt');
28
+ });
29
+ it('returns [] when FileList empty in multiple mode', () => {
30
+ expect(extractValue(null, true)).toEqual([]);
31
+ });
32
+ it('displayName: null → empty', () => {
33
+ expect(displayName(null)).toBe('');
34
+ });
35
+ it('displayName: single file → name', () => {
36
+ expect(displayName(mkFile('cat.png'))).toBe('cat.png');
37
+ });
38
+ it('displayName: multiple files → "N files"', () => {
39
+ expect(displayName([mkFile('a.txt'), mkFile('b.txt')])).toBe('2 files');
40
+ });
41
+ it('displayName: single in array → just the name', () => {
42
+ expect(displayName([mkFile('one.txt')])).toBe('one.txt');
43
+ });
44
+ });
@@ -0,0 +1,2 @@
1
+ export declare function toggleSingle(current: string, candidate: string): string;
2
+ export declare function toggleMultiple(current: string[], candidate: string): string[];
@@ -0,0 +1,8 @@
1
+ export function toggleSingle(current, candidate) {
2
+ return current === candidate ? '' : candidate;
3
+ }
4
+ export function toggleMultiple(current, candidate) {
5
+ if (current.includes(candidate))
6
+ return current.filter((v) => v !== candidate);
7
+ return [...current, candidate];
8
+ }
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { toggleSingle, toggleMultiple } from './IconToggleGroup';
4
+
5
+ type Option = { value: string; icon: Snippet; tooltip?: string };
6
+
7
+ let {
8
+ options,
9
+ value = $bindable(),
10
+ multiple = false,
11
+ disabled = false,
12
+ size = 'md',
13
+ }: {
14
+ options: Option[];
15
+ value: string | string[];
16
+ multiple?: boolean;
17
+ disabled?: boolean;
18
+ size?: 'sm' | 'md';
19
+ } = $props();
20
+
21
+ function isActive(v: string): boolean {
22
+ if (multiple && Array.isArray(value)) return value.includes(v);
23
+ return value === v;
24
+ }
25
+
26
+ function onClick(v: string) {
27
+ if (disabled) return;
28
+ if (multiple) {
29
+ const arr = Array.isArray(value) ? value : [];
30
+ value = toggleMultiple(arr, v);
31
+ } else {
32
+ value = toggleSingle(typeof value === 'string' ? value : '', v);
33
+ }
34
+ }
35
+ </script>
36
+
37
+ <div role="group" class="sh3-itg" class:sh3-itg--sm={size === 'sm'}>
38
+ {#each options as opt}
39
+ <button
40
+ type="button"
41
+ title={opt.tooltip}
42
+ aria-label={opt.tooltip}
43
+ aria-pressed={isActive(opt.value)}
44
+ {disabled}
45
+ class:sh3-itg__btn--active={isActive(opt.value)}
46
+ onclick={() => onClick(opt.value)}
47
+ >{@render opt.icon()}</button>
48
+ {/each}
49
+ </div>
50
+
51
+ <style>
52
+ .sh3-itg {
53
+ display: inline-flex;
54
+ background: var(--shell-bg-sunken);
55
+ border: 1px solid var(--shell-border);
56
+ border-radius: var(--shell-widget-radius);
57
+ padding: 2px;
58
+ gap: 2px;
59
+ }
60
+ .sh3-itg button {
61
+ width: 26px; height: 26px;
62
+ background: transparent;
63
+ color: var(--shell-fg-muted);
64
+ border: none;
65
+ border-radius: var(--shell-radius-sm);
66
+ cursor: pointer;
67
+ display: inline-flex; align-items: center; justify-content: center;
68
+ padding: 0;
69
+ transition: background var(--shell-motion-fast) var(--shell-ease-standard),
70
+ color var(--shell-motion-fast) var(--shell-ease-standard);
71
+ }
72
+ .sh3-itg--sm button { width: 22px; height: 22px; }
73
+ .sh3-itg button:hover:not(:disabled):not(.sh3-itg__btn--active) {
74
+ background: var(--shell-bg-elevated);
75
+ color: var(--shell-fg);
76
+ filter: none;
77
+ }
78
+ .sh3-itg__btn--active {
79
+ background: var(--shell-accent);
80
+ color: var(--shell-fg-on-accent);
81
+ font-weight: 600;
82
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25), inset 0 0 0 1px color-mix(in srgb, var(--shell-fg-on-accent) 25%, transparent);
83
+ }
84
+ .sh3-itg button:focus-visible { outline: none; box-shadow: var(--shell-focus-ring); }
85
+ .sh3-itg button:disabled { opacity: 0.5; cursor: not-allowed; }
86
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Option = {
3
+ value: string;
4
+ icon: Snippet;
5
+ tooltip?: string;
6
+ };
7
+ type $$ComponentProps = {
8
+ options: Option[];
9
+ value: string | string[];
10
+ multiple?: boolean;
11
+ disabled?: boolean;
12
+ size?: 'sm' | 'md';
13
+ };
14
+ declare const IconToggleGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
15
+ type IconToggleGroup = ReturnType<typeof IconToggleGroup>;
16
+ export default IconToggleGroup;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { toggleSingle, toggleMultiple } from './IconToggleGroup';
3
+ describe('IconToggleGroup state', () => {
4
+ it('single mode replaces value', () => {
5
+ expect(toggleSingle('a', 'b')).toBe('b');
6
+ });
7
+ it('single mode toggling current value clears it', () => {
8
+ expect(toggleSingle('a', 'a')).toBe('');
9
+ });
10
+ it('multi mode adds new value', () => {
11
+ expect(toggleMultiple(['a'], 'b')).toEqual(['a', 'b']);
12
+ });
13
+ it('multi mode removes existing value', () => {
14
+ expect(toggleMultiple(['a', 'b'], 'a')).toEqual(['b']);
15
+ });
16
+ it('multi mode preserves order on add', () => {
17
+ expect(toggleMultiple(['a', 'c'], 'b')).toEqual(['a', 'c', 'b']);
18
+ });
19
+ });
@@ -0,0 +1,6 @@
1
+ export declare function clamp(value: number, min: number | undefined, max: number | undefined): number;
2
+ export declare function applyStep(value: number, opts: {
3
+ min?: number;
4
+ step: number;
5
+ }): number;
6
+ export declare function formatNumber(value: number, precision: number | undefined): string;
@@ -0,0 +1,19 @@
1
+ export function clamp(value, min, max) {
2
+ let v = value;
3
+ if (min !== undefined && v < min)
4
+ v = min;
5
+ if (max !== undefined && v > max)
6
+ v = max;
7
+ return v;
8
+ }
9
+ export function applyStep(value, opts) {
10
+ var _a;
11
+ const base = (_a = opts.min) !== null && _a !== void 0 ? _a : 0;
12
+ const k = Math.round((value - base) / opts.step);
13
+ return base + k * opts.step;
14
+ }
15
+ export function formatNumber(value, precision) {
16
+ if (precision === undefined)
17
+ return String(value);
18
+ return value.toFixed(precision);
19
+ }
@@ -0,0 +1,167 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { clamp, applyStep, formatNumber } from './NumberInput';
4
+
5
+ let {
6
+ value = $bindable(0),
7
+ min,
8
+ max,
9
+ step = 1,
10
+ precision,
11
+ label,
12
+ prefix,
13
+ suffix,
14
+ disabled = false,
15
+ invalid = false,
16
+ size = 'md',
17
+ }: {
18
+ value?: number;
19
+ min?: number;
20
+ max?: number;
21
+ step?: number;
22
+ precision?: number;
23
+ label?: string;
24
+ prefix?: Snippet;
25
+ suffix?: Snippet;
26
+ disabled?: boolean;
27
+ invalid?: boolean;
28
+ size?: 'sm' | 'md';
29
+ } = $props();
30
+
31
+ const display = $derived(formatNumber(value, precision));
32
+
33
+ function commit(next: number) {
34
+ if (Number.isNaN(next)) return;
35
+ value = clamp(applyStep(next, { min, step }), min, max);
36
+ }
37
+
38
+ function bump(direction: 1 | -1) {
39
+ commit(value + direction * step);
40
+ }
41
+
42
+ let holdTimer: ReturnType<typeof setTimeout> | null = null;
43
+ let holdInterval: ReturnType<typeof setInterval> | null = null;
44
+ function startHold(direction: 1 | -1) {
45
+ if (disabled) return;
46
+ bump(direction);
47
+ holdTimer = setTimeout(() => {
48
+ let elapsed = 0;
49
+ holdInterval = setInterval(() => {
50
+ bump(direction);
51
+ elapsed += 60;
52
+ if (elapsed > 1500 && holdInterval) {
53
+ clearInterval(holdInterval);
54
+ holdInterval = setInterval(() => bump(direction), 20);
55
+ }
56
+ }, 60);
57
+ }, 350);
58
+ }
59
+ function stopHold() {
60
+ if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
61
+ if (holdInterval) { clearInterval(holdInterval); holdInterval = null; }
62
+ }
63
+ </script>
64
+
65
+ <label class="sh3-num" class:sh3-num--invalid={invalid} class:sh3-num--sm={size === 'sm'}>
66
+ {#if label}<span class="sh3-num__label">{label}</span>{/if}
67
+ <span class="sh3-num__row">
68
+ {#if prefix}<span class="sh3-num__affix">{@render prefix()}</span>{/if}
69
+ <input
70
+ class="sh3-num__input"
71
+ type="number"
72
+ {min}
73
+ {max}
74
+ {step}
75
+ {disabled}
76
+ aria-invalid={invalid || undefined}
77
+ value={display}
78
+ oninput={(e) => commit(parseFloat((e.currentTarget as HTMLInputElement).value))}
79
+ />
80
+ {#if suffix}<span class="sh3-num__affix">{@render suffix()}</span>{/if}
81
+ <span class="sh3-num__steppers">
82
+ <button type="button" {disabled}
83
+ onpointerdown={() => startHold(1)}
84
+ onpointerup={stopHold}
85
+ onpointerleave={stopHold}
86
+ aria-label="Increase">▲</button>
87
+ <button type="button" {disabled}
88
+ onpointerdown={() => startHold(-1)}
89
+ onpointerup={stopHold}
90
+ onpointerleave={stopHold}
91
+ aria-label="Decrease">▼</button>
92
+ </span>
93
+ </span>
94
+ </label>
95
+
96
+ <style>
97
+ .sh3-num { display: inline-flex; flex-direction: column; gap: 4px; font-size: 0.8125rem; }
98
+ .sh3-num__label { color: var(--shell-fg-muted); font-size: 0.75rem; }
99
+ .sh3-num__row {
100
+ display: inline-flex;
101
+ align-items: stretch;
102
+ background: var(--shell-input-bg);
103
+ border: 1px solid var(--shell-border);
104
+ border-radius: var(--shell-widget-radius);
105
+ height: var(--shell-field-height-md);
106
+ }
107
+ .sh3-num--sm .sh3-num__row { height: var(--shell-field-height-sm); }
108
+ .sh3-num__row:focus-within {
109
+ border-color: var(--shell-input-border-focus);
110
+ box-shadow: var(--shell-focus-ring);
111
+ }
112
+ .sh3-num--invalid .sh3-num__row { border-color: var(--shell-error); }
113
+ .sh3-num__input {
114
+ flex: 1 1 auto;
115
+ min-width: 50px;
116
+ padding: 0 var(--shell-field-pad-x);
117
+ background: transparent;
118
+ border: none;
119
+ color: var(--shell-fg);
120
+ font: inherit;
121
+ outline: none;
122
+ -moz-appearance: textfield;
123
+ appearance: textfield;
124
+ }
125
+ /* Row owns the focus ring; suppress base.css's global input:focus-visible. */
126
+ .sh3-num__input:focus,
127
+ .sh3-num__input:focus-visible {
128
+ outline: none;
129
+ box-shadow: none;
130
+ border: none;
131
+ }
132
+ .sh3-num__input::-webkit-outer-spin-button,
133
+ .sh3-num__input::-webkit-inner-spin-button {
134
+ -webkit-appearance: none;
135
+ margin: 0;
136
+ }
137
+ .sh3-num__affix {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ padding: 0 6px;
141
+ color: var(--shell-fg-muted);
142
+ }
143
+ .sh3-num__steppers {
144
+ display: inline-flex;
145
+ flex-direction: column;
146
+ border-left: 1px solid var(--shell-border);
147
+ }
148
+ .sh3-num__steppers button {
149
+ flex: 1;
150
+ width: 18px;
151
+ background: transparent;
152
+ color: var(--shell-fg-muted);
153
+ border: none;
154
+ border-radius: 0;
155
+ padding: 0;
156
+ cursor: pointer;
157
+ font-size: 8px;
158
+ line-height: 1;
159
+ }
160
+ .sh3-num__steppers button:hover:not(:disabled) {
161
+ background: var(--shell-bg-elevated);
162
+ color: var(--shell-fg);
163
+ filter: none;
164
+ }
165
+ .sh3-num__steppers button:disabled { cursor: not-allowed; opacity: 0.5; }
166
+ .sh3-num__steppers button + button { border-top: 1px solid var(--shell-border); }
167
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ value?: number;
4
+ min?: number;
5
+ max?: number;
6
+ step?: number;
7
+ precision?: number;
8
+ label?: string;
9
+ prefix?: Snippet;
10
+ suffix?: Snippet;
11
+ disabled?: boolean;
12
+ invalid?: boolean;
13
+ size?: 'sm' | 'md';
14
+ };
15
+ declare const NumberInput: import("svelte").Component<$$ComponentProps, {}, "value">;
16
+ type NumberInput = ReturnType<typeof NumberInput>;
17
+ export default NumberInput;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { clamp, applyStep, formatNumber } from './NumberInput';
3
+ describe('NumberInput value math', () => {
4
+ it('clamps below min', () => {
5
+ expect(clamp(-5, 0, 10)).toBe(0);
6
+ });
7
+ it('clamps above max', () => {
8
+ expect(clamp(15, 0, 10)).toBe(10);
9
+ });
10
+ it('passes through when in range', () => {
11
+ expect(clamp(5, 0, 10)).toBe(5);
12
+ });
13
+ it('handles undefined min/max', () => {
14
+ expect(clamp(5, undefined, undefined)).toBe(5);
15
+ });
16
+ it('applyStep snaps to step grid relative to min', () => {
17
+ expect(applyStep(2.7, { min: 0, step: 0.5 })).toBe(2.5);
18
+ expect(applyStep(2.8, { min: 0, step: 0.5 })).toBe(3);
19
+ });
20
+ it('applyStep with min=1 snaps to 1, 1.5, 2, ...', () => {
21
+ expect(applyStep(1.7, { min: 1, step: 0.5 })).toBe(1.5);
22
+ });
23
+ it('formatNumber respects precision', () => {
24
+ expect(formatNumber(3.14159, 2)).toBe('3.14');
25
+ expect(formatNumber(3.14159, 0)).toBe('3');
26
+ expect(formatNumber(3, undefined)).toBe('3');
27
+ });
28
+ });
@@ -0,0 +1,2 @@
1
+ export type Pair = [number, number];
2
+ export declare function constrainPair(current: Pair, which: 'low' | 'high', next: number): Pair;
@@ -0,0 +1,7 @@
1
+ export function constrainPair(current, which, next) {
2
+ const [lo, hi] = current;
3
+ if (which === 'low') {
4
+ return [Math.min(next, hi), hi];
5
+ }
6
+ return [lo, Math.max(next, lo)];
7
+ }
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import { valueToPercent } from './Slider';
3
+ import { constrainPair, type Pair } from './RangeSlider';
4
+
5
+ let {
6
+ value = $bindable<Pair>([0, 100]),
7
+ min = 0,
8
+ max = 100,
9
+ step = 1,
10
+ disabled = false,
11
+ invalid = false,
12
+ size = 'md',
13
+ }: {
14
+ value?: Pair;
15
+ min?: number;
16
+ max?: number;
17
+ step?: number;
18
+ disabled?: boolean;
19
+ invalid?: boolean;
20
+ size?: 'sm' | 'md';
21
+ } = $props();
22
+
23
+ const lowPct = $derived(valueToPercent(value[0], min, max));
24
+ const highPct = $derived(valueToPercent(value[1], min, max));
25
+
26
+ function setLow(n: number) { value = constrainPair(value, 'low', n); }
27
+ function setHigh(n: number) { value = constrainPair(value, 'high', n); }
28
+ </script>
29
+
30
+ <div class="sh3-range" class:sh3-range--sm={size === 'sm'} class:sh3-range--invalid={invalid}>
31
+ <div class="sh3-range__track">
32
+ <div class="sh3-range__fill"
33
+ style:--lo="{lowPct}%"
34
+ style:--hi="{highPct}%"></div>
35
+ </div>
36
+ <input type="range" class="sh3-range__native sh3-range__native--low"
37
+ {min} {max} {step} {disabled}
38
+ value={value[0]}
39
+ oninput={(e) => setLow(parseFloat((e.currentTarget as HTMLInputElement).value))} />
40
+ <input type="range" class="sh3-range__native sh3-range__native--high"
41
+ {min} {max} {step} {disabled}
42
+ value={value[1]}
43
+ oninput={(e) => setHigh(parseFloat((e.currentTarget as HTMLInputElement).value))} />
44
+ </div>
45
+
46
+ <style>
47
+ .sh3-range {
48
+ --thumb-size: 14px;
49
+ position: relative;
50
+ width: 200px; height: var(--thumb-size);
51
+ }
52
+ .sh3-range--sm { --thumb-size: 12px; }
53
+ .sh3-range__track {
54
+ position: absolute;
55
+ top: 50%; left: 0; right: 0;
56
+ height: 4px;
57
+ transform: translateY(-50%);
58
+ background: var(--shell-track-bg);
59
+ border: 1px solid var(--shell-track-border);
60
+ border-radius: var(--shell-widget-radius-pill);
61
+ }
62
+ .sh3-range__fill {
63
+ position: absolute;
64
+ top: 0; bottom: 0;
65
+ left: var(--lo);
66
+ width: calc(var(--hi) - var(--lo));
67
+ background: var(--shell-track-fill);
68
+ border-radius: inherit;
69
+ }
70
+ .sh3-range__native {
71
+ position: absolute;
72
+ top: 0; left: 0;
73
+ width: 100%; height: 100%;
74
+ margin: 0;
75
+ background: transparent;
76
+ -webkit-appearance: none;
77
+ appearance: none;
78
+ pointer-events: none;
79
+ }
80
+ /* See Slider.svelte — pin opacity at element level so disabled
81
+ thumb stays opaque rather than translucent. */
82
+ .sh3-range__native:disabled { opacity: 1; cursor: not-allowed; }
83
+ .sh3-range__native::-webkit-slider-thumb {
84
+ -webkit-appearance: none;
85
+ width: var(--thumb-size); height: var(--thumb-size);
86
+ border-radius: 50%;
87
+ background: var(--shell-thumb-bg);
88
+ border: 2px solid var(--shell-thumb-border);
89
+ box-shadow: var(--shell-thumb-shadow);
90
+ pointer-events: auto;
91
+ cursor: pointer;
92
+ opacity: 1;
93
+ }
94
+ .sh3-range__native::-moz-range-thumb {
95
+ width: var(--thumb-size); height: var(--thumb-size);
96
+ border-radius: 50%;
97
+ background: var(--shell-thumb-bg);
98
+ border: 2px solid var(--shell-thumb-border);
99
+ box-shadow: var(--shell-thumb-shadow);
100
+ pointer-events: auto;
101
+ cursor: pointer;
102
+ opacity: 1;
103
+ }
104
+ .sh3-range__native:disabled::-webkit-slider-thumb {
105
+ background: var(--shell-fg-subtle);
106
+ border-color: var(--shell-border-strong);
107
+ cursor: not-allowed;
108
+ opacity: 1;
109
+ }
110
+ .sh3-range__native:disabled::-moz-range-thumb {
111
+ background: var(--shell-fg-subtle);
112
+ border-color: var(--shell-border-strong);
113
+ cursor: not-allowed;
114
+ opacity: 1;
115
+ }
116
+ .sh3-range:has(.sh3-range__native:disabled) .sh3-range__fill {
117
+ background: var(--shell-border-strong);
118
+ }
119
+ .sh3-range__native::-webkit-slider-runnable-track,
120
+ .sh3-range__native::-moz-range-track {
121
+ background: transparent;
122
+ }
123
+ .sh3-range--invalid .sh3-range__track { border-color: var(--shell-error); }
124
+ </style>
@@ -0,0 +1,13 @@
1
+ import { type Pair } from './RangeSlider';
2
+ type $$ComponentProps = {
3
+ value?: Pair;
4
+ min?: number;
5
+ max?: number;
6
+ step?: number;
7
+ disabled?: boolean;
8
+ invalid?: boolean;
9
+ size?: 'sm' | 'md';
10
+ };
11
+ declare const RangeSlider: import("svelte").Component<$$ComponentProps, {}, "value">;
12
+ type RangeSlider = ReturnType<typeof RangeSlider>;
13
+ export default RangeSlider;
@@ -0,0 +1 @@
1
+ export {};