sh3-core 0.19.3 → 0.19.6

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 (43) hide show
  1. package/dist/api.d.ts +5 -0
  2. package/dist/api.js +3 -0
  3. package/dist/chrome/CompactChrome.svelte +34 -1
  4. package/dist/chrome/CompactChrome.svelte.test.js +4 -2
  5. package/dist/chrome/FloatsSheet.svelte +236 -0
  6. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  7. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  8. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  9. package/dist/documents/picker-api.d.ts +31 -0
  10. package/dist/documents/picker-api.js +1 -0
  11. package/dist/documents/picker-api.test.d.ts +1 -0
  12. package/dist/documents/picker-api.test.js +132 -0
  13. package/dist/documents/picker-primitive.d.ts +7 -0
  14. package/dist/documents/picker-primitive.js +58 -0
  15. package/dist/layout/compact/CompactRenderer.svelte +8 -2
  16. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  17. package/dist/layout/compact/rootStore.svelte.js +59 -0
  18. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  19. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  20. package/dist/layout/floats.d.ts +27 -0
  21. package/dist/layout/floats.js +20 -0
  22. package/dist/layout/floats.test.js +34 -1
  23. package/dist/layout/inspection.js +25 -2
  24. package/dist/layout/inspection.svelte.test.js +49 -0
  25. package/dist/overlays/FloatLayer.svelte +12 -1
  26. package/dist/overlays/float.d.ts +7 -0
  27. package/dist/overlays/float.js +76 -6
  28. package/dist/overlays/float.test.js +170 -0
  29. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  30. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  31. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  32. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  33. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  34. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  35. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  36. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  37. package/dist/primitives/widgets/_DocumentBrowser.svelte +339 -0
  38. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
  39. package/dist/shards/activate.svelte.js +23 -14
  40. package/dist/shards/types.d.ts +8 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
@@ -26,21 +26,58 @@
26
26
  * in-memory fallback array is used — this is both the test environment
27
27
  * and the pre-boot state.
28
28
  */
29
- import { computeMinSize, cascadePosition, generateFloatId } from '../layout/floats';
29
+ import { computeMinSize, cascadePosition, generateFloatId, clampFloatToViewport, } from '../layout/floats';
30
30
  import { findEnclosingOverlayHost } from './parentHost';
31
+ import { compactRootStore } from '../layout/compact/rootStore.svelte';
32
+ import { viewportStore } from '../viewport/store.svelte';
31
33
  import { setMaximizedReactive, readMaximizedReactive, __resetMaximizedReactiveForTest, } from './floatMaximized.svelte';
32
34
  // ----- storage binding ---------------------------------------------------
33
35
  let fallbackFloats = [];
34
36
  let boundFloats = null;
35
37
  let getTreeBounds = () => ({ w: 1600, h: 900 });
38
+ /**
39
+ * Float ids that have already been pulled into the viewport — either at
40
+ * open() time, or on the bind that first observed them as persisted.
41
+ * Subsequent binds (viewport-class swap, preset switch back-and-forth)
42
+ * skip these so a user-positioned float isn't snapped on every re-bind.
43
+ *
44
+ * Ids are time+counter unique per session, so this set never confuses a
45
+ * recycled id; it grows for the lifetime of the session.
46
+ */
47
+ const clampedIds = new Set();
48
+ function clampFloatRect(entry, bounds) {
49
+ const minSize = computeMinSize(entry.content);
50
+ const clamped = clampFloatToViewport({ position: entry.position, size: entry.size }, minSize, bounds);
51
+ entry.position.x = clamped.position.x;
52
+ entry.position.y = clamped.position.y;
53
+ entry.size.w = clamped.size.w;
54
+ entry.size.h = clamped.size.h;
55
+ }
36
56
  /**
37
57
  * Bind the manager to the active LayoutTree's `floats` array. Called
38
58
  * from Sh3.svelte during boot. `getBounds` returns the current
39
59
  * tree-allocated area for cascade-position wraparound.
60
+ *
61
+ * Persisted floats observed for the first time are pulled into the
62
+ * supplied viewport — without this, a float whose position/size came
63
+ * from a larger window renders past the overlay root, which Firefox
64
+ * paints by growing the parent and visibly shifts the docked grid.
65
+ * Ids already in `clampedIds` (re-bound on viewport-class swap, etc.)
66
+ * are left alone.
40
67
  */
41
68
  export function bindFloatStore(floats, getBounds) {
42
69
  boundFloats = floats;
43
70
  getTreeBounds = getBounds;
71
+ const bounds = getBounds();
72
+ for (const entry of floats) {
73
+ if (clampedIds.has(entry.id))
74
+ continue;
75
+ clampFloatRect(entry, bounds);
76
+ clampedIds.add(entry.id);
77
+ }
78
+ // Active tree changed (app/preset switch) — drop any compact body
79
+ // selection so the user lands on the new docked tree.
80
+ compactRootStore.reset();
44
81
  }
45
82
  export function unbindFloatStore() {
46
83
  boundFloats = null;
@@ -53,6 +90,7 @@ export function __resetFloatManagerForTest() {
53
90
  getTreeBounds = () => ({ w: 1600, h: 900 });
54
91
  parentHosts.clear();
55
92
  maximizedRects.clear();
93
+ clampedIds.clear();
56
94
  __resetMaximizedReactiveForTest();
57
95
  }
58
96
  function activeStore() {
@@ -80,8 +118,16 @@ function mintFloatSlotId(viewId) {
80
118
  }
81
119
  // ----- API ---------------------------------------------------------------
82
120
  const DEFAULT_SIZE = { w: 600, h: 400 };
83
- function maxSize(a, b) {
84
- return { w: Math.max(a.w, b.w), h: Math.max(a.h, b.h) };
121
+ /**
122
+ * The default opening size: prefer DEFAULT_SIZE, but cap each axis at
123
+ * the current viewport so phones don't get a 600×400 float on a 360×800
124
+ * screen. Never shrunk below the content's computed minimum.
125
+ */
126
+ function defaultOpenSize(min, bounds) {
127
+ return {
128
+ w: Math.max(min.w, Math.min(DEFAULT_SIZE.w, bounds.w)),
129
+ h: Math.max(min.h, Math.min(DEFAULT_SIZE.h, bounds.h)),
130
+ };
85
131
  }
86
132
  function openFloat(viewId, options = {}) {
87
133
  var _a, _b, _c;
@@ -118,8 +164,9 @@ function openFloat(viewId, options = {}) {
118
164
  };
119
165
  }
120
166
  const computedMin = computeMinSize(content);
121
- const size = (_b = options.size) !== null && _b !== void 0 ? _b : maxSize(DEFAULT_SIZE, computedMin);
122
- const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, getTreeBounds());
167
+ const bounds = getTreeBounds();
168
+ const size = (_b = options.size) !== null && _b !== void 0 ? _b : defaultOpenSize(computedMin, bounds);
169
+ const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, bounds);
123
170
  const entry = {
124
171
  id,
125
172
  content,
@@ -134,14 +181,26 @@ function openFloat(viewId, options = {}) {
134
181
  if (host)
135
182
  parentHosts.set(id, host);
136
183
  }
184
+ // Pull the chosen rect into the current viewport (handles user-supplied
185
+ // off-screen positions and oversized user-supplied sizes).
186
+ clampFloatRect(entry, bounds);
187
+ // Mark already-clamped so the next bind doesn't snap it again.
188
+ clampedIds.add(id);
137
189
  store.push(entry);
190
+ // Compact mode demotes non-dismissable floats from overlays to body-or-menu;
191
+ // a fresh open auto-switches the body to the new float so the user actually
192
+ // sees what they just opened. Pickers stay floating per the design.
193
+ if (viewportStore.current.class === 'compact' && !options.dismissable) {
194
+ compactRootStore.setRoot({ kind: 'float', floatId: id });
195
+ }
138
196
  return id;
139
197
  }
140
198
  function openFloatWithContent(options) {
141
199
  var _a;
142
200
  const store = activeStore();
143
201
  const id = generateFloatId();
144
- const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, getTreeBounds());
202
+ const bounds = getTreeBounds();
203
+ const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, bounds);
145
204
  const entry = {
146
205
  id,
147
206
  content: options.content,
@@ -149,7 +208,12 @@ function openFloatWithContent(options) {
149
208
  size: options.size,
150
209
  title: options.title,
151
210
  };
211
+ clampFloatRect(entry, bounds);
212
+ clampedIds.add(id);
152
213
  store.push(entry);
214
+ if (viewportStore.current.class === 'compact') {
215
+ compactRootStore.setRoot({ kind: 'float', floatId: id });
216
+ }
153
217
  return id;
154
218
  }
155
219
  function closeFloat(floatId) {
@@ -160,6 +224,12 @@ function closeFloat(floatId) {
160
224
  store.splice(idx, 1);
161
225
  parentHosts.delete(floatId);
162
226
  maximizedRects.delete(floatId);
227
+ // If this float was the compact body, snap back to docked.
228
+ const cur = compactRootStore.current;
229
+ if (cur.kind === 'float' && cur.floatId === floatId) {
230
+ compactRootStore.reset();
231
+ }
232
+ clampedIds.delete(floatId);
163
233
  setMaximizedReactive(floatId, false);
164
234
  }
165
235
  function listFloats() {
@@ -80,6 +80,176 @@ describe('floatManager', () => {
80
80
  expect(f.content.type).toBe('tabs');
81
81
  });
82
82
  });
83
+ describe('bindFloatStore — clamp persisted floats to viewport', () => {
84
+ beforeEach(() => {
85
+ __resetFloatManagerForTest();
86
+ });
87
+ it('pulls an off-screen persisted float back into the bound viewport', () => {
88
+ // Persisted state from a previous, larger viewport: x is past the
89
+ // current right edge, y is past the current bottom.
90
+ const floats = [
91
+ {
92
+ id: 'persisted-1',
93
+ content: {
94
+ type: 'tabs',
95
+ tabs: [{ slotId: 'float:v:1', viewId: 'v', label: 'V' }],
96
+ activeTab: 0,
97
+ },
98
+ position: { x: 2400, y: 1800 },
99
+ size: { w: 600, h: 400 },
100
+ },
101
+ ];
102
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
103
+ expect(floats[0].position.x).toBe(1024 - 600);
104
+ expect(floats[0].position.y).toBe(768 - 400);
105
+ expect(floats[0].size).toEqual({ w: 600, h: 400 });
106
+ });
107
+ it('shrinks an oversized persisted float to fit the bound viewport', () => {
108
+ const floats = [
109
+ {
110
+ id: 'persisted-2',
111
+ content: {
112
+ type: 'tabs',
113
+ tabs: [{ slotId: 'float:v:2', viewId: 'v', label: 'V' }],
114
+ activeTab: 0,
115
+ },
116
+ position: { x: 0, y: 0 },
117
+ size: { w: 4000, h: 3000 },
118
+ },
119
+ ];
120
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
121
+ expect(floats[0].size).toEqual({ w: 1024, h: 768 });
122
+ });
123
+ it('leaves a float that already fits unchanged', () => {
124
+ const floats = [
125
+ {
126
+ id: 'persisted-3',
127
+ content: {
128
+ type: 'tabs',
129
+ tabs: [{ slotId: 'float:v:3', viewId: 'v', label: 'V' }],
130
+ activeTab: 0,
131
+ },
132
+ position: { x: 100, y: 200 },
133
+ size: { w: 600, h: 400 },
134
+ },
135
+ ];
136
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
137
+ expect(floats[0].position).toEqual({ x: 100, y: 200 });
138
+ expect(floats[0].size).toEqual({ w: 600, h: 400 });
139
+ });
140
+ it('does not re-clamp a float on subsequent binds (e.g. viewport-class swap)', () => {
141
+ // Simulate a user-positioned float that's outside a smaller viewport.
142
+ // First bind clamps it; the user then moves it back to (700, 200);
143
+ // a second bind (compact ↔ desktop) must not snap it again.
144
+ const floats = [
145
+ {
146
+ id: 'persisted-4',
147
+ content: {
148
+ type: 'tabs',
149
+ tabs: [{ slotId: 'float:v:4', viewId: 'v', label: 'V' }],
150
+ activeTab: 0,
151
+ },
152
+ position: { x: 2400, y: 0 },
153
+ size: { w: 600, h: 400 },
154
+ },
155
+ ];
156
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
157
+ // User drags it back to an out-of-bounds spot (allowed mid-session).
158
+ floats[0].position.x = 2400;
159
+ floats[0].position.y = 1800;
160
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
161
+ expect(floats[0].position).toEqual({ x: 2400, y: 1800 });
162
+ });
163
+ });
164
+ describe('floatManager.open — default size respects viewport', () => {
165
+ beforeEach(() => {
166
+ __resetFloatManagerForTest();
167
+ });
168
+ it('caps the default size at viewport bounds (phone-sized window)', () => {
169
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
170
+ const id = floatManager.open('test:view', { title: 'Phone' });
171
+ const f = floatManager.list().find((e) => e.id === id);
172
+ expect(f.size.w).toBeLessThanOrEqual(360);
173
+ expect(f.size.h).toBeLessThanOrEqual(740);
174
+ // And the float is fully on-screen.
175
+ expect(f.position.x + f.size.w).toBeLessThanOrEqual(360);
176
+ expect(f.position.y + f.size.h).toBeLessThanOrEqual(740);
177
+ });
178
+ it('uses DEFAULT_SIZE when the viewport is large enough', () => {
179
+ bindFloatStore(layoutStore.floats, () => ({ w: 1280, h: 800 }));
180
+ const id = floatManager.open('test:view', { title: 'Desktop' });
181
+ const f = floatManager.list().find((e) => e.id === id);
182
+ expect(f.size).toEqual({ w: 600, h: 400 });
183
+ });
184
+ it('clamps a user-supplied oversized size to viewport on open', () => {
185
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
186
+ const id = floatManager.open('test:view', {
187
+ title: 'Phone',
188
+ size: { w: 2000, h: 2000 },
189
+ });
190
+ const f = floatManager.list().find((e) => e.id === id);
191
+ expect(f.size).toEqual({ w: 360, h: 740 });
192
+ });
193
+ });
194
+ // ---------------------------------------------------------------------------
195
+ // Compact body-root integration: floatManager auto-switches in compact mode,
196
+ // resets on close-of-current and on bind.
197
+ // ---------------------------------------------------------------------------
198
+ import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
199
+ import { viewportStore } from '../viewport/store.svelte';
200
+ describe('floatManager — compact body root integration', () => {
201
+ beforeEach(() => {
202
+ __resetFloatManagerForTest();
203
+ __resetCompactRootStoreForTest();
204
+ viewportStore.override(null);
205
+ });
206
+ afterEach(() => {
207
+ viewportStore.override(null);
208
+ });
209
+ it('open() in compact mode switches the body to the new (non-dismissable) float', () => {
210
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
211
+ viewportStore.override('compact');
212
+ const id = floatManager.open('test:view', { title: 'Notes' });
213
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
214
+ });
215
+ it('open() in compact mode does NOT switch the body for dismissable pickers', () => {
216
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
217
+ viewportStore.override('compact');
218
+ floatManager.open('picker:view', { dismissable: true });
219
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
220
+ });
221
+ it('open() on desktop does not touch the compact body root', () => {
222
+ bindFloatStore(layoutStore.floats, () => ({ w: 1280, h: 800 }));
223
+ viewportStore.override('desktop');
224
+ floatManager.open('test:view');
225
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
226
+ });
227
+ it('close(id) resets when the closed float is the current body', () => {
228
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
229
+ viewportStore.override('compact');
230
+ const id = floatManager.open('test:view');
231
+ floatManager.close(id);
232
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
233
+ });
234
+ it('close(id) leaves the body root alone when closing a different float', () => {
235
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
236
+ viewportStore.override('compact');
237
+ floatManager.open('view:a');
238
+ const b = floatManager.open('view:b');
239
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: b });
240
+ floatManager.close(layoutStore.floats[0].id);
241
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: b });
242
+ });
243
+ it('bindFloatStore resets the body root (covers app/preset switch)', () => {
244
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
245
+ viewportStore.override('compact');
246
+ const id = floatManager.open('test:view');
247
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
248
+ // Re-bind (e.g. preset switch).
249
+ bindFloatStore([], () => ({ w: 360, h: 740 }));
250
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
251
+ });
252
+ });
83
253
  describe('floatManager — anchor-aware parent host', () => {
84
254
  beforeEach(() => {
85
255
  __resetFloatManagerForTest();
@@ -0,0 +1,25 @@
1
+ import type { DocumentMeta } from '../../documents/types';
2
+ export type DocEntry = DocumentMeta & {
3
+ shardId: string;
4
+ };
5
+ export type OpenerValue = Pick<DocEntry, 'shardId' | 'path'> | null;
6
+ export type SaverValue = string | null;
7
+ export type FileItem = {
8
+ kind: 'folder';
9
+ name: string;
10
+ fullPath: string;
11
+ } | {
12
+ kind: 'file';
13
+ name: string;
14
+ doc: DocEntry;
15
+ };
16
+ export declare function buildTree(docs: DocEntry[], shardId: string | null, prefix: string): FileItem[];
17
+ export declare function formatSize(bytes: number): string;
18
+ export declare function formatDate(epochMs: number): string;
19
+ export declare function iconForFile(name: string): string;
20
+ export declare function breadcrumbSegments(shardId: string | null, prefix: string): {
21
+ label: string;
22
+ level: number;
23
+ targetShard: string | null;
24
+ targetPrefix: string;
25
+ }[];
@@ -0,0 +1,74 @@
1
+ export function buildTree(docs, shardId, prefix) {
2
+ if (shardId === null) {
3
+ const shards = [...new Set(docs.map((d) => d.shardId))].sort();
4
+ return shards.map((s) => ({ kind: 'folder', name: s, fullPath: s }));
5
+ }
6
+ const shardDocs = docs.filter((d) => d.shardId === shardId);
7
+ const folders = new Map();
8
+ const files = [];
9
+ const normPrefix = prefix ? prefix + '/' : '';
10
+ const plen = normPrefix.length;
11
+ for (const doc of shardDocs) {
12
+ if (!doc.path.startsWith(normPrefix))
13
+ continue;
14
+ const relative = doc.path.slice(plen);
15
+ const slash = relative.indexOf('/');
16
+ if (slash >= 0) {
17
+ const name = relative.slice(0, slash);
18
+ const full = prefix ? `${prefix}/${name}` : name;
19
+ if (!folders.has(name))
20
+ folders.set(name, full);
21
+ }
22
+ else {
23
+ files.push({ kind: 'file', name: relative, doc });
24
+ }
25
+ }
26
+ const folderItems = [...folders.entries()]
27
+ .map(([name, fullPath]) => ({ kind: 'folder', name, fullPath }))
28
+ .sort((a, b) => a.name.localeCompare(b.name));
29
+ const fileItems = files.sort((a, b) => a.name.localeCompare(b.name));
30
+ return [...folderItems, ...fileItems];
31
+ }
32
+ export function formatSize(bytes) {
33
+ if (bytes < 1024)
34
+ return `${bytes} B`;
35
+ if (bytes < 1024 * 1024)
36
+ return `${(bytes / 1024).toFixed(1)} KB`;
37
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
38
+ }
39
+ export function formatDate(epochMs) {
40
+ const d = new Date(epochMs);
41
+ return d.toLocaleDateString('en-US', {
42
+ month: 'short',
43
+ day: 'numeric',
44
+ year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined,
45
+ });
46
+ }
47
+ const EXT_ICONS = {
48
+ guml: '⬡', glsl: '◇', md: '≡', obj: '△', png: '▦',
49
+ };
50
+ export function iconForFile(name) {
51
+ var _a;
52
+ const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
53
+ return (_a = EXT_ICONS[ext]) !== null && _a !== void 0 ? _a : '▯';
54
+ }
55
+ export function breadcrumbSegments(shardId, prefix) {
56
+ const segs = [
57
+ { label: 'SH3', level: 0, targetShard: null, targetPrefix: '' },
58
+ ];
59
+ if (shardId) {
60
+ segs.push({ label: shardId, level: 1, targetShard: shardId, targetPrefix: '' });
61
+ if (prefix) {
62
+ const parts = prefix.split('/');
63
+ for (let i = 0; i < parts.length; i++) {
64
+ segs.push({
65
+ label: parts[i],
66
+ level: 2 + i,
67
+ targetShard: shardId,
68
+ targetPrefix: parts.slice(0, i + 1).join('/'),
69
+ });
70
+ }
71
+ }
72
+ }
73
+ return segs;
74
+ }
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
3
+ import { sh3 } from '../../sh3Runtime.svelte';
4
+ import DocumentBrowser from './_DocumentBrowser.svelte';
5
+ import type { DocumentMeta } from '../../documents/types';
6
+ import type { DocEntry, OpenerValue, SaverValue } from './DocumentFilePicker';
7
+
8
+ type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
9
+
10
+ let {
11
+ mode,
12
+ value = $bindable<OpenerValue | SaverValue>(null),
13
+ listDocuments,
14
+ disabled = false,
15
+ invalid = false,
16
+ size = 'md',
17
+ buttonLabel = 'Choose…',
18
+ onchange,
19
+ }: {
20
+ mode: 'open' | 'save';
21
+ value?: OpenerValue | SaverValue;
22
+ listDocuments: DocListFn;
23
+ disabled?: boolean;
24
+ invalid?: boolean;
25
+ size?: 'sm' | 'md';
26
+ buttonLabel?: string;
27
+ } & CommitOnlyEvents<OpenerValue | SaverValue> = $props();
28
+
29
+ let trigger = $state<HTMLButtonElement | undefined>(undefined);
30
+ let openFlag = $state(false);
31
+
32
+ const displayPath = $derived(
33
+ value
34
+ ? typeof value === 'string'
35
+ ? value
36
+ : `${value.shardId}/${value.path}`
37
+ : null,
38
+ );
39
+
40
+ function handleCommit(result: OpenerValue | SaverValue) {
41
+ value = result;
42
+ onchange?.(result);
43
+ }
44
+
45
+ function onOpenClosed() {
46
+ openFlag = false;
47
+ trigger?.focus();
48
+ }
49
+
50
+ async function open() {
51
+ if (disabled || openFlag || !trigger) return;
52
+ openFlag = true;
53
+ let docs: DocEntry[] = [];
54
+ try {
55
+ docs = await listDocuments();
56
+ } catch {
57
+ openFlag = false;
58
+ return;
59
+ }
60
+
61
+ const popupHandle = sh3.popup.show(
62
+ DocumentBrowser,
63
+ { anchor: trigger },
64
+ {
65
+ mode,
66
+ docs,
67
+ onCommit: (result: OpenerValue | SaverValue) => {
68
+ handleCommit(result);
69
+ },
70
+ onCancel: () => {},
71
+ },
72
+ );
73
+
74
+ const origClose = popupHandle.close;
75
+ popupHandle.close = () => {
76
+ origClose();
77
+ onOpenClosed();
78
+ };
79
+ }
80
+ </script>
81
+
82
+ <label class="sh3-dfp" class:sh3-dfp--sm={size === 'sm'} class:sh3-dfp--invalid={invalid}>
83
+ <button
84
+ type="button"
85
+ class="sh3-dfp__btn"
86
+ bind:this={trigger}
87
+ {disabled}
88
+ aria-haspopup="dialog"
89
+ aria-expanded={openFlag}
90
+ onclick={open}
91
+ >
92
+ <span class="sh3-dfp__label">{buttonLabel}</span>
93
+ <span class="sh3-dfp__path" class:sh3-dfp__path--empty={!displayPath}>
94
+ {displayPath ?? (mode === 'open' ? 'no document' : 'choose path…')}
95
+ </span>
96
+ <span class="sh3-dfp__chevron" aria-hidden="true">▾</span>
97
+ </button>
98
+ </label>
99
+
100
+ <style>
101
+ .sh3-dfp { display: inline-flex; font-size: 0.8125rem; }
102
+ .sh3-dfp__btn {
103
+ display: inline-flex; align-items: stretch;
104
+ height: var(--sh3-field-height-md);
105
+ min-width: 240px;
106
+ border: 1px solid var(--sh3-border);
107
+ border-radius: var(--sh3-widget-radius);
108
+ background: var(--sh3-input-bg);
109
+ cursor: pointer;
110
+ font: inherit;
111
+ text-align: left;
112
+ overflow: hidden;
113
+ padding: 0;
114
+ }
115
+ .sh3-dfp--sm .sh3-dfp__btn { height: var(--sh3-field-height-sm); }
116
+ .sh3-dfp--invalid .sh3-dfp__btn { border-color: var(--sh3-error); }
117
+ .sh3-dfp__btn:hover { border-color: var(--sh3-input-border-focus); }
118
+ .sh3-dfp__btn:focus-visible { outline: none; box-shadow: var(--sh3-focus-ring); border-color: var(--sh3-input-border-focus); }
119
+ .sh3-dfp__btn:disabled { opacity: 0.55; cursor: not-allowed; }
120
+ .sh3-dfp__label {
121
+ display: inline-flex; align-items: center;
122
+ padding: 0 var(--sh3-field-pad-x);
123
+ background: var(--sh3-bg-elevated);
124
+ color: var(--sh3-fg);
125
+ border-right: 1px solid var(--sh3-border);
126
+ white-space: nowrap;
127
+ flex-shrink: 0;
128
+ }
129
+ .sh3-dfp__path {
130
+ display: inline-flex; align-items: center;
131
+ padding: 0 var(--sh3-field-pad-x);
132
+ color: var(--sh3-fg);
133
+ flex: 1; min-width: 0;
134
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
135
+ font-family: var(--sh3-font-mono); font-size: 0.75rem;
136
+ }
137
+ .sh3-dfp__path--empty { color: var(--sh3-fg-muted); font-family: inherit; font-size: 0.8125rem; font-style: italic; }
138
+ .sh3-dfp__chevron {
139
+ display: inline-flex; align-items: center;
140
+ padding: 0 8px;
141
+ color: var(--sh3-fg-muted);
142
+ flex-shrink: 0;
143
+ }
144
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ import type { DocumentMeta } from '../../documents/types';
3
+ import type { OpenerValue, SaverValue } from './DocumentFilePicker';
4
+ type DocListFn = () => Promise<Array<DocumentMeta & {
5
+ shardId: string;
6
+ }>>;
7
+ type $$ComponentProps = {
8
+ mode: 'open' | 'save';
9
+ value?: OpenerValue | SaverValue;
10
+ listDocuments: DocListFn;
11
+ disabled?: boolean;
12
+ invalid?: boolean;
13
+ size?: 'sm' | 'md';
14
+ buttonLabel?: string;
15
+ } & CommitOnlyEvents<OpenerValue | SaverValue>;
16
+ declare const DocumentFilePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
17
+ type DocumentFilePicker = ReturnType<typeof DocumentFilePicker>;
18
+ export default DocumentFilePicker;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
3
+ import DocumentFilePicker from './DocumentFilePicker.svelte';
4
+ import type { DocumentMeta } from '../../documents/types';
5
+ import type { OpenerValue } from './DocumentFilePicker';
6
+
7
+ type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
8
+
9
+ let {
10
+ value = $bindable<OpenerValue>(null),
11
+ listDocuments,
12
+ disabled = false,
13
+ invalid = false,
14
+ size = 'md',
15
+ buttonLabel = 'Open document…',
16
+ onchange,
17
+ }: {
18
+ value?: OpenerValue;
19
+ listDocuments: DocListFn;
20
+ disabled?: boolean;
21
+ invalid?: boolean;
22
+ size?: 'sm' | 'md';
23
+ buttonLabel?: string;
24
+ } & CommitOnlyEvents<OpenerValue> = $props();
25
+ </script>
26
+
27
+ <DocumentFilePicker
28
+ mode="open"
29
+ bind:value
30
+ {listDocuments}
31
+ {disabled}
32
+ {invalid}
33
+ {size}
34
+ {buttonLabel}
35
+ {onchange}
36
+ />
@@ -0,0 +1,17 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ import type { DocumentMeta } from '../../documents/types';
3
+ import type { OpenerValue } from './DocumentFilePicker';
4
+ type DocListFn = () => Promise<Array<DocumentMeta & {
5
+ shardId: string;
6
+ }>>;
7
+ type $$ComponentProps = {
8
+ value?: OpenerValue;
9
+ listDocuments: DocListFn;
10
+ disabled?: boolean;
11
+ invalid?: boolean;
12
+ size?: 'sm' | 'md';
13
+ buttonLabel?: string;
14
+ } & CommitOnlyEvents<OpenerValue>;
15
+ declare const DocumentOpener: import("svelte").Component<$$ComponentProps, {}, "value">;
16
+ type DocumentOpener = ReturnType<typeof DocumentOpener>;
17
+ export default DocumentOpener;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
3
+ import DocumentFilePicker from './DocumentFilePicker.svelte';
4
+ import type { DocumentMeta } from '../../documents/types';
5
+ import type { SaverValue } from './DocumentFilePicker';
6
+
7
+ type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
8
+
9
+ let {
10
+ value = $bindable<SaverValue>(null),
11
+ listDocuments,
12
+ disabled = false,
13
+ invalid = false,
14
+ size = 'md',
15
+ buttonLabel = 'Save as…',
16
+ onchange,
17
+ }: {
18
+ value?: SaverValue;
19
+ listDocuments: DocListFn;
20
+ disabled?: boolean;
21
+ invalid?: boolean;
22
+ size?: 'sm' | 'md';
23
+ buttonLabel?: string;
24
+ } & CommitOnlyEvents<SaverValue> = $props();
25
+ </script>
26
+
27
+ <DocumentFilePicker
28
+ mode="save"
29
+ bind:value
30
+ {listDocuments}
31
+ {disabled}
32
+ {invalid}
33
+ {size}
34
+ {buttonLabel}
35
+ {onchange}
36
+ />