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,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nextValue, prevValue, firstValue, lastValue } from './Segmented';
|
|
3
|
+
describe('Segmented keyboard nav', () => {
|
|
4
|
+
const opts = [
|
|
5
|
+
{ value: 'a', label: 'A' },
|
|
6
|
+
{ value: 'b', label: 'B', disabled: true },
|
|
7
|
+
{ value: 'c', label: 'C' },
|
|
8
|
+
];
|
|
9
|
+
it('nextValue skips disabled', () => {
|
|
10
|
+
expect(nextValue(opts, 'a')).toBe('c');
|
|
11
|
+
});
|
|
12
|
+
it('nextValue wraps to first', () => {
|
|
13
|
+
expect(nextValue(opts, 'c')).toBe('a');
|
|
14
|
+
});
|
|
15
|
+
it('prevValue skips disabled', () => {
|
|
16
|
+
expect(prevValue(opts, 'c')).toBe('a');
|
|
17
|
+
});
|
|
18
|
+
it('firstValue returns first non-disabled', () => {
|
|
19
|
+
expect(firstValue(opts)).toBe('a');
|
|
20
|
+
});
|
|
21
|
+
it('lastValue returns last non-disabled', () => {
|
|
22
|
+
expect(lastValue(opts)).toBe('c');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SelectOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare const SEARCH_THRESHOLD = 8;
|
|
7
|
+
export declare function shouldShowSearch(options: SelectOption[]): boolean;
|
|
8
|
+
export declare function filterOptions(options: SelectOption[], query: string): SelectOption[];
|
|
9
|
+
export declare function matchTypeAhead(options: SelectOption[], buffer: string, current: number): number;
|
|
10
|
+
export declare function resolveValueLabel(options: SelectOption[], value: string | string[], multiple: boolean): string;
|
|
11
|
+
export declare function toggleMulti(current: string[], v: string): string[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const SEARCH_THRESHOLD = 8;
|
|
2
|
+
export function shouldShowSearch(options) {
|
|
3
|
+
return options.length >= SEARCH_THRESHOLD;
|
|
4
|
+
}
|
|
5
|
+
export function filterOptions(options, query) {
|
|
6
|
+
if (!query)
|
|
7
|
+
return options.slice();
|
|
8
|
+
const q = query.toLowerCase();
|
|
9
|
+
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
10
|
+
}
|
|
11
|
+
export function matchTypeAhead(options, buffer, current) {
|
|
12
|
+
if (!buffer)
|
|
13
|
+
return -1;
|
|
14
|
+
const b = buffer.toLowerCase();
|
|
15
|
+
const n = options.length;
|
|
16
|
+
for (let i = 1; i <= n; i++) {
|
|
17
|
+
const idx = (current + i) % n;
|
|
18
|
+
if (options[idx].disabled)
|
|
19
|
+
continue;
|
|
20
|
+
if (options[idx].label.toLowerCase().startsWith(b))
|
|
21
|
+
return idx;
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
export function resolveValueLabel(options, value, multiple) {
|
|
26
|
+
var _a, _b;
|
|
27
|
+
if (multiple && Array.isArray(value)) {
|
|
28
|
+
return value
|
|
29
|
+
.map((v) => { var _a, _b; return (_b = (_a = options.find((o) => o.value === v)) === null || _a === void 0 ? void 0 : _a.label) !== null && _b !== void 0 ? _b : ''; })
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(', ');
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === 'string' && value) {
|
|
34
|
+
return (_b = (_a = options.find((o) => o.value === value)) === null || _a === void 0 ? void 0 : _a.label) !== null && _b !== void 0 ? _b : '';
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
export function toggleMulti(current, v) {
|
|
39
|
+
if (current.includes(v))
|
|
40
|
+
return current.filter((x) => x !== v);
|
|
41
|
+
return [...current, v];
|
|
42
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { shell } from '../../shellRuntime.svelte';
|
|
4
|
+
import Listbox from './_select-listbox.svelte';
|
|
5
|
+
import { type SelectOption, resolveValueLabel, toggleMulti } from './Select';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
options,
|
|
9
|
+
value = $bindable<string | string[]>(''),
|
|
10
|
+
multiple = false,
|
|
11
|
+
placeholder = 'Select…',
|
|
12
|
+
label,
|
|
13
|
+
disabled = false,
|
|
14
|
+
invalid = false,
|
|
15
|
+
size = 'md',
|
|
16
|
+
}: {
|
|
17
|
+
options: SelectOption[];
|
|
18
|
+
value?: string | string[];
|
|
19
|
+
multiple?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
invalid?: boolean;
|
|
24
|
+
size?: 'sm' | 'md';
|
|
25
|
+
} = $props();
|
|
26
|
+
|
|
27
|
+
let trigger = $state<HTMLButtonElement | undefined>(undefined);
|
|
28
|
+
let nativeRef = $state<HTMLSelectElement | undefined>(undefined);
|
|
29
|
+
let isOpen = $state(false);
|
|
30
|
+
|
|
31
|
+
const displayLabel = $derived(resolveValueLabel(options, value, multiple));
|
|
32
|
+
|
|
33
|
+
function onPopupClose() {
|
|
34
|
+
isOpen = false;
|
|
35
|
+
trigger?.focus();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function open() {
|
|
39
|
+
if (disabled || isOpen || !trigger) return;
|
|
40
|
+
isOpen = true;
|
|
41
|
+
const popupHandle = shell.popup.show(
|
|
42
|
+
Listbox,
|
|
43
|
+
{ anchor: trigger },
|
|
44
|
+
{
|
|
45
|
+
options,
|
|
46
|
+
// Closure read so the listbox sees Select's live value across
|
|
47
|
+
// multiple selections without remount.
|
|
48
|
+
getValue: () => value,
|
|
49
|
+
multiple,
|
|
50
|
+
onSelect: (v: string) => {
|
|
51
|
+
if (multiple) {
|
|
52
|
+
const arr = Array.isArray(value) ? value : [];
|
|
53
|
+
value = toggleMulti(arr, v);
|
|
54
|
+
} else {
|
|
55
|
+
value = v;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
// Listbox calls this on its own close paths (selection in
|
|
59
|
+
// single mode, Escape inside the listbox). Outside-click and
|
|
60
|
+
// document-Escape go through the wrap below instead.
|
|
61
|
+
onClose: onPopupClose,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
// popup.ts's outside-click + document-Escape route through
|
|
65
|
+
// current.handle.close() — wrap that to also clear our state.
|
|
66
|
+
const origClose = popupHandle.close;
|
|
67
|
+
popupHandle.close = () => {
|
|
68
|
+
origClose();
|
|
69
|
+
onPopupClose();
|
|
70
|
+
};
|
|
71
|
+
await tick();
|
|
72
|
+
const lb = document.querySelector<HTMLElement>('.sh3-listbox');
|
|
73
|
+
lb?.focus();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onTriggerKey(e: KeyboardEvent) {
|
|
77
|
+
if (disabled || isOpen) return;
|
|
78
|
+
switch (e.key) {
|
|
79
|
+
case 'ArrowDown': case 'ArrowUp': case 'Enter': case ' ':
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
open();
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onNativeChange() {
|
|
87
|
+
if (!nativeRef) return;
|
|
88
|
+
if (multiple) {
|
|
89
|
+
value = Array.from(nativeRef.selectedOptions).map((o) => o.value);
|
|
90
|
+
} else {
|
|
91
|
+
value = nativeRef.value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<label class="sh3-select" class:sh3-select--invalid={invalid} class:sh3-select--sm={size === 'sm'}>
|
|
97
|
+
{#if label}<span class="sh3-select__label">{label}</span>{/if}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
class="sh3-select__btn"
|
|
101
|
+
bind:this={trigger}
|
|
102
|
+
{disabled}
|
|
103
|
+
aria-haspopup="listbox"
|
|
104
|
+
aria-expanded={isOpen}
|
|
105
|
+
onclick={open}
|
|
106
|
+
onkeydown={onTriggerKey}
|
|
107
|
+
>
|
|
108
|
+
<span class="sh3-select__display" class:sh3-select__display--placeholder={!displayLabel}>
|
|
109
|
+
{displayLabel || placeholder}
|
|
110
|
+
</span>
|
|
111
|
+
<span class="sh3-select__chevron" aria-hidden="true">▾</span>
|
|
112
|
+
</button>
|
|
113
|
+
|
|
114
|
+
<select
|
|
115
|
+
bind:this={nativeRef}
|
|
116
|
+
class="sh3-select__native"
|
|
117
|
+
{multiple}
|
|
118
|
+
{disabled}
|
|
119
|
+
tabindex={-1}
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
onchange={onNativeChange}
|
|
122
|
+
>
|
|
123
|
+
{#each options as opt}
|
|
124
|
+
<option value={opt.value} selected={
|
|
125
|
+
multiple
|
|
126
|
+
? Array.isArray(value) && value.includes(opt.value)
|
|
127
|
+
: value === opt.value
|
|
128
|
+
}>{opt.label}</option>
|
|
129
|
+
{/each}
|
|
130
|
+
</select>
|
|
131
|
+
</label>
|
|
132
|
+
|
|
133
|
+
<style>
|
|
134
|
+
.sh3-select { display: inline-flex; flex-direction: column; gap: 4px; font-size: 0.8125rem; position: relative; }
|
|
135
|
+
.sh3-select__label { color: var(--shell-fg-muted); font-size: 0.75rem; }
|
|
136
|
+
.sh3-select__btn {
|
|
137
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
138
|
+
height: var(--shell-field-height-md);
|
|
139
|
+
min-width: 140px;
|
|
140
|
+
padding: 0 var(--shell-field-pad-x);
|
|
141
|
+
background: var(--shell-input-bg);
|
|
142
|
+
color: var(--shell-fg);
|
|
143
|
+
border: 1px solid var(--shell-border);
|
|
144
|
+
border-radius: var(--shell-widget-radius);
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
font: inherit;
|
|
147
|
+
text-align: left;
|
|
148
|
+
}
|
|
149
|
+
.sh3-select--sm .sh3-select__btn { height: var(--shell-field-height-sm); }
|
|
150
|
+
.sh3-select__btn:focus-visible { outline: none; box-shadow: var(--shell-focus-ring); border-color: var(--shell-input-border-focus); }
|
|
151
|
+
.sh3-select__btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
152
|
+
.sh3-select--invalid .sh3-select__btn { border-color: var(--shell-error); }
|
|
153
|
+
.sh3-select__display { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
154
|
+
.sh3-select__display--placeholder { color: var(--shell-fg-muted); }
|
|
155
|
+
.sh3-select__chevron { color: var(--shell-fg-muted); }
|
|
156
|
+
.sh3-select__native {
|
|
157
|
+
position: absolute;
|
|
158
|
+
width: 1px; height: 1px;
|
|
159
|
+
margin: 0; padding: 0;
|
|
160
|
+
border: 0; opacity: 0;
|
|
161
|
+
pointer-events: none;
|
|
162
|
+
}
|
|
163
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type SelectOption } from './Select';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
options: SelectOption[];
|
|
4
|
+
value?: string | string[];
|
|
5
|
+
multiple?: boolean;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
invalid?: boolean;
|
|
10
|
+
size?: 'sm' | 'md';
|
|
11
|
+
};
|
|
12
|
+
declare const Select: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
13
|
+
type Select = ReturnType<typeof Select>;
|
|
14
|
+
export default Select;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { shouldShowSearch, filterOptions, matchTypeAhead, resolveValueLabel, toggleMulti, SEARCH_THRESHOLD, } from './Select';
|
|
3
|
+
const opts = [
|
|
4
|
+
{ value: 'apple', label: 'Apple' },
|
|
5
|
+
{ value: 'banana', label: 'Banana' },
|
|
6
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
7
|
+
{ value: 'date', label: 'Date' },
|
|
8
|
+
{ value: 'elderberry', label: 'Elderberry' },
|
|
9
|
+
{ value: 'fig', label: 'Fig' },
|
|
10
|
+
{ value: 'grape', label: 'Grape' },
|
|
11
|
+
{ value: 'honeydew', label: 'Honeydew' },
|
|
12
|
+
];
|
|
13
|
+
describe('Select threshold', () => {
|
|
14
|
+
it('does not show search at 7 options', () => {
|
|
15
|
+
expect(shouldShowSearch(opts.slice(0, 7))).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it('shows search at 8 options (threshold)', () => {
|
|
18
|
+
expect(shouldShowSearch(opts)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it('threshold constant is 8', () => {
|
|
21
|
+
expect(SEARCH_THRESHOLD).toBe(8);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('Select filtering', () => {
|
|
25
|
+
it('filters case-insensitively by substring', () => {
|
|
26
|
+
expect(filterOptions(opts, 'AN').map((o) => o.value)).toEqual(['banana']);
|
|
27
|
+
});
|
|
28
|
+
it('matches multiple', () => {
|
|
29
|
+
expect(filterOptions(opts, 'e').map((o) => o.value))
|
|
30
|
+
.toEqual(['apple', 'cherry', 'date', 'elderberry', 'grape', 'honeydew']);
|
|
31
|
+
});
|
|
32
|
+
it('empty query returns all', () => {
|
|
33
|
+
expect(filterOptions(opts, '').length).toBe(opts.length);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('Select type-ahead', () => {
|
|
37
|
+
it('matches by first character', () => {
|
|
38
|
+
expect(matchTypeAhead(opts, 'b', 0)).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
it('matches by buffer prefix', () => {
|
|
41
|
+
expect(matchTypeAhead(opts, 'ho', 0)).toBe(7);
|
|
42
|
+
});
|
|
43
|
+
it('returns -1 when no match', () => {
|
|
44
|
+
expect(matchTypeAhead(opts, 'z', 0)).toBe(-1);
|
|
45
|
+
});
|
|
46
|
+
it('searches from current+1 forward, then wraps', () => {
|
|
47
|
+
expect(matchTypeAhead(opts, 'b', 1)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('Select value resolution', () => {
|
|
51
|
+
it('resolveValueLabel single', () => {
|
|
52
|
+
expect(resolveValueLabel(opts, 'cherry', false)).toBe('Cherry');
|
|
53
|
+
});
|
|
54
|
+
it('resolveValueLabel single null', () => {
|
|
55
|
+
expect(resolveValueLabel(opts, '', false)).toBe('');
|
|
56
|
+
});
|
|
57
|
+
it('resolveValueLabel multi joined', () => {
|
|
58
|
+
expect(resolveValueLabel(opts, ['apple', 'cherry'], true)).toBe('Apple, Cherry');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('Select toggleMulti', () => {
|
|
62
|
+
it('adds new selection', () => {
|
|
63
|
+
expect(toggleMulti(['a'], 'b')).toEqual(['a', 'b']);
|
|
64
|
+
});
|
|
65
|
+
it('removes existing', () => {
|
|
66
|
+
expect(toggleMulti(['a', 'b'], 'a')).toEqual(['b']);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function valueToPercent(value: number, min: number, max: number): number;
|
|
2
|
+
export declare function percentToValue(pct: number, min: number, max: number): number;
|
|
3
|
+
export declare function snapToStep(value: number, base: number, step: number, bounds?: {
|
|
4
|
+
min?: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
}): number;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function valueToPercent(value, min, max) {
|
|
2
|
+
if (max === min)
|
|
3
|
+
return 0;
|
|
4
|
+
return ((value - min) / (max - min)) * 100;
|
|
5
|
+
}
|
|
6
|
+
export function percentToValue(pct, min, max) {
|
|
7
|
+
return min + (pct / 100) * (max - min);
|
|
8
|
+
}
|
|
9
|
+
export function snapToStep(value, base, step, bounds) {
|
|
10
|
+
if (step <= 0)
|
|
11
|
+
return value;
|
|
12
|
+
const k = Math.round((value - base) / step);
|
|
13
|
+
let v = base + k * step;
|
|
14
|
+
if ((bounds === null || bounds === void 0 ? void 0 : bounds.min) !== undefined && v < bounds.min)
|
|
15
|
+
v = bounds.min;
|
|
16
|
+
if ((bounds === null || bounds === void 0 ? void 0 : bounds.max) !== undefined && v > bounds.max)
|
|
17
|
+
v = bounds.max;
|
|
18
|
+
return v;
|
|
19
|
+
}
|
|
@@ -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;
|