sh3-core 0.13.1 → 0.13.3

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 (172) hide show
  1. package/dist/BrandSlot.svelte +62 -13
  2. package/dist/__test__/setup-dom.js +5 -0
  3. package/dist/actions/MenuButton.svelte +2 -1
  4. package/dist/actions/contextMenuModel.d.ts +1 -1
  5. package/dist/actions/contextMenuModel.js +2 -1
  6. package/dist/actions/dispatcher.svelte.d.ts +1 -1
  7. package/dist/actions/dispatcher.svelte.js +2 -1
  8. package/dist/actions/listActive.d.ts +1 -1
  9. package/dist/actions/listActive.js +2 -1
  10. package/dist/actions/listeners.d.ts +1 -1
  11. package/dist/actions/listeners.js +6 -5
  12. package/dist/actions/menuBarModel.js +3 -2
  13. package/dist/actions/paletteModel.js +2 -1
  14. package/dist/actions/resolveLabel.test.js +14 -0
  15. package/dist/actions/types.d.ts +12 -1
  16. package/dist/actions/types.js +7 -1
  17. package/dist/api.d.ts +3 -0
  18. package/dist/api.js +3 -0
  19. package/dist/app/store/AppUpdateAvailableModal.svelte +87 -0
  20. package/dist/app/store/AppUpdateAvailableModal.svelte.d.ts +11 -0
  21. package/dist/app/store/InstalledView.svelte +8 -54
  22. package/dist/app/store/UninstallAppDialog.svelte +86 -0
  23. package/dist/app/store/UninstallAppDialog.svelte.d.ts +10 -0
  24. package/dist/app/store/permissionConfirm.d.ts +4 -0
  25. package/dist/app/store/permissionConfirm.js +28 -0
  26. package/dist/app/store/storeShard.svelte.d.ts +8 -1
  27. package/dist/app/store/storeShard.svelte.js +42 -9
  28. package/dist/app/store/updatePackage.test.d.ts +1 -0
  29. package/dist/app/store/updatePackage.test.js +34 -0
  30. package/dist/app/store/verbs.d.ts +1 -0
  31. package/dist/app/store/verbs.js +79 -5
  32. package/dist/app/store/verbs.test.d.ts +1 -0
  33. package/dist/app/store/verbs.test.js +56 -0
  34. package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
  35. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
  36. package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
  37. package/dist/app-appearance/appearanceShard.svelte.js +61 -0
  38. package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
  39. package/dist/app-appearance/appearanceState.svelte.js +59 -0
  40. package/dist/app-appearance/appearanceState.test.d.ts +1 -0
  41. package/dist/app-appearance/appearanceState.test.js +30 -0
  42. package/dist/app-appearance/index.d.ts +3 -0
  43. package/dist/app-appearance/index.js +2 -0
  44. package/dist/app-appearance/types.d.ts +11 -0
  45. package/dist/app-appearance/types.js +1 -0
  46. package/dist/apps/lifecycle.js +10 -2
  47. package/dist/apps/types.d.ts +18 -4
  48. package/dist/apps/workspace-rekey.d.ts +1 -0
  49. package/dist/apps/workspace-rekey.js +35 -0
  50. package/dist/apps/workspace-rekey.test.d.ts +1 -0
  51. package/dist/apps/workspace-rekey.test.js +23 -0
  52. package/dist/assets/iconIds.generated.d.ts +2 -0
  53. package/dist/assets/iconIds.generated.js +154 -0
  54. package/dist/auth/admin-users.svelte.d.ts +9 -0
  55. package/dist/auth/admin-users.svelte.js +42 -0
  56. package/dist/auth/admin-users.test.d.ts +1 -0
  57. package/dist/auth/admin-users.test.js +52 -0
  58. package/dist/createShell.js +5 -5
  59. package/dist/documents/config.d.ts +5 -1
  60. package/dist/documents/config.js +16 -8
  61. package/dist/documents/index.d.ts +1 -1
  62. package/dist/documents/index.js +1 -1
  63. package/dist/host-entry.d.ts +1 -1
  64. package/dist/host-entry.js +1 -1
  65. package/dist/host.d.ts +1 -1
  66. package/dist/host.js +9 -2
  67. package/dist/primitives/Button.svelte +50 -4
  68. package/dist/primitives/Button.svelte.d.ts +3 -1
  69. package/dist/primitives/Collapsible.svelte +110 -0
  70. package/dist/primitives/Collapsible.svelte.d.ts +14 -0
  71. package/dist/primitives/widgets/AppPicker.svelte +41 -0
  72. package/dist/primitives/widgets/AppPicker.svelte.d.ts +9 -0
  73. package/dist/primitives/widgets/AppPicker.svelte.test.d.ts +1 -0
  74. package/dist/primitives/widgets/AppPicker.svelte.test.js +26 -0
  75. package/dist/primitives/widgets/AppPicker.test.d.ts +1 -0
  76. package/dist/primitives/widgets/AppPicker.test.js +74 -0
  77. package/dist/primitives/widgets/ColorSwatch.svelte +7 -2
  78. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +2 -1
  79. package/dist/primitives/widgets/ColorSwatch.svelte.test.d.ts +1 -0
  80. package/dist/primitives/widgets/ColorSwatch.svelte.test.js +31 -0
  81. package/dist/primitives/widgets/Field.svelte +4 -2
  82. package/dist/primitives/widgets/Field.svelte.d.ts +2 -2
  83. package/dist/primitives/widgets/Field.svelte.test.d.ts +1 -0
  84. package/dist/primitives/widgets/Field.svelte.test.js +33 -0
  85. package/dist/primitives/widgets/FilePicker.svelte +2 -2
  86. package/dist/primitives/widgets/FilePicker.svelte.d.ts +2 -2
  87. package/dist/primitives/widgets/FilePicker.svelte.test.d.ts +1 -0
  88. package/dist/primitives/widgets/FilePicker.svelte.test.js +31 -0
  89. package/dist/primitives/widgets/IconPicker.svelte +115 -0
  90. package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
  91. package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
  92. package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
  93. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -4
  94. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +3 -3
  95. package/dist/primitives/widgets/IconToggleGroup.svelte.test.d.ts +1 -0
  96. package/dist/primitives/widgets/IconToggleGroup.svelte.test.js +40 -0
  97. package/dist/primitives/widgets/NumberInput.svelte +19 -9
  98. package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -2
  99. package/dist/primitives/widgets/NumberInput.svelte.test.d.ts +1 -0
  100. package/dist/primitives/widgets/NumberInput.svelte.test.js +48 -0
  101. package/dist/primitives/widgets/PickerList.d.ts +24 -0
  102. package/dist/primitives/widgets/PickerList.js +21 -0
  103. package/dist/primitives/widgets/PickerList.svelte +150 -0
  104. package/dist/primitives/widgets/PickerList.svelte.d.ts +16 -0
  105. package/dist/primitives/widgets/PickerList.svelte.test.d.ts +1 -0
  106. package/dist/primitives/widgets/PickerList.svelte.test.js +31 -0
  107. package/dist/primitives/widgets/PickerList.test.d.ts +1 -0
  108. package/dist/primitives/widgets/PickerList.test.js +218 -0
  109. package/dist/primitives/widgets/RangeSlider.svelte +11 -4
  110. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +2 -2
  111. package/dist/primitives/widgets/RangeSlider.svelte.test.d.ts +1 -0
  112. package/dist/primitives/widgets/RangeSlider.svelte.test.js +38 -0
  113. package/dist/primitives/widgets/Segmented.svelte +4 -4
  114. package/dist/primitives/widgets/Segmented.svelte.d.ts +3 -3
  115. package/dist/primitives/widgets/Segmented.svelte.test.d.ts +1 -0
  116. package/dist/primitives/widgets/Segmented.svelte.test.js +25 -0
  117. package/dist/primitives/widgets/Select.svelte +4 -4
  118. package/dist/primitives/widgets/Select.svelte.d.ts +3 -3
  119. package/dist/primitives/widgets/Select.svelte.test.d.ts +1 -0
  120. package/dist/primitives/widgets/Select.svelte.test.js +37 -0
  121. package/dist/primitives/widgets/Slider.svelte +4 -2
  122. package/dist/primitives/widgets/Slider.svelte.d.ts +2 -2
  123. package/dist/primitives/widgets/Slider.svelte.test.d.ts +1 -0
  124. package/dist/primitives/widgets/Slider.svelte.test.js +22 -0
  125. package/dist/primitives/widgets/SliderGroup.svelte +4 -2
  126. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +2 -2
  127. package/dist/primitives/widgets/SliderGroup.svelte.test.d.ts +1 -0
  128. package/dist/primitives/widgets/SliderGroup.svelte.test.js +34 -0
  129. package/dist/primitives/widgets/Textarea.svelte +5 -2
  130. package/dist/primitives/widgets/Textarea.svelte.d.ts +2 -2
  131. package/dist/primitives/widgets/Textarea.svelte.test.d.ts +1 -0
  132. package/dist/primitives/widgets/Textarea.svelte.test.js +29 -0
  133. package/dist/primitives/widgets/UserPicker.svelte +53 -0
  134. package/dist/primitives/widgets/UserPicker.svelte.d.ts +9 -0
  135. package/dist/primitives/widgets/UserPicker.svelte.test.d.ts +1 -0
  136. package/dist/primitives/widgets/UserPicker.svelte.test.js +30 -0
  137. package/dist/primitives/widgets/UserPicker.test.d.ts +1 -0
  138. package/dist/primitives/widgets/UserPicker.test.js +115 -0
  139. package/dist/primitives/widgets/_contract.d.ts +27 -0
  140. package/dist/primitives/widgets/_contract.js +10 -0
  141. package/dist/projects/session-state.svelte.d.ts +17 -0
  142. package/dist/projects/session-state.svelte.js +39 -0
  143. package/dist/projects/session-state.test.d.ts +1 -0
  144. package/dist/projects/session-state.test.js +55 -0
  145. package/dist/projects-shard/DeleteProjectDialog.svelte +150 -0
  146. package/dist/projects-shard/DeleteProjectDialog.svelte.d.ts +12 -0
  147. package/dist/projects-shard/DeleteProjectDialog.test.d.ts +1 -0
  148. package/dist/projects-shard/DeleteProjectDialog.test.js +120 -0
  149. package/dist/projects-shard/ProjectManage.svelte +219 -0
  150. package/dist/projects-shard/ProjectManage.svelte.d.ts +8 -0
  151. package/dist/projects-shard/ProjectsSection.svelte +120 -0
  152. package/dist/projects-shard/ProjectsSection.svelte.d.ts +3 -0
  153. package/dist/projects-shard/index.d.ts +4 -0
  154. package/dist/projects-shard/index.js +4 -0
  155. package/dist/projects-shard/projectsApi.d.ts +20 -0
  156. package/dist/projects-shard/projectsApi.js +44 -0
  157. package/dist/projects-shard/projectsApi.test.d.ts +1 -0
  158. package/dist/projects-shard/projectsApi.test.js +71 -0
  159. package/dist/projects-shard/projectsShard.svelte.d.ts +10 -0
  160. package/dist/projects-shard/projectsShard.svelte.js +148 -0
  161. package/dist/sh3core-shard/ShellHome.svelte +83 -39
  162. package/dist/sh3core-shard/appActions.d.ts +13 -0
  163. package/dist/sh3core-shard/appActions.js +181 -0
  164. package/dist/sh3core-shard/appActions.test.d.ts +1 -0
  165. package/dist/sh3core-shard/appActions.test.js +25 -0
  166. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
  167. package/dist/shards/activate-scopeid.test.d.ts +1 -0
  168. package/dist/shards/{activate-tenantid.test.js → activate-scopeid.test.js} +6 -6
  169. package/dist/version.d.ts +1 -1
  170. package/dist/version.js +1 -1
  171. package/package.json +2 -2
  172. /package/dist/{shards/activate-tenantid.test.d.ts → actions/resolveLabel.test.d.ts} +0 -0
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import Field from './Field.svelte';
4
+ describe('Field event contract', () => {
5
+ it('fires oninput on every keystroke with current value', async () => {
6
+ const oninput = vi.fn();
7
+ const { container } = render(Field, { props: { value: '', oninput } });
8
+ const input = container.querySelector('input');
9
+ await fireEvent.input(input, { target: { value: 'h' } });
10
+ await fireEvent.input(input, { target: { value: 'he' } });
11
+ await fireEvent.input(input, { target: { value: 'hel' } });
12
+ expect(oninput).toHaveBeenCalledTimes(3);
13
+ expect(oninput).toHaveBeenNthCalledWith(1, 'h');
14
+ expect(oninput).toHaveBeenNthCalledWith(2, 'he');
15
+ expect(oninput).toHaveBeenNthCalledWith(3, 'hel');
16
+ });
17
+ it('fires onchange exactly once on blur with final value', async () => {
18
+ const onchange = vi.fn();
19
+ const { container } = render(Field, { props: { value: '', onchange } });
20
+ const input = container.querySelector('input');
21
+ await fireEvent.input(input, { target: { value: 'hello' } });
22
+ await fireEvent.blur(input);
23
+ expect(onchange).toHaveBeenCalledTimes(1);
24
+ expect(onchange).toHaveBeenCalledWith('hello');
25
+ });
26
+ it('renders error text and aria-invalid when invalid', () => {
27
+ const { container, getByText } = render(Field, {
28
+ props: { value: '', invalid: true, error: 'Required' },
29
+ });
30
+ expect(container.querySelector('input').getAttribute('aria-invalid')).toBe('true');
31
+ expect(getByText('Required')).toBeInTheDocument();
32
+ });
33
+ });
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
2
3
  import { extractValue, displayName, type FilePickerValue } from './FilePicker';
3
4
 
4
5
  let {
@@ -18,8 +19,7 @@
18
19
  invalid?: boolean;
19
20
  size?: 'sm' | 'md';
20
21
  buttonLabel?: string;
21
- onchange?: (next: FilePickerValue) => void;
22
- } = $props();
22
+ } & CommitOnlyEvents<FilePickerValue> = $props();
23
23
 
24
24
  function onPick(e: Event) {
25
25
  const target = e.currentTarget as HTMLInputElement;
@@ -1,3 +1,4 @@
1
+ import type { CommitOnlyEvents } from './_contract';
1
2
  import { type FilePickerValue } from './FilePicker';
2
3
  type $$ComponentProps = {
3
4
  value?: FilePickerValue;
@@ -7,8 +8,7 @@ type $$ComponentProps = {
7
8
  invalid?: boolean;
8
9
  size?: 'sm' | 'md';
9
10
  buttonLabel?: string;
10
- onchange?: (next: FilePickerValue) => void;
11
- };
11
+ } & CommitOnlyEvents<FilePickerValue>;
12
12
  declare const FilePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
13
13
  type FilePicker = ReturnType<typeof FilePicker>;
14
14
  export default FilePicker;
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import FilePicker from './FilePicker.svelte';
4
+ function makeFile(name) {
5
+ return new File(['x'], name, { type: 'text/plain' });
6
+ }
7
+ describe('FilePicker event contract', () => {
8
+ it('fires onchange with single File when picked', async () => {
9
+ const onchange = vi.fn();
10
+ const { container } = render(FilePicker, { props: { onchange } });
11
+ const native = container.querySelector('input[type=file]');
12
+ const file = makeFile('hello.txt');
13
+ Object.defineProperty(native, 'files', { value: [file] });
14
+ await fireEvent.change(native);
15
+ expect(onchange).toHaveBeenCalledTimes(1);
16
+ expect(onchange.mock.calls[0][0]).toBeInstanceOf(File);
17
+ expect(onchange.mock.calls[0][0].name).toBe('hello.txt');
18
+ });
19
+ it('fires onchange with File[] when multiple', async () => {
20
+ const onchange = vi.fn();
21
+ const { container } = render(FilePicker, { props: { multiple: true, onchange } });
22
+ const native = container.querySelector('input[type=file]');
23
+ const files = [makeFile('a.txt'), makeFile('b.txt')];
24
+ Object.defineProperty(native, 'files', { value: files });
25
+ await fireEvent.change(native);
26
+ expect(onchange).toHaveBeenCalledTimes(1);
27
+ const next = onchange.mock.calls[0][0];
28
+ expect(Array.isArray(next)).toBe(true);
29
+ expect(next.map((f) => f.name)).toEqual(['a.txt', 'b.txt']);
30
+ });
31
+ });
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ /*
3
+ * IconPicker — searchable grid of icon ids from the bundled lucide
4
+ * sprite. The "(none)" tile clears the value. Composes the widget
5
+ * commit-only event contract (CommitOnlyEvents<string | undefined>).
6
+ */
7
+
8
+ import type { CommitOnlyEvents } from './_contract';
9
+ import { ICON_IDS } from '../../assets/iconIds.generated';
10
+ import iconsUrl from '../../assets/icons.svg';
11
+
12
+ type Props = {
13
+ value?: string | undefined;
14
+ label?: string;
15
+ disabled?: boolean;
16
+ } & CommitOnlyEvents<string | undefined>;
17
+
18
+ let {
19
+ value = $bindable(undefined),
20
+ label,
21
+ disabled = false,
22
+ onchange,
23
+ }: Props = $props();
24
+
25
+ let query = $state('');
26
+
27
+ const filtered = $derived(
28
+ query.trim() === ''
29
+ ? ICON_IDS
30
+ : ICON_IDS.filter((id) => id.includes(query.trim().toLowerCase())),
31
+ );
32
+
33
+ function pick(id: string | undefined): void {
34
+ if (disabled) return;
35
+ value = id;
36
+ onchange?.(id);
37
+ }
38
+ </script>
39
+
40
+ <div class="sh3-icon-picker" class:sh3-icon-picker--disabled={disabled}>
41
+ {#if label}<span class="sh3-icon-picker__label">{label}</span>{/if}
42
+ <input
43
+ type="search"
44
+ placeholder="Search icons…"
45
+ bind:value={query}
46
+ {disabled}
47
+ aria-label="Filter icons"
48
+ />
49
+ <div class="sh3-icon-picker__grid">
50
+ <button
51
+ type="button"
52
+ class="sh3-icon-picker__tile sh3-icon-picker__tile--none"
53
+ class:sh3-icon-picker__tile--selected={value === undefined}
54
+ aria-label="No icon"
55
+ {disabled}
56
+ onclick={() => pick(undefined)}
57
+ >×</button>
58
+ {#each filtered as id (id)}
59
+ <button
60
+ type="button"
61
+ class="sh3-icon-picker__tile"
62
+ class:sh3-icon-picker__tile--selected={value === id}
63
+ aria-label={id}
64
+ title={id}
65
+ {disabled}
66
+ onclick={() => pick(id)}
67
+ >
68
+ <svg viewBox="0 0 24 24"><use href="{iconsUrl}#{id}" /></svg>
69
+ </button>
70
+ {/each}
71
+ </div>
72
+ </div>
73
+
74
+ <style>
75
+ .sh3-icon-picker { display: flex; flex-direction: column; gap: 6px; }
76
+ .sh3-icon-picker__label { font-size: 13px; color: var(--shell-fg-muted); }
77
+ .sh3-icon-picker input[type="search"] {
78
+ background: var(--shell-bg-elevated);
79
+ color: var(--shell-fg);
80
+ border: 1px solid var(--shell-border);
81
+ border-radius: var(--shell-radius-sm, 3px);
82
+ padding: 6px 8px; font: inherit; font-size: 13px;
83
+ }
84
+ .sh3-icon-picker__grid {
85
+ display: grid;
86
+ grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
87
+ gap: 4px;
88
+ max-height: 220px;
89
+ overflow-y: auto;
90
+ padding: 4px;
91
+ background: var(--shell-bg);
92
+ border: 1px solid var(--shell-border);
93
+ border-radius: var(--shell-radius-sm, 3px);
94
+ }
95
+ .sh3-icon-picker__tile {
96
+ aspect-ratio: 1 / 1;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ background: var(--shell-bg-elevated);
101
+ color: var(--shell-fg);
102
+ border: 1px solid transparent;
103
+ border-radius: var(--shell-radius-sm, 3px);
104
+ cursor: pointer;
105
+ padding: 0; font: inherit;
106
+ }
107
+ .sh3-icon-picker__tile--none { font-size: 16px; color: var(--shell-fg-muted); }
108
+ .sh3-icon-picker__tile:hover { border-color: var(--shell-accent); }
109
+ .sh3-icon-picker__tile--selected {
110
+ outline: 2px solid var(--shell-accent);
111
+ outline-offset: -2px;
112
+ }
113
+ .sh3-icon-picker__tile svg { width: 18px; height: 18px; }
114
+ .sh3-icon-picker--disabled .sh3-icon-picker__tile { cursor: not-allowed; opacity: 0.5; }
115
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ type Props = {
3
+ value?: string | undefined;
4
+ label?: string;
5
+ disabled?: boolean;
6
+ } & CommitOnlyEvents<string | undefined>;
7
+ declare const IconPicker: import("svelte").Component<Props, {}, "value">;
8
+ type IconPicker = ReturnType<typeof IconPicker>;
9
+ export default IconPicker;
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import IconPicker from './IconPicker.svelte';
4
+ describe('IconPicker', () => {
5
+ it('renders the (none) tile and at least one icon tile', () => {
6
+ const { container } = render(IconPicker, { props: {} });
7
+ const tiles = container.querySelectorAll('.sh3-icon-picker__tile');
8
+ expect(tiles.length).toBeGreaterThan(1);
9
+ expect(tiles[0].getAttribute('aria-label')).toBe('No icon');
10
+ });
11
+ it('filters tiles by search input', async () => {
12
+ const { container } = render(IconPicker, { props: {} });
13
+ const search = container.querySelector('input[type="search"]');
14
+ await fireEvent.input(search, { target: { value: 'house' } });
15
+ const visible = container.querySelectorAll('.sh3-icon-picker__tile:not([hidden])');
16
+ expect(visible.length).toBeGreaterThan(0);
17
+ expect(visible.length).toBeLessThan(20);
18
+ });
19
+ it('calls onchange with the clicked icon id', async () => {
20
+ let lastValue = '__sentinel__';
21
+ const { container } = render(IconPicker, {
22
+ props: {
23
+ onchange: (v) => { lastValue = v; },
24
+ },
25
+ });
26
+ const tiles = Array.from(container.querySelectorAll('.sh3-icon-picker__tile'));
27
+ await fireEvent.click(tiles[1]);
28
+ expect(lastValue).toEqual(expect.any(String));
29
+ expect(lastValue).not.toBe('__sentinel__');
30
+ });
31
+ it('calls onchange with undefined when (none) tile is clicked', async () => {
32
+ let lastValue = 'house';
33
+ const { container } = render(IconPicker, {
34
+ props: {
35
+ value: 'house',
36
+ onchange: (v) => { lastValue = v; },
37
+ },
38
+ });
39
+ const noneTile = container.querySelector('.sh3-icon-picker__tile');
40
+ await fireEvent.click(noneTile);
41
+ expect(lastValue).toBeUndefined();
42
+ });
43
+ });
@@ -1,24 +1,24 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import type { CommitOnlyEvents } from './_contract';
3
4
  import { toggleSingle, toggleMultiple } from './IconToggleGroup';
4
5
 
5
6
  type Option = { value: string; icon: Snippet; tooltip?: string };
6
7
 
7
8
  let {
8
9
  options,
9
- value = $bindable(),
10
10
  multiple = false,
11
+ value = $bindable<string | string[]>(multiple ? [] : (options[0]?.value ?? '')),
11
12
  disabled = false,
12
13
  size = 'md',
13
14
  onchange,
14
15
  }: {
15
16
  options: Option[];
16
- value: string | string[];
17
17
  multiple?: boolean;
18
+ value?: string | string[];
18
19
  disabled?: boolean;
19
20
  size?: 'sm' | 'md';
20
- onchange?: (next: string | string[]) => void;
21
- } = $props();
21
+ } & CommitOnlyEvents<string | string[]> = $props();
22
22
 
23
23
  function isActive(v: string): boolean {
24
24
  if (multiple && Array.isArray(value)) return value.includes(v);
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { CommitOnlyEvents } from './_contract';
2
3
  type Option = {
3
4
  value: string;
4
5
  icon: Snippet;
@@ -6,12 +7,11 @@ type Option = {
6
7
  };
7
8
  type $$ComponentProps = {
8
9
  options: Option[];
9
- value: string | string[];
10
10
  multiple?: boolean;
11
+ value?: string | string[];
11
12
  disabled?: boolean;
12
13
  size?: 'sm' | 'md';
13
- onchange?: (next: string | string[]) => void;
14
- };
14
+ } & CommitOnlyEvents<string | string[]>;
15
15
  declare const IconToggleGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
16
16
  type IconToggleGroup = ReturnType<typeof IconToggleGroup>;
17
17
  export default IconToggleGroup;
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import { createRawSnippet } from 'svelte';
4
+ import IconToggleGroup from './IconToggleGroup.svelte';
5
+ const blank = createRawSnippet(() => ({ render: () => '<span/>' }));
6
+ const options = [
7
+ { value: 'b', icon: blank, tooltip: 'Bold' },
8
+ { value: 'i', icon: blank, tooltip: 'Italic' },
9
+ { value: 'u', icon: blank, tooltip: 'Under' },
10
+ ];
11
+ describe('IconToggleGroup event contract', () => {
12
+ it('fires onchange on click in single mode', async () => {
13
+ const onchange = vi.fn();
14
+ const { getByLabelText } = render(IconToggleGroup, {
15
+ props: { options, value: 'b', onchange },
16
+ });
17
+ await fireEvent.click(getByLabelText('Italic'));
18
+ expect(onchange).toHaveBeenCalledTimes(1);
19
+ expect(onchange).toHaveBeenCalledWith('i');
20
+ });
21
+ it('defaults to first option in single mode', () => {
22
+ const { getByLabelText } = render(IconToggleGroup, { props: { options } });
23
+ const bold = getByLabelText('Bold');
24
+ expect(bold.getAttribute('aria-pressed')).toBe('true');
25
+ });
26
+ it('defaults to empty array in multi mode', () => {
27
+ const { getByLabelText } = render(IconToggleGroup, { props: { options, multiple: true } });
28
+ expect(getByLabelText('Bold').getAttribute('aria-pressed')).toBe('false');
29
+ expect(getByLabelText('Italic').getAttribute('aria-pressed')).toBe('false');
30
+ });
31
+ it('toggles individual items in multi mode', async () => {
32
+ const onchange = vi.fn();
33
+ const { getByLabelText } = render(IconToggleGroup, {
34
+ props: { options, multiple: true, onchange },
35
+ });
36
+ await fireEvent.click(getByLabelText('Bold'));
37
+ await fireEvent.click(getByLabelText('Italic'));
38
+ expect(onchange).toHaveBeenLastCalledWith(['b', 'i']);
39
+ });
40
+ });
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { clamp, applyStep, formatNumber } from './NumberInput';
3
+ import type { LiveInputEvents } from './_contract';
4
+ import { clamp, applyStep } from './NumberInput';
4
5
 
5
6
  let {
6
7
  value = $bindable(0),
@@ -14,6 +15,7 @@
14
15
  disabled = false,
15
16
  invalid = false,
16
17
  size = 'md',
18
+ oninput,
17
19
  onchange,
18
20
  }: {
19
21
  value?: number;
@@ -27,18 +29,18 @@
27
29
  disabled?: boolean;
28
30
  invalid?: boolean;
29
31
  size?: 'sm' | 'md';
30
- onchange?: (next: number) => void;
31
- } = $props();
32
-
33
- const display = $derived(formatNumber(value, precision));
32
+ } & LiveInputEvents<number> = $props();
34
33
 
35
34
  function commit(next: number) {
36
35
  if (Number.isNaN(next)) return;
37
- value = clamp(applyStep(next, { min, step }), min, max);
36
+ let v = clamp(applyStep(next, { min, step }), min, max);
37
+ if (precision !== undefined) v = Number(v.toFixed(precision));
38
+ value = v;
38
39
  }
39
40
 
40
41
  function bump(direction: 1 | -1) {
41
42
  commit(value + direction * step);
43
+ oninput?.(value);
42
44
  }
43
45
 
44
46
  let holdTimer: ReturnType<typeof setTimeout> | null = null;
@@ -82,9 +84,17 @@
82
84
  {step}
83
85
  {disabled}
84
86
  aria-invalid={invalid || undefined}
85
- value={display}
86
- oninput={(e) => commit(parseFloat((e.currentTarget as HTMLInputElement).value))}
87
- onblur={() => onchange?.(value)}
87
+ value={value}
88
+ oninput={(e) => {
89
+ const raw = (e.currentTarget as HTMLInputElement).valueAsNumber;
90
+ if (Number.isNaN(raw)) return;
91
+ value = raw;
92
+ oninput?.(raw);
93
+ }}
94
+ onblur={() => {
95
+ commit(value);
96
+ onchange?.(value);
97
+ }}
88
98
  />
89
99
  {#if suffix}<span class="sh3-num__affix">{@render suffix()}</span>{/if}
90
100
  <span class="sh3-num__steppers">
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { LiveInputEvents } from './_contract';
2
3
  type $$ComponentProps = {
3
4
  value?: number;
4
5
  min?: number;
@@ -11,8 +12,7 @@ type $$ComponentProps = {
11
12
  disabled?: boolean;
12
13
  invalid?: boolean;
13
14
  size?: 'sm' | 'md';
14
- onchange?: (next: number) => void;
15
- };
15
+ } & LiveInputEvents<number>;
16
16
  declare const NumberInput: import("svelte").Component<$$ComponentProps, {}, "value">;
17
17
  type NumberInput = ReturnType<typeof NumberInput>;
18
18
  export default NumberInput;
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import NumberInput from './NumberInput.svelte';
4
+ describe('NumberInput event contract', () => {
5
+ it('does not snap mid-typing when step=1', async () => {
6
+ const oninput = vi.fn();
7
+ const { container } = render(NumberInput, { props: { value: 0, step: 1, oninput } });
8
+ const input = container.querySelector('input');
9
+ await fireEvent.input(input, { target: { value: '5' } });
10
+ await fireEvent.input(input, { target: { value: '5.5' } });
11
+ expect(oninput).toHaveBeenCalledTimes(2);
12
+ expect(oninput).toHaveBeenNthCalledWith(1, 5);
13
+ expect(oninput).toHaveBeenNthCalledWith(2, 5.5);
14
+ });
15
+ it('swallows partial parses (NaN does not call oninput)', async () => {
16
+ const oninput = vi.fn();
17
+ const { container } = render(NumberInput, { props: { value: 1, oninput } });
18
+ const input = container.querySelector('input');
19
+ await fireEvent.input(input, { target: { value: '' } });
20
+ expect(oninput).not.toHaveBeenCalled();
21
+ });
22
+ it('commits applyStep on blur and fires onchange once', async () => {
23
+ const onchange = vi.fn();
24
+ const { container } = render(NumberInput, { props: { value: 0, step: 1, onchange } });
25
+ const input = container.querySelector('input');
26
+ await fireEvent.input(input, { target: { value: '5.5' } });
27
+ await fireEvent.blur(input);
28
+ expect(onchange).toHaveBeenCalledTimes(1);
29
+ expect(onchange).toHaveBeenCalledWith(6);
30
+ });
31
+ it('clamps to max on blur', async () => {
32
+ const onchange = vi.fn();
33
+ const { container } = render(NumberInput, { props: { value: 0, max: 10, onchange } });
34
+ const input = container.querySelector('input');
35
+ await fireEvent.input(input, { target: { value: '999' } });
36
+ await fireEvent.blur(input);
37
+ expect(onchange).toHaveBeenCalledWith(10);
38
+ });
39
+ it('rounds to precision on blur', async () => {
40
+ const onchange = vi.fn();
41
+ const { container } = render(NumberInput, { props: { value: 0, step: 0.01, precision: 2, onchange } });
42
+ const input = container.querySelector('input');
43
+ await fireEvent.input(input, { target: { value: '1.23456' } });
44
+ await fireEvent.blur(input);
45
+ expect(onchange).toHaveBeenCalledTimes(1);
46
+ expect(onchange.mock.calls[0][0]).toBeCloseTo(1.23, 5);
47
+ });
48
+ });
@@ -0,0 +1,24 @@
1
+ export interface PickerItem {
2
+ id: string;
3
+ label: string;
4
+ /** Optional secondary line shown beneath the label in muted style. */
5
+ sublabel?: string;
6
+ }
7
+ export interface PickerListProps {
8
+ items: PickerItem[];
9
+ value: string[];
10
+ onchange?: (next: string[]) => void;
11
+ loading?: boolean;
12
+ error?: string | null;
13
+ onRetry?: () => void;
14
+ /** Empty-state message when items.length === 0 and not loading/error. */
15
+ emptyText?: string;
16
+ /** Search input is rendered when items.length >= this value. Default: 8. */
17
+ searchThreshold?: number;
18
+ disabled?: boolean;
19
+ size?: 'sm' | 'md';
20
+ }
21
+ /** Case-insensitive substring match over label, id, and sublabel. */
22
+ export declare function filterItems(items: PickerItem[], query: string): PickerItem[];
23
+ /** Toggle the given id in or out of value, returning a NEW array. */
24
+ export declare function toggle(value: string[], id: string): string[];
@@ -0,0 +1,21 @@
1
+ /** Case-insensitive substring match over label, id, and sublabel. */
2
+ export function filterItems(items, query) {
3
+ if (!query)
4
+ return items;
5
+ const q = query.toLowerCase();
6
+ return items.filter((it) => {
7
+ if (it.label.toLowerCase().includes(q))
8
+ return true;
9
+ if (it.id.toLowerCase().includes(q))
10
+ return true;
11
+ if (it.sublabel && it.sublabel.toLowerCase().includes(q))
12
+ return true;
13
+ return false;
14
+ });
15
+ }
16
+ /** Toggle the given id in or out of value, returning a NEW array. */
17
+ export function toggle(value, id) {
18
+ if (value.includes(id))
19
+ return value.filter((v) => v !== id);
20
+ return [...value, id];
21
+ }