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,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 {};
@@ -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
+ }