sh3-core 0.22.2 → 0.23.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 (88) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +1 -1
  3. package/dist/app/admin/adminApp.js +2 -0
  4. package/dist/app/admin/adminShard.svelte.js +1 -0
  5. package/dist/app/store/storeApp.js +3 -1
  6. package/dist/app/store/storeShard.svelte.js +1 -0
  7. package/dist/app-appearance/appearanceShard.svelte.js +1 -0
  8. package/dist/apps/lifecycle.js +22 -10
  9. package/dist/apps/lifecycle.test.js +53 -1
  10. package/dist/apps/types.d.ts +13 -0
  11. package/dist/chrome/CompactChrome.svelte +11 -7
  12. package/dist/createShell.js +40 -0
  13. package/dist/documents/picker-api.test.js +40 -0
  14. package/dist/documents/picker-primitive.d.ts +39 -1
  15. package/dist/documents/picker-primitive.js +5 -4
  16. package/dist/host.js +30 -7
  17. package/dist/layout/slotHostPool.svelte.d.ts +11 -0
  18. package/dist/layout/slotHostPool.svelte.js +41 -17
  19. package/dist/layout/slotHostPool.test.js +45 -1
  20. package/dist/layouts-shard/layoutsShard.svelte.js +1 -0
  21. package/dist/overlays/OverlayRoots.svelte +15 -4
  22. package/dist/overlays/__test__/OverlayBindHarness.svelte +20 -0
  23. package/dist/overlays/__test__/OverlayBindHarness.svelte.d.ts +3 -0
  24. package/dist/overlays/float-compact-bind.svelte.test.d.ts +1 -0
  25. package/dist/overlays/float-compact-bind.svelte.test.js +51 -0
  26. package/dist/overlays/modal.js +3 -0
  27. package/dist/overlays/modal.test.js +45 -0
  28. package/dist/overlays/types.d.ts +9 -0
  29. package/dist/primitives/widgets/Field.svelte +5 -0
  30. package/dist/primitives/widgets/Field.svelte.d.ts +1 -0
  31. package/dist/primitives/widgets/Field.svelte.test.js +16 -0
  32. package/dist/primitives/widgets/NumberInput.svelte +21 -12
  33. package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -0
  34. package/dist/primitives/widgets/NumberInput.svelte.test.js +26 -0
  35. package/dist/primitives/widgets/ShardPicker.svelte +38 -0
  36. package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
  37. package/dist/primitives/widgets/Textarea.svelte +5 -0
  38. package/dist/primitives/widgets/Textarea.svelte.d.ts +1 -0
  39. package/dist/primitives/widgets/Textarea.svelte.test.js +16 -0
  40. package/dist/primitives/widgets/_DocumentBrowser.svelte +11 -3
  41. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +2 -0
  42. package/dist/primitives/widgets/_selectOnFocus.d.ts +15 -0
  43. package/dist/primitives/widgets/_selectOnFocus.js +24 -0
  44. package/dist/projects/scope-gate.d.ts +4 -0
  45. package/dist/projects/scope-gate.js +51 -0
  46. package/dist/projects/scope-gate.test.d.ts +1 -0
  47. package/dist/projects/scope-gate.test.js +92 -0
  48. package/dist/projects-shard/ProjectManage.svelte +42 -2
  49. package/dist/projects-shard/ProjectManage.svelte.test.js +10 -9
  50. package/dist/projects-shard/projectsApi.d.ts +3 -2
  51. package/dist/projects-shard/projectsApi.test.js +1 -1
  52. package/dist/projects-shard/projectsShard.svelte.js +1 -0
  53. package/dist/runtime/runVerb.d.ts +9 -0
  54. package/dist/runtime/runVerb.js +4 -4
  55. package/dist/runtime/runVerb.test.js +29 -0
  56. package/dist/sh3Api/headless.d.ts +7 -0
  57. package/dist/sh3Api/headless.js +3 -1
  58. package/dist/sh3Api/headless.svelte.test.js +42 -0
  59. package/dist/sh3core-shard/Sh3Home.svelte +5 -4
  60. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -0
  61. package/dist/shards/lifecycle.svelte.d.ts +8 -2
  62. package/dist/shards/lifecycle.svelte.js +65 -7
  63. package/dist/shards/lifecycle.test.js +110 -1
  64. package/dist/shards/types.d.ts +13 -0
  65. package/dist/shell-shard/Terminal.svelte +1 -4
  66. package/dist/shell-shard/Terminal.svelte.d.ts +0 -2
  67. package/dist/shell-shard/dispatch.d.ts +0 -2
  68. package/dist/shell-shard/dispatch.js +0 -2
  69. package/dist/shell-shard/display-cwd.test.js +4 -4
  70. package/dist/shell-shard/manifest.js +1 -0
  71. package/dist/shell-shard/shellShard.svelte.d.ts +1 -1
  72. package/dist/shell-shard/shellShard.svelte.js +9 -4
  73. package/dist/shell-shard/verbs/cat.js +3 -3
  74. package/dist/shell-shard/verbs/cat.test.js +1 -2
  75. package/dist/shell-shard/verbs/ls.js +2 -2
  76. package/dist/shell-shard/verbs/ls.test.js +1 -2
  77. package/dist/shell-shard/verbs/mkdir.js +3 -3
  78. package/dist/shell-shard/verbs/mkdir.test.js +1 -2
  79. package/dist/shell-shard/verbs/mv.js +3 -3
  80. package/dist/shell-shard/verbs/mv.test.js +1 -2
  81. package/dist/shell-shard/verbs/rm.js +3 -3
  82. package/dist/shell-shard/verbs/rm.test.js +1 -2
  83. package/dist/shell-shard/verbs/xfer.js +5 -5
  84. package/dist/shell-shard/verbs/xfer.test.js +2 -2
  85. package/dist/verbs/types.d.ts +10 -2
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { tick } from 'svelte';
3
3
  import { resetFramework } from '../__test__/reset';
4
- import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
4
+ import { acquireSlotHost, releaseSlotHost, flushPendingDestroys } from './slotHostPool.svelte';
5
5
  import { registerView } from '../shards/registry';
6
6
  import SlotContainer from './SlotContainer.svelte';
7
7
  import { renderWithShell } from '../__test__/render';
@@ -102,6 +102,50 @@ describe('slotHostPool — D.5 root swap preserves app slots', () => {
102
102
  expect(teardown).not.toHaveBeenCalled();
103
103
  });
104
104
  });
105
+ // ─── D.9 ─────────────────────────────────────────────────────────────────────
106
+ describe('slotHostPool — D.9 flushPendingDestroys synchronously unmounts', () => {
107
+ beforeEach(resetFramework);
108
+ it('synchronously unmounts pooled hosts whose refcount has reached 0', async () => {
109
+ const teardown = vi.fn();
110
+ registerView('flush:view', { mount: () => ({ unmount: teardown }) });
111
+ // Acquire a slot host, then drop refcount to 0 — this enters pendingDestroy
112
+ // but does NOT unmount until the next microtask (existing behavior).
113
+ acquireSlotHost('flush-slot', 'flush:view', 'Flush View');
114
+ // The view-factory mount is itself deferred to a microtask; let it run
115
+ // so the pool entry has a real `handle` to unmount.
116
+ await Promise.resolve();
117
+ releaseSlotHost('flush-slot');
118
+ // The destroy microtask has NOT yet run, so teardown has not been called.
119
+ expect(teardown).not.toHaveBeenCalled();
120
+ // flushPendingDestroys runs the destroy body now, synchronously.
121
+ flushPendingDestroys();
122
+ expect(teardown).toHaveBeenCalledTimes(1);
123
+ });
124
+ it('does not unmount entries whose refcount is still > 0', async () => {
125
+ const teardown = vi.fn();
126
+ registerView('keep:view', { mount: () => ({ unmount: teardown }) });
127
+ acquireSlotHost('keep-slot', 'keep:view', 'Keep View');
128
+ await Promise.resolve();
129
+ // Refcount is 1, never released.
130
+ flushPendingDestroys();
131
+ expect(teardown).not.toHaveBeenCalled();
132
+ // Cleanup
133
+ releaseSlotHost('keep-slot');
134
+ });
135
+ it('clears pendingDestroy so the queued microtask is a no-op', async () => {
136
+ const teardown = vi.fn();
137
+ registerView('once:view', { mount: () => ({ unmount: teardown }) });
138
+ acquireSlotHost('once-slot', 'once:view', 'Once View');
139
+ await Promise.resolve();
140
+ releaseSlotHost('once-slot');
141
+ flushPendingDestroys();
142
+ expect(teardown).toHaveBeenCalledTimes(1);
143
+ // The previously-queued microtask should now find pendingDestroy empty
144
+ // for this slot and do nothing. teardown stays at 1.
145
+ await Promise.resolve();
146
+ expect(teardown).toHaveBeenCalledTimes(1);
147
+ });
148
+ });
105
149
  // ─── D.6 ─────────────────────────────────────────────────────────────────────
106
150
  describe('slotHostPool — D.6 data-sh3-view attribute', () => {
107
151
  beforeEach(resetFramework);
@@ -135,6 +135,7 @@ export const layoutsShard = {
135
135
  id: '__layouts__',
136
136
  label: 'Saved Layouts',
137
137
  version: VERSION,
138
+ kind: 'system',
138
139
  views: [],
139
140
  },
140
141
  register(ctx) {
@@ -14,6 +14,7 @@
14
14
  * they portal in.
15
15
  */
16
16
 
17
+ import { untrack } from 'svelte';
17
18
  import DragPreview from '../layout/DragPreview.svelte';
18
19
  import FloatLayer from './FloatLayer.svelte';
19
20
  import { registerLayerRoot, unregisterLayerRoot } from './roots';
@@ -47,12 +48,22 @@
47
48
  };
48
49
  });
49
50
 
51
+ // Re-bind only when the active LayoutTree itself changes (app/preset
52
+ // switch). bindFloatStore iterates `floats` to clamp persisted entries,
53
+ // which would otherwise subscribe this effect to every push/splice from
54
+ // floatManager — and bindFloatStore unconditionally calls
55
+ // compactRootStore.reset() at the end, so a re-fire on push wipes the
56
+ // auto-focus that openFloat() set milliseconds earlier (compact-body
57
+ // would stay on docked after the first ²-press; see float-compact-bind
58
+ // regression test).
50
59
  $effect(() => {
51
60
  const tree = layoutStore.tree;
52
- bindFloatStore(tree.floats, () => ({
53
- w: window.innerWidth,
54
- h: window.innerHeight,
55
- }));
61
+ untrack(() => {
62
+ bindFloatStore(tree.floats, () => ({
63
+ w: window.innerWidth,
64
+ h: window.innerHeight,
65
+ }));
66
+ });
56
67
  return () => unbindFloatStore();
57
68
  });
58
69
  </script>
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Test harness — mirrors the $effect block in OverlayRoots.svelte that
4
+ * binds the float manager to the active LayoutTree's floats array.
5
+ * Keeps the `untrack` shape so the harness validates the production
6
+ * fix: the bind should NOT re-fire on float push/splice (which would
7
+ * trip compactRootStore.reset() and wipe auto-focus from openFloat).
8
+ */
9
+ import { untrack } from 'svelte';
10
+ import { layoutStore } from '../../layout/store.svelte';
11
+ import { bindFloatStore, unbindFloatStore } from '../float';
12
+
13
+ $effect(() => {
14
+ const tree = layoutStore.tree;
15
+ untrack(() => {
16
+ bindFloatStore(tree.floats, () => ({ w: 360, h: 740 }));
17
+ });
18
+ return () => unbindFloatStore();
19
+ });
20
+ </script>
@@ -0,0 +1,3 @@
1
+ declare const OverlayBindHarness: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type OverlayBindHarness = ReturnType<typeof OverlayBindHarness>;
3
+ export default OverlayBindHarness;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Regression: in compact mode, floatManager.open() must auto-switch the
3
+ * compact body to the new float. The setRoot call inside openFloat is
4
+ * correct, but a reactive $effect that re-binds the float store on every
5
+ * iteration of `tree.floats` re-runs the moment the array is mutated,
6
+ * and bindFloatStore unconditionally calls compactRootStore.reset() —
7
+ * wiping the auto-focus that openFloat just set.
8
+ *
9
+ * This test mounts a tiny wrapper that mirrors the OverlayRoots $effect
10
+ * (read layoutStore.tree, call bindFloatStore on every run) and asserts
11
+ * compactRootStore is still pointing at the new float after open().
12
+ */
13
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14
+ import { mount, unmount, flushSync } from 'svelte';
15
+ import { floatManager, unbindFloatStore, __resetFloatManagerForTest, } from './float';
16
+ import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
17
+ import { __resetLayoutStoreForTest } from '../layout/store.svelte';
18
+ import { viewportStore } from '../viewport/store.svelte';
19
+ import OverlayBindHarness from './__test__/OverlayBindHarness.svelte';
20
+ describe('floatManager.open in compact — preserves setRoot under reactive bind', () => {
21
+ let mounted = null;
22
+ let host = null;
23
+ beforeEach(() => {
24
+ __resetFloatManagerForTest();
25
+ __resetCompactRootStoreForTest();
26
+ __resetLayoutStoreForTest();
27
+ viewportStore.override('compact');
28
+ });
29
+ afterEach(() => {
30
+ if (mounted) {
31
+ unmount(mounted);
32
+ mounted = null;
33
+ }
34
+ if (host) {
35
+ host.remove();
36
+ host = null;
37
+ }
38
+ viewportStore.override(null);
39
+ unbindFloatStore();
40
+ });
41
+ it('opens a float and the compact body root points to it after the bind effect settles', () => {
42
+ host = document.createElement('div');
43
+ document.body.appendChild(host);
44
+ mounted = mount(OverlayBindHarness, { target: host });
45
+ flushSync();
46
+ const id = floatManager.open('test:view', { title: 'Notes' });
47
+ // Allow any reactive re-bind to run.
48
+ flushSync();
49
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
50
+ });
51
+ });
@@ -96,6 +96,7 @@ function removeEscapeListenerIfIdle() {
96
96
  document.removeEventListener('keydown', onDocumentKeydown, true);
97
97
  }
98
98
  function removeEntry(entry) {
99
+ var _a;
99
100
  const idx = stack.indexOf(entry);
100
101
  if (idx < 0)
101
102
  return; // already closed — idempotent
@@ -104,6 +105,7 @@ function removeEntry(entry) {
104
105
  entry.host.remove();
105
106
  syncBackdrop();
106
107
  removeEscapeListenerIfIdle();
108
+ (_a = entry.onClose) === null || _a === void 0 ? void 0 : _a.call(entry);
107
109
  }
108
110
  function openModal(Content, props, options) {
109
111
  const root = getLayerRoot('modal');
@@ -138,6 +140,7 @@ function openModal(Content, props, options) {
138
140
  entry.host = host;
139
141
  entry.frame = frame;
140
142
  entry.handle = handle;
143
+ entry.onClose = options === null || options === void 0 ? void 0 : options.onClose;
141
144
  stack.push(entry);
142
145
  syncBackdrop();
143
146
  ensureEscapeListener();
@@ -88,6 +88,51 @@ describe('modal — back-cascade integration', () => {
88
88
  expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
89
89
  });
90
90
  });
91
+ describe('modal — onClose callback', () => {
92
+ let layerRoot;
93
+ beforeEach(() => {
94
+ layerRoot = makeLayerRoot();
95
+ });
96
+ afterEach(() => {
97
+ modalManager.closeAll();
98
+ teardownLayerRoot(layerRoot);
99
+ });
100
+ it('fires onClose when handle.close() is called', async () => {
101
+ let calls = 0;
102
+ const handle = modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
103
+ await tick();
104
+ handle.close();
105
+ await tick();
106
+ expect(calls).toBe(1);
107
+ });
108
+ it('fires onClose on backdrop dismissal', async () => {
109
+ let calls = 0;
110
+ modalManager.open(DummyFrame, {}, { dismissOnBackdrop: true, onClose: () => calls++ });
111
+ await tick();
112
+ const frame = layerRoot.querySelector('.modal-frame');
113
+ frame.dispatchEvent(new MouseEvent('click', { bubbles: true }));
114
+ await tick();
115
+ expect(calls).toBe(1);
116
+ });
117
+ it('fires onClose on closeAll', async () => {
118
+ let calls = 0;
119
+ modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
120
+ modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
121
+ await tick();
122
+ modalManager.closeAll();
123
+ await tick();
124
+ expect(calls).toBe(2);
125
+ });
126
+ it('does not fire onClose twice when close() is called repeatedly (idempotent)', async () => {
127
+ let calls = 0;
128
+ const handle = modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
129
+ await tick();
130
+ handle.close();
131
+ handle.close();
132
+ await tick();
133
+ expect(calls).toBe(1);
134
+ });
135
+ });
91
136
  describe('modal — overlay host marker', () => {
92
137
  let layerRoot;
93
138
  beforeEach(() => {
@@ -53,4 +53,13 @@ export interface ModalOptions {
53
53
  * palette on touch-only devices).
54
54
  */
55
55
  initialFocus?: boolean;
56
+ /**
57
+ * Invoked after the modal has been torn down — from any dismissal
58
+ * path (handle.close(), Escape, backdrop click, closeAll). Use this
59
+ * to reset caller-side "is open" state instead of wrapping
60
+ * handle.close after open() returns: by then the manager has already
61
+ * passed handle.close by reference to ModalFrame and onBackdropClick,
62
+ * so post-hoc wraps would be bypassed by every real dismissal.
63
+ */
64
+ onClose?: () => void;
56
65
  }
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { LiveInputEvents } from './_contract';
4
+ import { selectOnPointerDown, selectOnFocusEvent } from './_selectOnFocus';
4
5
 
5
6
  let {
6
7
  value = $bindable(''),
@@ -16,6 +17,7 @@
16
17
  size = 'md',
17
18
  required = false,
18
19
  autocomplete,
20
+ selectOnFocus = false,
19
21
  oninput,
20
22
  onchange,
21
23
  }: {
@@ -32,6 +34,7 @@
32
34
  size?: 'sm' | 'md';
33
35
  required?: boolean;
34
36
  autocomplete?: AutoFill;
37
+ selectOnFocus?: boolean;
35
38
  } & LiveInputEvents<string> = $props();
36
39
 
37
40
  const showError = $derived(invalid && !!error);
@@ -53,6 +56,8 @@
53
56
  bind:value
54
57
  oninput={() => oninput?.(value)}
55
58
  onblur={() => onchange?.(value)}
59
+ onpointerdown={selectOnFocus ? selectOnPointerDown : undefined}
60
+ onfocus={selectOnFocus ? selectOnFocusEvent : undefined}
56
61
  />
57
62
  {#if suffix}<span class="sh3-field__affix">{@render suffix()}</span>{/if}
58
63
  </span>
@@ -14,6 +14,7 @@ type $$ComponentProps = {
14
14
  size?: 'sm' | 'md';
15
15
  required?: boolean;
16
16
  autocomplete?: AutoFill;
17
+ selectOnFocus?: boolean;
17
18
  } & LiveInputEvents<string>;
18
19
  declare const Field: import("svelte").Component<$$ComponentProps, {}, "value">;
19
20
  type Field = ReturnType<typeof Field>;
@@ -31,3 +31,19 @@ describe('Field event contract', () => {
31
31
  expect(getByText('Required')).toBeInTheDocument();
32
32
  });
33
33
  });
34
+ describe('Field selectOnFocus opt-in', () => {
35
+ it('does not select on focus by default', async () => {
36
+ const { container } = render(Field, { props: { value: 'hello' } });
37
+ const input = container.querySelector('input');
38
+ const spy = vi.spyOn(input, 'select');
39
+ await fireEvent.focus(input);
40
+ expect(spy).not.toHaveBeenCalled();
41
+ });
42
+ it('selects on focus when selectOnFocus is true', async () => {
43
+ const { container } = render(Field, { props: { value: 'hello', selectOnFocus: true } });
44
+ const input = container.querySelector('input');
45
+ const spy = vi.spyOn(input, 'select');
46
+ await fireEvent.focus(input);
47
+ expect(spy).toHaveBeenCalledTimes(1);
48
+ });
49
+ });
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { LiveInputEvents } from './_contract';
4
4
  import { clamp, applyStep } from './NumberInput';
5
+ import { selectOnPointerDown, selectOnFocusEvent } from './_selectOnFocus';
5
6
 
6
7
  let {
7
8
  value = $bindable(0),
@@ -15,6 +16,8 @@
15
16
  disabled = false,
16
17
  invalid = false,
17
18
  size = 'md',
19
+ steppers = false,
20
+ selectOnFocus = false,
18
21
  oninput,
19
22
  onchange,
20
23
  }: {
@@ -29,6 +32,8 @@
29
32
  disabled?: boolean;
30
33
  invalid?: boolean;
31
34
  size?: 'sm' | 'md';
35
+ steppers?: boolean;
36
+ selectOnFocus?: boolean;
32
37
  } & LiveInputEvents<number> = $props();
33
38
 
34
39
  function commit(next: number) {
@@ -95,20 +100,24 @@
95
100
  commit(value);
96
101
  onchange?.(value);
97
102
  }}
103
+ onpointerdown={selectOnFocus ? selectOnPointerDown : undefined}
104
+ onfocus={selectOnFocus ? selectOnFocusEvent : undefined}
98
105
  />
99
106
  {#if suffix}<span class="sh3-num__affix">{@render suffix()}</span>{/if}
100
- <span class="sh3-num__steppers">
101
- <button type="button" {disabled}
102
- onpointerdown={() => startHold(1)}
103
- onpointerup={stopHold}
104
- onpointerleave={stopHold}
105
- aria-label="Increase">▲</button>
106
- <button type="button" {disabled}
107
- onpointerdown={() => startHold(-1)}
108
- onpointerup={stopHold}
109
- onpointerleave={stopHold}
110
- aria-label="Decrease">▼</button>
111
- </span>
107
+ {#if steppers}
108
+ <span class="sh3-num__steppers">
109
+ <button type="button" {disabled}
110
+ onpointerdown={() => startHold(1)}
111
+ onpointerup={stopHold}
112
+ onpointerleave={stopHold}
113
+ aria-label="Increase">▲</button>
114
+ <button type="button" {disabled}
115
+ onpointerdown={() => startHold(-1)}
116
+ onpointerup={stopHold}
117
+ onpointerleave={stopHold}
118
+ aria-label="Decrease">▼</button>
119
+ </span>
120
+ {/if}
112
121
  </span>
113
122
  </label>
114
123
 
@@ -12,6 +12,8 @@ type $$ComponentProps = {
12
12
  disabled?: boolean;
13
13
  invalid?: boolean;
14
14
  size?: 'sm' | 'md';
15
+ steppers?: boolean;
16
+ selectOnFocus?: boolean;
15
17
  } & LiveInputEvents<number>;
16
18
  declare const NumberInput: import("svelte").Component<$$ComponentProps, {}, "value">;
17
19
  type NumberInput = ReturnType<typeof NumberInput>;
@@ -46,3 +46,29 @@ describe('NumberInput event contract', () => {
46
46
  expect(onchange.mock.calls[0][0]).toBeCloseTo(1.23, 5);
47
47
  });
48
48
  });
49
+ describe('NumberInput steppers opt-in', () => {
50
+ it('hides ▲▼ buttons by default', () => {
51
+ const { container } = render(NumberInput, { props: { value: 0 } });
52
+ expect(container.querySelectorAll('button').length).toBe(0);
53
+ });
54
+ it('renders ▲▼ buttons when steppers is true', () => {
55
+ const { container } = render(NumberInput, { props: { value: 0, steppers: true } });
56
+ expect(container.querySelectorAll('button').length).toBe(2);
57
+ });
58
+ });
59
+ describe('NumberInput selectOnFocus opt-in', () => {
60
+ it('does not select on focus by default', async () => {
61
+ const { container } = render(NumberInput, { props: { value: 42 } });
62
+ const input = container.querySelector('input');
63
+ const spy = vi.spyOn(input, 'select');
64
+ await fireEvent.focus(input);
65
+ expect(spy).not.toHaveBeenCalled();
66
+ });
67
+ it('selects on focus when selectOnFocus is true', async () => {
68
+ const { container } = render(NumberInput, { props: { value: 42, selectOnFocus: true } });
69
+ const input = container.querySelector('input');
70
+ const spy = vi.spyOn(input, 'select');
71
+ await fireEvent.focus(input);
72
+ expect(spy).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
3
+ import PickerList from './PickerList.svelte';
4
+ import type { PickerItem } from './PickerList';
5
+ import { listRegisteredShards } from '../../shards/lifecycle.svelte';
6
+
7
+ let {
8
+ value = $bindable<string[]>([]),
9
+ onchange,
10
+ disabled = false,
11
+ size = 'md',
12
+ }: {
13
+ value?: string[];
14
+ disabled?: boolean;
15
+ size?: 'sm' | 'md';
16
+ } & CommitOnlyEvents<string[]> = $props();
17
+
18
+ const items = $derived<PickerItem[]>(
19
+ listRegisteredShards()
20
+ .filter((m) => m.kind === 'service')
21
+ .map((m) => ({ id: m.id, label: m.label, sublabel: m.id }))
22
+ .sort((a, b) => a.label.localeCompare(b.label)),
23
+ );
24
+
25
+ function handleChange(next: string[]) {
26
+ value = next;
27
+ onchange?.(next);
28
+ }
29
+ </script>
30
+
31
+ <PickerList
32
+ {items}
33
+ {value}
34
+ onchange={handleChange}
35
+ {disabled}
36
+ {size}
37
+ emptyText="No service shards installed."
38
+ />
@@ -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 ShardPicker: import("svelte").Component<$$ComponentProps, {}, "value">;
8
+ type ShardPicker = ReturnType<typeof ShardPicker>;
9
+ export default ShardPicker;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { LiveInputEvents } from './_contract';
3
+ import { selectOnPointerDown, selectOnFocusEvent } from './_selectOnFocus';
3
4
 
4
5
  let {
5
6
  value = $bindable(''),
@@ -13,6 +14,7 @@
13
14
  required = false,
14
15
  rows = 3,
15
16
  resize = 'vertical',
17
+ selectOnFocus = false,
16
18
  oninput,
17
19
  onchange,
18
20
  }: {
@@ -27,6 +29,7 @@
27
29
  required?: boolean;
28
30
  rows?: number;
29
31
  resize?: 'none' | 'vertical' | 'both';
32
+ selectOnFocus?: boolean;
30
33
  } & LiveInputEvents<string> = $props();
31
34
 
32
35
  const showError = $derived(invalid && !!error);
@@ -46,6 +49,8 @@
46
49
  bind:value
47
50
  oninput={() => oninput?.(value)}
48
51
  onblur={() => onchange?.(value)}
52
+ onpointerdown={selectOnFocus ? selectOnPointerDown : undefined}
53
+ onfocus={selectOnFocus ? selectOnFocusEvent : undefined}
49
54
  ></textarea>
50
55
  {#if helperText}<span class="sh3-textarea__helper" class:sh3-textarea__helper--error={showError}>{helperText}</span>{/if}
51
56
  </label>
@@ -11,6 +11,7 @@ type $$ComponentProps = {
11
11
  required?: boolean;
12
12
  rows?: number;
13
13
  resize?: 'none' | 'vertical' | 'both';
14
+ selectOnFocus?: boolean;
14
15
  } & LiveInputEvents<string>;
15
16
  declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value">;
16
17
  type Textarea = ReturnType<typeof Textarea>;
@@ -27,3 +27,19 @@ describe('Textarea event contract', () => {
27
27
  expect(ta.style.resize).toBe('none');
28
28
  });
29
29
  });
30
+ describe('Textarea selectOnFocus opt-in', () => {
31
+ it('does not select on focus by default', async () => {
32
+ const { container } = render(Textarea, { props: { value: 'hello' } });
33
+ const ta = container.querySelector('textarea');
34
+ const spy = vi.spyOn(ta, 'select');
35
+ await fireEvent.focus(ta);
36
+ expect(spy).not.toHaveBeenCalled();
37
+ });
38
+ it('selects on focus when selectOnFocus is true', async () => {
39
+ const { container } = render(Textarea, { props: { value: 'hello', selectOnFocus: true } });
40
+ const ta = container.querySelector('textarea');
41
+ const spy = vi.spyOn(ta, 'select');
42
+ await fireEvent.focus(ta);
43
+ expect(spy).toHaveBeenCalledTimes(1);
44
+ });
45
+ });
@@ -26,6 +26,8 @@
26
26
  listFolders,
27
27
  handle,
28
28
  readOnlyShard,
29
+ initialShardId = null,
30
+ lockToShard = false,
29
31
  }: {
30
32
  mode: 'open' | 'save';
31
33
  docs: DocEntry[];
@@ -43,6 +45,8 @@
43
45
  delete: (shardId: string, path: string) => Promise<void>;
44
46
  };
45
47
  readOnlyShard?: (shardId: string) => boolean;
48
+ initialShardId?: string | null;
49
+ lockToShard?: boolean;
46
50
  } = $props();
47
51
 
48
52
  type Selected =
@@ -50,7 +54,7 @@
50
54
  | { kind: 'folder'; fullPath: string; name: string }
51
55
  | null;
52
56
 
53
- let shardId = $state<string | null>(null);
57
+ let shardId = $state<string | null>(untrack(() => initialShardId));
54
58
  let prefix = $state('');
55
59
  let selected = $state<Selected>(null);
56
60
  let filename = $state(untrack(() => suggestedName));
@@ -76,7 +80,11 @@
76
80
  });
77
81
 
78
82
  const items = $derived(buildTree(docs, folders, shardId, prefix));
79
- const crumbs = $derived(breadcrumbSegments(shardId, prefix));
83
+ const crumbs = $derived(
84
+ lockToShard
85
+ ? breadcrumbSegments(shardId, prefix).filter((c) => c.level > 0)
86
+ : breadcrumbSegments(shardId, prefix),
87
+ );
80
88
 
81
89
  $effect(() => {
82
90
  items;
@@ -352,7 +360,7 @@
352
360
  parts.pop();
353
361
  prefix = parts.join('/');
354
362
  activeIdx = 0;
355
- } else if (shardId) {
363
+ } else if (shardId && !lockToShard) {
356
364
  e.preventDefault();
357
365
  shardId = null;
358
366
  activeIdx = 0;
@@ -18,6 +18,8 @@ type $$ComponentProps = {
18
18
  delete: (shardId: string, path: string) => Promise<void>;
19
19
  };
20
20
  readOnlyShard?: (shardId: string) => boolean;
21
+ initialShardId?: string | null;
22
+ lockToShard?: boolean;
21
23
  };
22
24
  declare const DocumentBrowser: import("svelte").Component<$$ComponentProps, {}, "">;
23
25
  type DocumentBrowser = ReturnType<typeof DocumentBrowser>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared "select content on focus" opt-in handlers for keystroke widgets
3
+ * (Field, Textarea, NumberInput).
4
+ *
5
+ * Two paths:
6
+ * - pointer click on an unfocused input: preventDefault on pointerdown to
7
+ * suppress native caret placement, then focus() + select() ourselves.
8
+ * Clicking again while already focused falls through to normal caret
9
+ * placement, so users can still edit a single character.
10
+ * - tab / label click: onfocus selects all.
11
+ *
12
+ * Internal — not exported from api.ts.
13
+ */
14
+ export declare function selectOnPointerDown(e: PointerEvent): void;
15
+ export declare function selectOnFocusEvent(e: FocusEvent): void;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared "select content on focus" opt-in handlers for keystroke widgets
3
+ * (Field, Textarea, NumberInput).
4
+ *
5
+ * Two paths:
6
+ * - pointer click on an unfocused input: preventDefault on pointerdown to
7
+ * suppress native caret placement, then focus() + select() ourselves.
8
+ * Clicking again while already focused falls through to normal caret
9
+ * placement, so users can still edit a single character.
10
+ * - tab / label click: onfocus selects all.
11
+ *
12
+ * Internal — not exported from api.ts.
13
+ */
14
+ export function selectOnPointerDown(e) {
15
+ const el = e.currentTarget;
16
+ if (document.activeElement !== el) {
17
+ e.preventDefault();
18
+ el.focus();
19
+ el.select();
20
+ }
21
+ }
22
+ export function selectOnFocusEvent(e) {
23
+ e.currentTarget.select();
24
+ }
@@ -0,0 +1,4 @@
1
+ import type { App } from '../apps/types';
2
+ import type { ProjectRecord } from '../projects-shard/projectsApi';
3
+ import type { ShardManifest } from '../shards/types';
4
+ export declare function resolveAllowedShardIds(project: ProjectRecord | null, apps: ReadonlyMap<string, App>, shardManifests: readonly ShardManifest[]): Set<string> | null;