sh3-core 0.11.8 → 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.
- package/dist/__test__/reset.js +2 -0
- package/dist/actions/MenuButton.svelte +2 -1
- package/dist/actions/contextMenuModel.js +8 -0
- package/dist/actions/contextMenuModel.test.js +22 -2
- package/dist/actions/listeners.js +28 -2
- package/dist/actions/listeners.test.js +87 -1
- package/dist/actions/scope-helpers.d.ts +17 -0
- package/dist/actions/scope-helpers.js +37 -0
- package/dist/actions/scope-helpers.test.js +33 -1
- package/dist/api.d.ts +18 -1
- package/dist/api.js +15 -1
- package/dist/app/store/InstalledView.svelte +2 -1
- package/dist/app/store/StoreView.svelte +2 -1
- package/dist/apps/lifecycle.d.ts +7 -0
- package/dist/apps/lifecycle.js +25 -5
- package/dist/apps/lifecycle.test.js +95 -0
- package/dist/host.js +30 -4
- package/dist/layout/LayoutRenderer.svelte +5 -1
- package/dist/layout/LayoutRenderer.test.js +42 -0
- package/dist/layout/SlotContainer.svelte +11 -2
- package/dist/layout/SlotContainer.svelte.d.ts +1 -0
- package/dist/layout/slotHostPool.svelte.js +10 -3
- package/dist/layout/slotHostPool.test.js +15 -0
- package/dist/navigation/back-stack.d.ts +29 -0
- package/dist/navigation/back-stack.js +87 -0
- package/dist/navigation/back-stack.test.d.ts +1 -0
- package/dist/navigation/back-stack.test.js +145 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +6 -0
- package/dist/navigation/platform-web.d.ts +3 -0
- package/dist/navigation/platform-web.js +54 -0
- package/dist/navigation/platform-web.test.d.ts +1 -0
- package/dist/navigation/platform-web.test.js +96 -0
- package/dist/overlays/modal.js +7 -0
- package/dist/overlays/modal.test.js +35 -0
- package/dist/overlays/popup.js +7 -0
- package/dist/overlays/popup.test.js +33 -0
- package/dist/platform/index.d.ts +15 -0
- package/dist/platform/index.js +47 -0
- package/dist/primitives/base.css +17 -6
- package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
- package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
- package/dist/primitives/widgets/Field.svelte +124 -0
- package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
- package/dist/primitives/widgets/FilePicker.d.ts +3 -0
- package/dist/primitives/widgets/FilePicker.js +19 -0
- package/dist/primitives/widgets/FilePicker.svelte +79 -0
- package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
- package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
- package/dist/primitives/widgets/FilePicker.test.js +44 -0
- package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
- package/dist/primitives/widgets/IconToggleGroup.js +8 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
- package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
- package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
- package/dist/primitives/widgets/NumberInput.d.ts +6 -0
- package/dist/primitives/widgets/NumberInput.js +19 -0
- package/dist/primitives/widgets/NumberInput.svelte +167 -0
- package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
- package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
- package/dist/primitives/widgets/NumberInput.test.js +28 -0
- package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
- package/dist/primitives/widgets/RangeSlider.js +7 -0
- package/dist/primitives/widgets/RangeSlider.svelte +124 -0
- package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
- package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
- package/dist/primitives/widgets/RangeSlider.test.js +14 -0
- package/dist/primitives/widgets/Segmented.d.ts +9 -0
- package/dist/primitives/widgets/Segmented.js +28 -0
- package/dist/primitives/widgets/Segmented.svelte +82 -0
- package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
- package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
- package/dist/primitives/widgets/Segmented.test.js +24 -0
- package/dist/primitives/widgets/Select.d.ts +11 -0
- package/dist/primitives/widgets/Select.js +42 -0
- package/dist/primitives/widgets/Select.svelte +163 -0
- package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
- package/dist/primitives/widgets/Select.test.d.ts +1 -0
- package/dist/primitives/widgets/Select.test.js +68 -0
- package/dist/primitives/widgets/Slider.d.ts +6 -0
- package/dist/primitives/widgets/Slider.js +19 -0
- package/dist/primitives/widgets/Slider.svelte +205 -0
- package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
- package/dist/primitives/widgets/Slider.test.d.ts +1 -0
- package/dist/primitives/widgets/Slider.test.js +31 -0
- package/dist/primitives/widgets/SliderGroup.svelte +58 -0
- package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
- package/dist/primitives/widgets/Textarea.svelte +81 -0
- package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
- package/dist/primitives/widgets/_select-listbox.svelte +228 -0
- package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
- package/dist/shards/activate-error-isolation.test.d.ts +1 -0
- package/dist/shards/activate-error-isolation.test.js +98 -0
- package/dist/shards/activate.svelte.d.ts +30 -2
- package/dist/shards/activate.svelte.js +62 -17
- package/dist/shell-shard/Terminal.svelte +1 -4
- package/dist/shell-shard/verbs/index.js +2 -0
- package/dist/shell-shard/verbs/reset.d.ts +2 -0
- package/dist/shell-shard/verbs/reset.js +26 -0
- package/dist/tokens.css +32 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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,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 {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { constrainPair } from './RangeSlider';
|
|
3
|
+
describe('RangeSlider thumb-no-cross', () => {
|
|
4
|
+
it('keeps low <= high when low pushes up', () => {
|
|
5
|
+
expect(constrainPair([3, 5], 'low', 7)).toEqual([5, 5]);
|
|
6
|
+
});
|
|
7
|
+
it('keeps low <= high when high pushes down', () => {
|
|
8
|
+
expect(constrainPair([3, 5], 'high', 1)).toEqual([3, 3]);
|
|
9
|
+
});
|
|
10
|
+
it('passes through when not crossing', () => {
|
|
11
|
+
expect(constrainPair([3, 5], 'low', 4)).toEqual([4, 5]);
|
|
12
|
+
expect(constrainPair([3, 5], 'high', 6)).toEqual([3, 6]);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SegmentedOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function nextValue(opts: SegmentedOption[], current: string): string;
|
|
7
|
+
export declare function prevValue(opts: SegmentedOption[], current: string): string;
|
|
8
|
+
export declare function firstValue(opts: SegmentedOption[]): string | undefined;
|
|
9
|
+
export declare function lastValue(opts: SegmentedOption[]): string | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function activeOptions(opts) {
|
|
2
|
+
return opts.filter((o) => !o.disabled);
|
|
3
|
+
}
|
|
4
|
+
export function nextValue(opts, current) {
|
|
5
|
+
const active = activeOptions(opts);
|
|
6
|
+
if (active.length === 0)
|
|
7
|
+
return current;
|
|
8
|
+
const idx = active.findIndex((o) => o.value === current);
|
|
9
|
+
const next = active[(idx + 1 + active.length) % active.length];
|
|
10
|
+
return next.value;
|
|
11
|
+
}
|
|
12
|
+
export function prevValue(opts, current) {
|
|
13
|
+
const active = activeOptions(opts);
|
|
14
|
+
if (active.length === 0)
|
|
15
|
+
return current;
|
|
16
|
+
const idx = active.findIndex((o) => o.value === current);
|
|
17
|
+
const prev = active[(idx - 1 + active.length) % active.length];
|
|
18
|
+
return prev.value;
|
|
19
|
+
}
|
|
20
|
+
export function firstValue(opts) {
|
|
21
|
+
var _a;
|
|
22
|
+
return (_a = activeOptions(opts)[0]) === null || _a === void 0 ? void 0 : _a.value;
|
|
23
|
+
}
|
|
24
|
+
export function lastValue(opts) {
|
|
25
|
+
var _a;
|
|
26
|
+
const a = activeOptions(opts);
|
|
27
|
+
return (_a = a[a.length - 1]) === null || _a === void 0 ? void 0 : _a.value;
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type SegmentedOption, nextValue, prevValue, firstValue, lastValue } from './Segmented';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
options,
|
|
6
|
+
value = $bindable(),
|
|
7
|
+
size = 'md',
|
|
8
|
+
disabled = false,
|
|
9
|
+
}: {
|
|
10
|
+
options: SegmentedOption[];
|
|
11
|
+
value: string;
|
|
12
|
+
size?: 'sm' | 'md';
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
function onKey(e: KeyboardEvent) {
|
|
17
|
+
if (disabled) return;
|
|
18
|
+
let next: string | undefined;
|
|
19
|
+
switch (e.key) {
|
|
20
|
+
case 'ArrowRight': case 'ArrowDown': next = nextValue(options, value); break;
|
|
21
|
+
case 'ArrowLeft': case 'ArrowUp': next = prevValue(options, value); break;
|
|
22
|
+
case 'Home': next = firstValue(options); break;
|
|
23
|
+
case 'End': next = lastValue(options); break;
|
|
24
|
+
default: return;
|
|
25
|
+
}
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
if (next !== undefined) value = next;
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<div role="radiogroup" tabindex="-1" class="sh3-seg" class:sh3-seg--sm={size === 'sm'} onkeydown={onKey}>
|
|
32
|
+
{#each options as opt}
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
role="radio"
|
|
36
|
+
aria-checked={value === opt.value}
|
|
37
|
+
tabindex={value === opt.value ? 0 : -1}
|
|
38
|
+
disabled={disabled || opt.disabled}
|
|
39
|
+
class:sh3-seg__btn--active={value === opt.value}
|
|
40
|
+
onclick={() => { if (!opt.disabled && !disabled) value = opt.value; }}
|
|
41
|
+
>{opt.label}</button>
|
|
42
|
+
{/each}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<style>
|
|
46
|
+
.sh3-seg {
|
|
47
|
+
display: inline-flex;
|
|
48
|
+
background: var(--shell-bg-sunken);
|
|
49
|
+
border: 1px solid var(--shell-border);
|
|
50
|
+
border-radius: var(--shell-widget-radius-pill);
|
|
51
|
+
padding: 2px;
|
|
52
|
+
height: var(--shell-field-height-md);
|
|
53
|
+
gap: 0;
|
|
54
|
+
}
|
|
55
|
+
.sh3-seg--sm { height: var(--shell-field-height-sm); }
|
|
56
|
+
.sh3-seg button {
|
|
57
|
+
background: transparent;
|
|
58
|
+
color: var(--shell-fg-muted);
|
|
59
|
+
border: none;
|
|
60
|
+
padding: 0 var(--shell-field-pad-x);
|
|
61
|
+
border-radius: var(--shell-widget-radius-pill);
|
|
62
|
+
font-size: 0.8125rem;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
transition: background var(--shell-motion-fast) var(--shell-ease-standard),
|
|
65
|
+
color var(--shell-motion-fast) var(--shell-ease-standard);
|
|
66
|
+
}
|
|
67
|
+
.sh3-seg button:hover:not(:disabled):not(.sh3-seg__btn--active) {
|
|
68
|
+
color: var(--shell-fg);
|
|
69
|
+
filter: none;
|
|
70
|
+
}
|
|
71
|
+
.sh3-seg__btn--active {
|
|
72
|
+
background: var(--shell-accent);
|
|
73
|
+
color: var(--shell-fg-on-accent);
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
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);
|
|
76
|
+
}
|
|
77
|
+
.sh3-seg button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
78
|
+
.sh3-seg button:focus-visible {
|
|
79
|
+
outline: none;
|
|
80
|
+
box-shadow: var(--shell-focus-ring);
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type SegmentedOption } from './Segmented';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
options: SegmentedOption[];
|
|
4
|
+
value: string;
|
|
5
|
+
size?: 'sm' | 'md';
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
};
|
|
8
|
+
declare const Segmented: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
9
|
+
type Segmented = ReturnType<typeof Segmented>;
|
|
10
|
+
export default Segmented;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|