sh3-core 0.13.1 → 0.13.2

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 (119) hide show
  1. package/dist/BrandSlot.svelte +62 -13
  2. package/dist/__test__/setup-dom.js +5 -0
  3. package/dist/api.d.ts +3 -0
  4. package/dist/api.js +3 -0
  5. package/dist/apps/lifecycle.js +10 -2
  6. package/dist/apps/types.d.ts +11 -4
  7. package/dist/apps/workspace-rekey.d.ts +1 -0
  8. package/dist/apps/workspace-rekey.js +35 -0
  9. package/dist/apps/workspace-rekey.test.js +23 -0
  10. package/dist/auth/admin-users.svelte.d.ts +9 -0
  11. package/dist/auth/admin-users.svelte.js +42 -0
  12. package/dist/auth/admin-users.test.d.ts +1 -0
  13. package/dist/auth/admin-users.test.js +52 -0
  14. package/dist/createShell.js +5 -5
  15. package/dist/documents/config.d.ts +5 -1
  16. package/dist/documents/config.js +16 -8
  17. package/dist/documents/index.d.ts +1 -1
  18. package/dist/documents/index.js +1 -1
  19. package/dist/host-entry.d.ts +1 -1
  20. package/dist/host-entry.js +1 -1
  21. package/dist/host.d.ts +1 -1
  22. package/dist/host.js +8 -2
  23. package/dist/primitives/Button.svelte +50 -4
  24. package/dist/primitives/Button.svelte.d.ts +3 -1
  25. package/dist/primitives/Collapsible.svelte +110 -0
  26. package/dist/primitives/Collapsible.svelte.d.ts +14 -0
  27. package/dist/primitives/widgets/AppPicker.svelte +41 -0
  28. package/dist/primitives/widgets/AppPicker.svelte.d.ts +9 -0
  29. package/dist/primitives/widgets/AppPicker.svelte.test.d.ts +1 -0
  30. package/dist/primitives/widgets/AppPicker.svelte.test.js +26 -0
  31. package/dist/primitives/widgets/AppPicker.test.d.ts +1 -0
  32. package/dist/primitives/widgets/AppPicker.test.js +74 -0
  33. package/dist/primitives/widgets/ColorSwatch.svelte +7 -2
  34. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +2 -1
  35. package/dist/primitives/widgets/ColorSwatch.svelte.test.d.ts +1 -0
  36. package/dist/primitives/widgets/ColorSwatch.svelte.test.js +31 -0
  37. package/dist/primitives/widgets/Field.svelte +4 -2
  38. package/dist/primitives/widgets/Field.svelte.d.ts +2 -2
  39. package/dist/primitives/widgets/Field.svelte.test.d.ts +1 -0
  40. package/dist/primitives/widgets/Field.svelte.test.js +33 -0
  41. package/dist/primitives/widgets/FilePicker.svelte +2 -2
  42. package/dist/primitives/widgets/FilePicker.svelte.d.ts +2 -2
  43. package/dist/primitives/widgets/FilePicker.svelte.test.d.ts +1 -0
  44. package/dist/primitives/widgets/FilePicker.svelte.test.js +31 -0
  45. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -4
  46. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +3 -3
  47. package/dist/primitives/widgets/IconToggleGroup.svelte.test.d.ts +1 -0
  48. package/dist/primitives/widgets/IconToggleGroup.svelte.test.js +40 -0
  49. package/dist/primitives/widgets/NumberInput.svelte +19 -9
  50. package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -2
  51. package/dist/primitives/widgets/NumberInput.svelte.test.d.ts +1 -0
  52. package/dist/primitives/widgets/NumberInput.svelte.test.js +48 -0
  53. package/dist/primitives/widgets/PickerList.d.ts +24 -0
  54. package/dist/primitives/widgets/PickerList.js +21 -0
  55. package/dist/primitives/widgets/PickerList.svelte +150 -0
  56. package/dist/primitives/widgets/PickerList.svelte.d.ts +16 -0
  57. package/dist/primitives/widgets/PickerList.svelte.test.d.ts +1 -0
  58. package/dist/primitives/widgets/PickerList.svelte.test.js +31 -0
  59. package/dist/primitives/widgets/PickerList.test.d.ts +1 -0
  60. package/dist/primitives/widgets/PickerList.test.js +218 -0
  61. package/dist/primitives/widgets/RangeSlider.svelte +11 -4
  62. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +2 -2
  63. package/dist/primitives/widgets/RangeSlider.svelte.test.d.ts +1 -0
  64. package/dist/primitives/widgets/RangeSlider.svelte.test.js +38 -0
  65. package/dist/primitives/widgets/Segmented.svelte +4 -4
  66. package/dist/primitives/widgets/Segmented.svelte.d.ts +3 -3
  67. package/dist/primitives/widgets/Segmented.svelte.test.d.ts +1 -0
  68. package/dist/primitives/widgets/Segmented.svelte.test.js +25 -0
  69. package/dist/primitives/widgets/Select.svelte +4 -4
  70. package/dist/primitives/widgets/Select.svelte.d.ts +3 -3
  71. package/dist/primitives/widgets/Select.svelte.test.d.ts +1 -0
  72. package/dist/primitives/widgets/Select.svelte.test.js +37 -0
  73. package/dist/primitives/widgets/Slider.svelte +4 -2
  74. package/dist/primitives/widgets/Slider.svelte.d.ts +2 -2
  75. package/dist/primitives/widgets/Slider.svelte.test.d.ts +1 -0
  76. package/dist/primitives/widgets/Slider.svelte.test.js +22 -0
  77. package/dist/primitives/widgets/SliderGroup.svelte +4 -2
  78. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +2 -2
  79. package/dist/primitives/widgets/SliderGroup.svelte.test.d.ts +1 -0
  80. package/dist/primitives/widgets/SliderGroup.svelte.test.js +34 -0
  81. package/dist/primitives/widgets/Textarea.svelte +5 -2
  82. package/dist/primitives/widgets/Textarea.svelte.d.ts +2 -2
  83. package/dist/primitives/widgets/Textarea.svelte.test.d.ts +1 -0
  84. package/dist/primitives/widgets/Textarea.svelte.test.js +29 -0
  85. package/dist/primitives/widgets/UserPicker.svelte +53 -0
  86. package/dist/primitives/widgets/UserPicker.svelte.d.ts +9 -0
  87. package/dist/primitives/widgets/UserPicker.svelte.test.d.ts +1 -0
  88. package/dist/primitives/widgets/UserPicker.svelte.test.js +30 -0
  89. package/dist/primitives/widgets/UserPicker.test.d.ts +1 -0
  90. package/dist/primitives/widgets/UserPicker.test.js +115 -0
  91. package/dist/primitives/widgets/_contract.d.ts +27 -0
  92. package/dist/primitives/widgets/_contract.js +10 -0
  93. package/dist/projects/session-state.svelte.d.ts +17 -0
  94. package/dist/projects/session-state.svelte.js +39 -0
  95. package/dist/projects/session-state.test.d.ts +1 -0
  96. package/dist/projects/session-state.test.js +55 -0
  97. package/dist/projects-shard/DeleteProjectDialog.svelte +150 -0
  98. package/dist/projects-shard/DeleteProjectDialog.svelte.d.ts +12 -0
  99. package/dist/projects-shard/DeleteProjectDialog.test.d.ts +1 -0
  100. package/dist/projects-shard/DeleteProjectDialog.test.js +120 -0
  101. package/dist/projects-shard/ProjectManage.svelte +209 -0
  102. package/dist/projects-shard/ProjectManage.svelte.d.ts +8 -0
  103. package/dist/projects-shard/ProjectsSection.svelte +120 -0
  104. package/dist/projects-shard/ProjectsSection.svelte.d.ts +3 -0
  105. package/dist/projects-shard/index.d.ts +4 -0
  106. package/dist/projects-shard/index.js +4 -0
  107. package/dist/projects-shard/projectsApi.d.ts +20 -0
  108. package/dist/projects-shard/projectsApi.js +44 -0
  109. package/dist/projects-shard/projectsApi.test.d.ts +1 -0
  110. package/dist/projects-shard/projectsApi.test.js +71 -0
  111. package/dist/projects-shard/projectsShard.svelte.d.ts +10 -0
  112. package/dist/projects-shard/projectsShard.svelte.js +148 -0
  113. package/dist/sh3core-shard/ShellHome.svelte +19 -1
  114. package/dist/shards/activate-scopeid.test.d.ts +1 -0
  115. package/dist/shards/{activate-tenantid.test.js → activate-scopeid.test.js} +6 -6
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/package.json +1 -1
  119. /package/dist/{shards/activate-tenantid.test.d.ts → apps/workspace-rekey.test.d.ts} +0 -0
@@ -6,6 +6,9 @@
6
6
  * `icon` accepts either a sprite symbol id ("save") or a direct URL
7
7
  * ending in .svg / containing a slash ("./foo.svg"); the URL form
8
8
  * bypasses the active sprite entirely.
9
+ *
10
+ * `loading` flips the button into a pending state: spinner takes the
11
+ * icon slot, label stays put, button is disabled, aria-busy is set.
9
12
  */
10
13
 
11
14
  import type { Snippet } from 'svelte';
@@ -17,6 +20,7 @@
17
20
  icon,
18
21
  sprite,
19
22
  disabled = false,
23
+ loading,
20
24
  type = 'button',
21
25
  title,
22
26
  ariaLabel,
@@ -29,10 +33,12 @@
29
33
  /** Override the sprite sheet URL for this button only. */
30
34
  sprite?: string;
31
35
  disabled?: boolean;
36
+ /** Controlled pending state. When true, spinner + disabled + aria-busy. */
37
+ loading?: boolean;
32
38
  type?: 'button' | 'submit' | 'reset';
33
39
  title?: string;
34
40
  ariaLabel?: string;
35
- onclick?: (event: MouseEvent) => void;
41
+ onclick?: (event: MouseEvent) => void | Promise<unknown>;
36
42
  children?: Snippet;
37
43
  } = $props();
38
44
 
@@ -49,19 +55,48 @@
49
55
  return `${base}#${icon}`;
50
56
  });
51
57
 
58
+ let autoPending = $state(false);
59
+ const pending = $derived(loading ?? autoPending);
52
60
  const iconOnly = $derived(variant === 'icon' || (!!icon && !children));
61
+
62
+ async function handleClick(event: MouseEvent) {
63
+ if (!onclick) return;
64
+ const result = onclick(event);
65
+ if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
66
+ autoPending = true;
67
+ try {
68
+ await result;
69
+ } finally {
70
+ autoPending = false;
71
+ }
72
+ }
73
+ }
53
74
  </script>
54
75
 
55
76
  <button
56
77
  {type}
57
78
  class="sh3-btn sh3-btn--{variant}"
58
79
  class:sh3-btn--icon-only={iconOnly}
59
- {disabled}
80
+ disabled={disabled || pending}
81
+ aria-busy={pending || undefined}
60
82
  {title}
61
83
  aria-label={ariaLabel ?? (iconOnly ? title : undefined)}
62
- {onclick}
84
+ onclick={handleClick}
63
85
  >
64
- {#if iconHref}
86
+ {#if pending}
87
+ <svg class="sh3-btn__spinner" aria-hidden="true" viewBox="0 0 16 16">
88
+ <circle
89
+ cx="8"
90
+ cy="8"
91
+ r="6"
92
+ fill="none"
93
+ stroke="currentColor"
94
+ stroke-width="2"
95
+ stroke-linecap="round"
96
+ stroke-dasharray="30 10"
97
+ />
98
+ </svg>
99
+ {:else if iconHref}
65
100
  <svg class="sh3-btn__icon" aria-hidden="true">
66
101
  <use href={iconHref} />
67
102
  </svg>
@@ -141,4 +176,15 @@
141
176
  display: inline-flex;
142
177
  white-space: nowrap;
143
178
  }
179
+
180
+ .sh3-btn__spinner {
181
+ width: 16px;
182
+ height: 16px;
183
+ flex-shrink: 0;
184
+ animation: sh3-btn-spin 0.8s linear infinite;
185
+ }
186
+
187
+ @keyframes sh3-btn-spin {
188
+ to { transform: rotate(360deg); }
189
+ }
144
190
  </style>
@@ -7,10 +7,12 @@ type $$ComponentProps = {
7
7
  /** Override the sprite sheet URL for this button only. */
8
8
  sprite?: string;
9
9
  disabled?: boolean;
10
+ /** Controlled pending state. When true, spinner + disabled + aria-busy. */
11
+ loading?: boolean;
10
12
  type?: 'button' | 'submit' | 'reset';
11
13
  title?: string;
12
14
  ariaLabel?: string;
13
- onclick?: (event: MouseEvent) => void;
15
+ onclick?: (event: MouseEvent) => void | Promise<unknown>;
14
16
  children?: Snippet;
15
17
  };
16
18
  declare const Button: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Collapsible — structural primitive for show/hide sections.
4
+ *
5
+ * Body is conditionally rendered (not just hidden) so heavy children
6
+ * don't stay live when collapsed. Defaults to closed. No collapse/expand
7
+ * animation in v1 — caret rotates 90° via CSS, but no transitions
8
+ * (waiting on the motion RFC).
9
+ *
10
+ * `header` snippet, when provided, replaces the `title` string.
11
+ */
12
+
13
+ import type { Snippet } from 'svelte';
14
+
15
+ let {
16
+ open = $bindable(false),
17
+ title,
18
+ header,
19
+ children,
20
+ onopenchange,
21
+ }: {
22
+ /** Bindable; defaults to false. */
23
+ open?: boolean;
24
+ /** Plain text header. Ignored if `header` snippet is provided. */
25
+ title?: string;
26
+ /** Snippet alternative to `title` for richer headers. */
27
+ header?: Snippet;
28
+ children: Snippet;
29
+ onopenchange?: (open: boolean) => void;
30
+ } = $props();
31
+
32
+ function toggle() {
33
+ open = !open;
34
+ onopenchange?.(open);
35
+ }
36
+ </script>
37
+
38
+ <div class="sh3-collapsible" class:sh3-collapsible--open={open}>
39
+ <button
40
+ class="sh3-collapsible__head"
41
+ type="button"
42
+ aria-expanded={open}
43
+ onclick={toggle}
44
+ >
45
+ <svg class="sh3-collapsible__caret" aria-hidden="true" viewBox="0 0 16 16">
46
+ <path
47
+ d="M5 3l6 5-6 5"
48
+ fill="none"
49
+ stroke="currentColor"
50
+ stroke-width="2"
51
+ stroke-linecap="round"
52
+ stroke-linejoin="round"
53
+ />
54
+ </svg>
55
+ {#if header}
56
+ {@render header()}
57
+ {:else if title}
58
+ <span class="sh3-collapsible__title">{title}</span>
59
+ {/if}
60
+ </button>
61
+ {#if open}
62
+ <div class="sh3-collapsible__body">{@render children()}</div>
63
+ {/if}
64
+ </div>
65
+
66
+ <style>
67
+ .sh3-collapsible {
68
+ border: 1px solid var(--shell-border);
69
+ border-radius: var(--shell-radius);
70
+ background: transparent;
71
+ }
72
+ .sh3-collapsible__head {
73
+ appearance: none;
74
+ width: 100%;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ gap: var(--shell-pad-sm);
78
+ padding: var(--shell-pad-sm) 12px;
79
+ background: transparent;
80
+ color: var(--shell-fg);
81
+ border: none;
82
+ border-radius: inherit;
83
+ cursor: pointer;
84
+ font-family: inherit;
85
+ font-size: 0.875rem;
86
+ line-height: var(--shell-line);
87
+ text-align: left;
88
+ }
89
+ .sh3-collapsible__head:hover { background: var(--shell-bg-elevated); }
90
+ .sh3-collapsible__head:focus-visible {
91
+ box-shadow: var(--shell-focus-ring);
92
+ outline: none;
93
+ }
94
+ .sh3-collapsible__caret {
95
+ width: 12px;
96
+ height: 12px;
97
+ flex-shrink: 0;
98
+ color: var(--shell-fg-muted);
99
+ }
100
+ .sh3-collapsible--open .sh3-collapsible__caret {
101
+ transform: rotate(90deg);
102
+ }
103
+ .sh3-collapsible__title {
104
+ flex: 1;
105
+ }
106
+ .sh3-collapsible__body {
107
+ padding: var(--shell-pad-sm) 12px;
108
+ border-top: 1px solid var(--shell-border);
109
+ }
110
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Bindable; defaults to false. */
4
+ open?: boolean;
5
+ /** Plain text header. Ignored if `header` snippet is provided. */
6
+ title?: string;
7
+ /** Snippet alternative to `title` for richer headers. */
8
+ header?: Snippet;
9
+ children: Snippet;
10
+ onopenchange?: (open: boolean) => void;
11
+ };
12
+ declare const Collapsible: import("svelte").Component<$$ComponentProps, {}, "open">;
13
+ type Collapsible = ReturnType<typeof Collapsible>;
14
+ export default Collapsible;
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
3
+ import PickerList from './PickerList.svelte';
4
+ import type { PickerItem } from './PickerList';
5
+ import { listRegisteredApps } from '../../apps/registry.svelte';
6
+
7
+ /** Apps the framework ships and that bypass the project allowlist server-side. */
8
+ const FRAMEWORK_APP_IDS = new Set(['sh3-admin-app', 'sh3-store-app']);
9
+
10
+ let {
11
+ value = $bindable<string[]>([]),
12
+ onchange,
13
+ disabled = false,
14
+ size = 'md',
15
+ }: {
16
+ value?: string[];
17
+ disabled?: boolean;
18
+ size?: 'sm' | 'md';
19
+ } & CommitOnlyEvents<string[]> = $props();
20
+
21
+ const items = $derived<PickerItem[]>(
22
+ listRegisteredApps()
23
+ .filter((m) => !FRAMEWORK_APP_IDS.has(m.id) && !m.admin)
24
+ .map((m) => ({ id: m.id, label: m.label, sublabel: m.id }))
25
+ .sort((a, b) => a.label.localeCompare(b.label)),
26
+ );
27
+
28
+ function handleChange(next: string[]) {
29
+ value = next;
30
+ onchange?.(next);
31
+ }
32
+ </script>
33
+
34
+ <PickerList
35
+ {items}
36
+ {value}
37
+ onchange={handleChange}
38
+ {disabled}
39
+ {size}
40
+ emptyText="No apps installed."
41
+ />
@@ -0,0 +1,9 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ type $$ComponentProps = {
3
+ value?: string[];
4
+ disabled?: boolean;
5
+ size?: 'sm' | 'md';
6
+ } & CommitOnlyEvents<string[]>;
7
+ declare const AppPicker: import("svelte").Component<$$ComponentProps, {}, "value">;
8
+ type AppPicker = ReturnType<typeof AppPicker>;
9
+ export default AppPicker;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import AppPicker from './AppPicker.svelte';
4
+ vi.mock('../../apps/registry.svelte', () => ({
5
+ listRegisteredApps: () => [
6
+ { id: 'sh3-store-app', label: 'Store' },
7
+ { id: 'app.alpha', label: 'Alpha' },
8
+ { id: 'app.bravo', label: 'Bravo', admin: true },
9
+ { id: 'app.charlie', label: 'Charlie' },
10
+ ],
11
+ }));
12
+ describe('AppPicker event contract', () => {
13
+ it('renders only non-framework, non-admin apps', () => {
14
+ const { container } = render(AppPicker, { props: { value: [] } });
15
+ const labels = Array.from(container.querySelectorAll('.sh3-picker__row-label')).map((el) => el.textContent);
16
+ expect(labels).toEqual(['Alpha', 'Charlie']);
17
+ });
18
+ it('fires onchange when an app row is toggled', async () => {
19
+ const onchange = vi.fn();
20
+ const { container } = render(AppPicker, { props: { value: [], onchange } });
21
+ const checkboxes = container.querySelectorAll('input[type=checkbox]');
22
+ await fireEvent.click(checkboxes[0]);
23
+ expect(onchange).toHaveBeenCalledTimes(1);
24
+ expect(onchange).toHaveBeenCalledWith(['app.alpha']);
25
+ });
26
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ import AppPicker from './AppPicker.svelte';
4
+ import { registerApp, __resetAppRegistryForTest } from '../../apps/registry.svelte';
5
+ let host;
6
+ let cmp = null;
7
+ function makeApp(id, label, opts = {}) {
8
+ return {
9
+ manifest: Object.assign({ id, label, version: '0.0.0', requiredShards: [], layoutVersion: 1 }, (opts.admin ? { admin: true } : {})),
10
+ initialLayout: { type: 'leaf', viewId: 'x:v' },
11
+ };
12
+ }
13
+ beforeEach(() => {
14
+ host = document.createElement('div');
15
+ document.body.appendChild(host);
16
+ __resetAppRegistryForTest();
17
+ });
18
+ afterEach(() => {
19
+ if (cmp) {
20
+ unmount(cmp);
21
+ cmp = null;
22
+ }
23
+ host.remove();
24
+ __resetAppRegistryForTest();
25
+ });
26
+ describe('AppPicker', () => {
27
+ it('renders one row per non-framework, non-admin app', async () => {
28
+ registerApp(makeApp('notes', 'Notes'));
29
+ registerApp(makeApp('files', 'Files'));
30
+ registerApp(makeApp('sh3-admin-app', 'Admin')); // framework — excluded
31
+ registerApp(makeApp('sh3-store-app', 'Store')); // framework — excluded
32
+ registerApp(makeApp('shadow', 'Shadow', { admin: true })); // admin flag — excluded
33
+ cmp = mount(AppPicker, { target: host, props: { value: [] } });
34
+ await tick();
35
+ const rows = host.querySelectorAll('.sh3-picker__row');
36
+ expect(rows.length).toBe(2);
37
+ const labels = Array.from(rows).map((r) => { var _a; return (_a = r.querySelector('.sh3-picker__row-label')) === null || _a === void 0 ? void 0 : _a.textContent; }).sort();
38
+ expect(labels).toEqual(['Files', 'Notes']);
39
+ });
40
+ it('passes selected ids through as checked rows', async () => {
41
+ registerApp(makeApp('notes', 'Notes'));
42
+ registerApp(makeApp('files', 'Files'));
43
+ cmp = mount(AppPicker, { target: host, props: { value: ['files'] } });
44
+ await tick();
45
+ const checks = host.querySelectorAll('input[type="checkbox"]');
46
+ const byLabel = (text) => Array.from(checks).find((c) => { var _a, _b; return (_b = (_a = c.closest('.sh3-picker__row')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.includes(text); });
47
+ expect(byLabel('Files').checked).toBe(true);
48
+ expect(byLabel('Notes').checked).toBe(false);
49
+ });
50
+ it('fires onchange with the new array on row click', async () => {
51
+ registerApp(makeApp('notes', 'Notes'));
52
+ registerApp(makeApp('files', 'Files'));
53
+ let received = null;
54
+ cmp = mount(AppPicker, {
55
+ target: host,
56
+ props: {
57
+ value: [],
58
+ onchange: (next) => { received = next; },
59
+ },
60
+ });
61
+ await tick();
62
+ const filesRow = Array.from(host.querySelectorAll('.sh3-picker__row'))
63
+ .find((r) => { var _a; return (_a = r.textContent) === null || _a === void 0 ? void 0 : _a.includes('Files'); });
64
+ filesRow.querySelector('input[type="checkbox"]').click();
65
+ await tick();
66
+ expect(received).toEqual(['files']);
67
+ });
68
+ it('shows emptyText when there are no installable apps', async () => {
69
+ registerApp(makeApp('sh3-admin-app', 'Admin')); // framework — excluded
70
+ cmp = mount(AppPicker, { target: host, props: { value: [] } });
71
+ await tick();
72
+ expect(host.textContent).toMatch(/No apps installed\./);
73
+ });
74
+ });
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
2
3
  import { shell } from '../../shellRuntime.svelte';
3
4
 
4
5
  let {
@@ -6,19 +7,23 @@
6
7
  label,
7
8
  disabled = false,
8
9
  size = 'md',
10
+ onchange,
9
11
  }: {
10
12
  value?: string;
11
13
  label?: string;
12
14
  disabled?: boolean;
13
15
  size?: 'sm' | 'md';
14
- } = $props();
16
+ } & CommitOnlyEvents<string> = $props();
15
17
 
16
18
  let trigger: HTMLButtonElement | undefined;
17
19
 
18
20
  async function open() {
19
21
  if (disabled) return;
20
22
  const result = await shell.color.pick({ initial: value, anchor: trigger });
21
- if (result !== null && result !== undefined) value = result;
23
+ if (result !== null && result !== undefined) {
24
+ value = result;
25
+ onchange?.(result);
26
+ }
22
27
  }
23
28
  </script>
24
29
 
@@ -1,9 +1,10 @@
1
+ import type { CommitOnlyEvents } from './_contract';
1
2
  type $$ComponentProps = {
2
3
  value?: string;
3
4
  label?: string;
4
5
  disabled?: boolean;
5
6
  size?: 'sm' | 'md';
6
- };
7
+ } & CommitOnlyEvents<string>;
7
8
  declare const ColorSwatch: import("svelte").Component<$$ComponentProps, {}, "value">;
8
9
  type ColorSwatch = ReturnType<typeof ColorSwatch>;
9
10
  export default ColorSwatch;
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import ColorSwatch from './ColorSwatch.svelte';
4
+ vi.mock('../../shellRuntime.svelte', () => ({
5
+ shell: {
6
+ color: {
7
+ pick: vi.fn(async () => '#abcdef'),
8
+ },
9
+ },
10
+ }));
11
+ describe('ColorSwatch event contract', () => {
12
+ it('fires onchange with new hex when picker resolves', async () => {
13
+ const onchange = vi.fn();
14
+ const { container } = render(ColorSwatch, { props: { value: '#000000', onchange } });
15
+ const trigger = container.querySelector('.sh3-swatch__btn');
16
+ await fireEvent.click(trigger);
17
+ await new Promise((r) => setTimeout(r, 0));
18
+ expect(onchange).toHaveBeenCalledTimes(1);
19
+ expect(onchange).toHaveBeenCalledWith('#abcdef');
20
+ });
21
+ it('does not fire onchange when picker is cancelled (returns null)', async () => {
22
+ const { shell } = await import('../../shellRuntime.svelte');
23
+ shell.color.pick.mockResolvedValueOnce(null);
24
+ const onchange = vi.fn();
25
+ const { container } = render(ColorSwatch, { props: { value: '#000000', onchange } });
26
+ const trigger = container.querySelector('.sh3-swatch__btn');
27
+ await fireEvent.click(trigger);
28
+ await new Promise((r) => setTimeout(r, 0));
29
+ expect(onchange).not.toHaveBeenCalled();
30
+ });
31
+ });
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import type { LiveInputEvents } from './_contract';
3
4
 
4
5
  let {
5
6
  value = $bindable(''),
@@ -15,6 +16,7 @@
15
16
  size = 'md',
16
17
  required = false,
17
18
  autocomplete,
19
+ oninput,
18
20
  onchange,
19
21
  }: {
20
22
  value?: string;
@@ -30,8 +32,7 @@
30
32
  size?: 'sm' | 'md';
31
33
  required?: boolean;
32
34
  autocomplete?: AutoFill;
33
- onchange?: (next: string) => void;
34
- } = $props();
35
+ } & LiveInputEvents<string> = $props();
35
36
 
36
37
  const showError = $derived(invalid && !!error);
37
38
  const helperText = $derived(showError ? error : helper);
@@ -50,6 +51,7 @@
50
51
  {autocomplete}
51
52
  aria-invalid={invalid || undefined}
52
53
  bind:value
54
+ oninput={() => oninput?.(value)}
53
55
  onblur={() => onchange?.(value)}
54
56
  />
55
57
  {#if suffix}<span class="sh3-field__affix">{@render suffix()}</span>{/if}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { LiveInputEvents } from './_contract';
2
3
  type $$ComponentProps = {
3
4
  value?: string;
4
5
  type?: 'text' | 'email' | 'password' | 'search' | 'url' | 'tel';
@@ -13,8 +14,7 @@ type $$ComponentProps = {
13
14
  size?: 'sm' | 'md';
14
15
  required?: boolean;
15
16
  autocomplete?: AutoFill;
16
- onchange?: (next: string) => void;
17
- };
17
+ } & LiveInputEvents<string>;
18
18
  declare const Field: import("svelte").Component<$$ComponentProps, {}, "value">;
19
19
  type Field = ReturnType<typeof Field>;
20
20
  export default Field;
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
@@ -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);