sh3-core 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Sh3.svelte +2 -73
- package/dist/actions/ctx-actions.svelte.test.js +4 -4
- package/dist/api.d.ts +2 -0
- package/dist/api.js +1 -0
- package/dist/build.d.ts +27 -0
- package/dist/build.js +59 -1
- package/dist/build.test.d.ts +1 -0
- package/dist/build.test.js +31 -0
- package/dist/contributions/index.d.ts +1 -1
- package/dist/contributions/index.js +1 -1
- package/dist/contributions/registry.d.ts +17 -1
- package/dist/contributions/registry.js +50 -2
- package/dist/contributions/scope.test.d.ts +1 -0
- package/dist/contributions/scope.test.js +52 -0
- package/dist/contributions/types.d.ts +11 -3
- package/dist/createShell.js +7 -1
- package/dist/fields/address.d.ts +3 -0
- package/dist/fields/address.js +36 -0
- package/dist/fields/address.test.d.ts +1 -0
- package/dist/fields/address.test.js +34 -0
- package/dist/fields/decoration.d.ts +7 -0
- package/dist/fields/decoration.js +199 -0
- package/dist/fields/decoration.svelte.test.d.ts +1 -0
- package/dist/fields/decoration.svelte.test.js +177 -0
- package/dist/fields/dispatch.d.ts +22 -0
- package/dist/fields/dispatch.js +254 -0
- package/dist/fields/dispatch.test.d.ts +1 -0
- package/dist/fields/dispatch.test.js +175 -0
- package/dist/fields/types.d.ts +101 -0
- package/dist/fields/types.js +16 -0
- package/dist/fields/walker.svelte.test.d.ts +1 -0
- package/dist/fields/walker.svelte.test.js +138 -0
- package/dist/host.js +27 -2
- package/dist/host.svelte.test.d.ts +1 -0
- package/dist/host.svelte.test.js +92 -0
- package/dist/layout/slotHostPool.svelte.d.ts +8 -0
- package/dist/layout/slotHostPool.svelte.js +14 -1
- package/dist/overlays/OverlayRoots.svelte +86 -0
- package/dist/overlays/OverlayRoots.svelte.d.ts +3 -0
- package/dist/platform/tauri-backend.d.ts +3 -3
- package/dist/platform/tauri-backend.js +24 -3
- package/dist/projects/session-state.svelte.d.ts +3 -3
- package/dist/projects/session-state.svelte.js +5 -4
- package/dist/runtime/runVerb.js +2 -2
- package/dist/satellite/SatelliteShell.svelte +58 -11
- package/dist/satellite/SatelliteShell.svelte.test.d.ts +1 -0
- package/dist/satellite/SatelliteShell.svelte.test.js +61 -0
- package/dist/sh3Api/fields-walker.svelte.test.d.ts +1 -0
- package/dist/sh3Api/fields-walker.svelte.test.js +75 -0
- package/dist/sh3Api/headless.d.ts +9 -0
- package/dist/sh3Api/headless.js +163 -16
- package/dist/sh3Api/headless.svelte.test.js +9 -9
- package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -2
- package/dist/shards/activate-fields.svelte.test.d.ts +1 -0
- package/dist/shards/activate-fields.svelte.test.js +121 -0
- package/dist/shards/activate-runtime.test.js +8 -8
- package/dist/shards/activate.svelte.js +29 -35
- package/dist/shards/types.d.ts +14 -75
- package/dist/shell-shard/ScrollbackView.svelte +55 -9
- package/dist/shell-shard/Terminal.svelte +1 -1
- package/dist/shell-shard/scrollback-stick.d.ts +9 -0
- package/dist/shell-shard/scrollback-stick.js +21 -0
- package/dist/shell-shard/scrollback-stick.test.d.ts +1 -0
- package/dist/shell-shard/scrollback-stick.test.js +25 -0
- package/dist/verbs/types.d.ts +56 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Decoration overlay layer.
|
|
3
|
+
*
|
|
4
|
+
* One root-level <div class="sh3-decoration-layer"> per Sh3 instance, lazily
|
|
5
|
+
* created on first attachDecoration. Pointer-events: none by default;
|
|
6
|
+
* decorations opt in for clickable UI by setting pointer-events: auto on
|
|
7
|
+
* their own root element.
|
|
8
|
+
*
|
|
9
|
+
* Reflow triggers (shared across all decorations):
|
|
10
|
+
* - window resize (debounced to one frame)
|
|
11
|
+
* - scroll on field's nearest scrollable ancestor (delegated)
|
|
12
|
+
* - ResizeObserver on the field element (handles parent reflows)
|
|
13
|
+
*
|
|
14
|
+
* Auto-dispose on contribution unregister via contributions.onChange.
|
|
15
|
+
*/
|
|
16
|
+
import { onChange as onContributionsChange, list as listContributions } from '../contributions';
|
|
17
|
+
import { FIELD_POINT_ID } from './types';
|
|
18
|
+
const decorations = new Set();
|
|
19
|
+
let layer = null;
|
|
20
|
+
let resizeListener = null;
|
|
21
|
+
let contributionsUnsubscribe = null;
|
|
22
|
+
let rafScheduled = false;
|
|
23
|
+
function ensureLayer() {
|
|
24
|
+
if (layer && layer.isConnected)
|
|
25
|
+
return layer;
|
|
26
|
+
const el = document.createElement('div');
|
|
27
|
+
el.className = 'sh3-decoration-layer';
|
|
28
|
+
el.style.cssText = 'position: fixed; inset: 0; pointer-events: none;';
|
|
29
|
+
document.body.appendChild(el);
|
|
30
|
+
layer = el;
|
|
31
|
+
if (!resizeListener) {
|
|
32
|
+
resizeListener = () => scheduleReflowAll();
|
|
33
|
+
window.addEventListener('resize', resizeListener);
|
|
34
|
+
}
|
|
35
|
+
if (!contributionsUnsubscribe) {
|
|
36
|
+
contributionsUnsubscribe = onContributionsChange(FIELD_POINT_ID, () => {
|
|
37
|
+
const present = new Set();
|
|
38
|
+
for (const entry of listContributions(FIELD_POINT_ID)) {
|
|
39
|
+
present.add(addrKey({
|
|
40
|
+
shardId: entry.owner.shardId,
|
|
41
|
+
slotId: entry.owner.slotId,
|
|
42
|
+
fieldId: entry.descriptor.fieldId,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
for (const dec of Array.from(decorations)) {
|
|
46
|
+
if (!present.has(addrKey(dec.addr)))
|
|
47
|
+
disposeDecoration(dec);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return el;
|
|
52
|
+
}
|
|
53
|
+
function addrKey(a) {
|
|
54
|
+
var _a;
|
|
55
|
+
return `${a.shardId}::${(_a = a.slotId) !== null && _a !== void 0 ? _a : ''}::${a.fieldId}`;
|
|
56
|
+
}
|
|
57
|
+
function findEntry(addr) {
|
|
58
|
+
var _a, _b;
|
|
59
|
+
for (const entry of listContributions(FIELD_POINT_ID)) {
|
|
60
|
+
if (entry.owner.shardId !== addr.shardId)
|
|
61
|
+
continue;
|
|
62
|
+
if (((_a = entry.owner.slotId) !== null && _a !== void 0 ? _a : undefined) !== ((_b = addr.slotId) !== null && _b !== void 0 ? _b : undefined))
|
|
63
|
+
continue;
|
|
64
|
+
if (entry.descriptor.fieldId !== addr.fieldId)
|
|
65
|
+
continue;
|
|
66
|
+
return entry;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function entryElement(entry) {
|
|
71
|
+
const d = entry.descriptor;
|
|
72
|
+
if (d.shape === 'element')
|
|
73
|
+
return d.element;
|
|
74
|
+
if (d.shape === 'imperative' || d.shape === 'readonly')
|
|
75
|
+
return d.element;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
function nearestScrollableAncestor(el) {
|
|
79
|
+
let cur = el.parentElement;
|
|
80
|
+
while (cur) {
|
|
81
|
+
const s = getComputedStyle(cur);
|
|
82
|
+
if (/auto|scroll/.test(s.overflowY) || /auto|scroll/.test(s.overflowX))
|
|
83
|
+
return cur;
|
|
84
|
+
cur = cur.parentElement;
|
|
85
|
+
}
|
|
86
|
+
return window;
|
|
87
|
+
}
|
|
88
|
+
function reflow(dec) {
|
|
89
|
+
var _a, _b;
|
|
90
|
+
if (dec.disposed)
|
|
91
|
+
return;
|
|
92
|
+
const rect = dec.target.getBoundingClientRect();
|
|
93
|
+
dec.wrapper.style.left = `${rect.left}px`;
|
|
94
|
+
dec.wrapper.style.top = `${rect.top}px`;
|
|
95
|
+
dec.wrapper.style.width = `${rect.width}px`;
|
|
96
|
+
dec.wrapper.style.height = `${rect.height}px`;
|
|
97
|
+
(_b = (_a = dec.handle) === null || _a === void 0 ? void 0 : _a.update) === null || _b === void 0 ? void 0 : _b.call(_a, rect);
|
|
98
|
+
}
|
|
99
|
+
function scheduleReflowAll() {
|
|
100
|
+
if (rafScheduled)
|
|
101
|
+
return;
|
|
102
|
+
rafScheduled = true;
|
|
103
|
+
requestAnimationFrame(() => {
|
|
104
|
+
rafScheduled = false;
|
|
105
|
+
for (const dec of decorations)
|
|
106
|
+
reflow(dec);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function disposeDecoration(dec) {
|
|
110
|
+
var _a, _b;
|
|
111
|
+
if (dec.disposed)
|
|
112
|
+
return;
|
|
113
|
+
dec.disposed = true;
|
|
114
|
+
decorations.delete(dec);
|
|
115
|
+
dec.resizeObserver.disconnect();
|
|
116
|
+
if (dec.scrollAncestor === window) {
|
|
117
|
+
window.removeEventListener('scroll', dec.scrollListener, true);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
dec.scrollAncestor.removeEventListener('scroll', dec.scrollListener);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
(_b = (_a = dec.handle) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error('[sh3] decoration dispose threw', err);
|
|
127
|
+
}
|
|
128
|
+
if (dec.wrapper.parentElement)
|
|
129
|
+
dec.wrapper.parentElement.removeChild(dec.wrapper);
|
|
130
|
+
}
|
|
131
|
+
export function attachDecoration(addr, factory) {
|
|
132
|
+
const entry = findEntry(addr);
|
|
133
|
+
if (!entry) {
|
|
134
|
+
throw new Error(`unknown field: ${addrKey(addr)}`);
|
|
135
|
+
}
|
|
136
|
+
const target = entryElement(entry);
|
|
137
|
+
if (!target) {
|
|
138
|
+
throw new Error(`field has no element to anchor to: ${addrKey(addr)}`);
|
|
139
|
+
}
|
|
140
|
+
const layerEl = ensureLayer();
|
|
141
|
+
const rect = target.getBoundingClientRect();
|
|
142
|
+
const result = factory({ element: target, rect });
|
|
143
|
+
let element;
|
|
144
|
+
let handle = null;
|
|
145
|
+
if (result instanceof HTMLElement) {
|
|
146
|
+
element = result;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
element = result.element;
|
|
150
|
+
handle = result;
|
|
151
|
+
}
|
|
152
|
+
const wrapper = document.createElement('div');
|
|
153
|
+
wrapper.className = 'sh3-decoration';
|
|
154
|
+
wrapper.style.cssText =
|
|
155
|
+
`position: absolute; left: ${rect.left}px; top: ${rect.top}px; ` +
|
|
156
|
+
`width: ${rect.width}px; height: ${rect.height}px; pointer-events: none;`;
|
|
157
|
+
wrapper.appendChild(element);
|
|
158
|
+
layerEl.appendChild(wrapper);
|
|
159
|
+
const scrollAncestor = nearestScrollableAncestor(target);
|
|
160
|
+
const dec = {
|
|
161
|
+
addr,
|
|
162
|
+
element,
|
|
163
|
+
wrapper,
|
|
164
|
+
target,
|
|
165
|
+
handle,
|
|
166
|
+
resizeObserver: new ResizeObserver(() => scheduleReflowAll()),
|
|
167
|
+
scrollAncestor,
|
|
168
|
+
scrollListener: () => scheduleReflowAll(),
|
|
169
|
+
disposed: false,
|
|
170
|
+
};
|
|
171
|
+
dec.resizeObserver.observe(target);
|
|
172
|
+
if (scrollAncestor === window) {
|
|
173
|
+
window.addEventListener('scroll', dec.scrollListener, true);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
scrollAncestor.addEventListener('scroll', dec.scrollListener);
|
|
177
|
+
}
|
|
178
|
+
decorations.add(dec);
|
|
179
|
+
return () => disposeDecoration(dec);
|
|
180
|
+
}
|
|
181
|
+
/** Test-only: tear everything down and reset module state. */
|
|
182
|
+
export function __resetDecorationLayerForTest() {
|
|
183
|
+
var _a;
|
|
184
|
+
for (const dec of Array.from(decorations))
|
|
185
|
+
disposeDecoration(dec);
|
|
186
|
+
decorations.clear();
|
|
187
|
+
if (layer && layer.isConnected)
|
|
188
|
+
(_a = layer.parentElement) === null || _a === void 0 ? void 0 : _a.removeChild(layer);
|
|
189
|
+
layer = null;
|
|
190
|
+
if (resizeListener) {
|
|
191
|
+
window.removeEventListener('resize', resizeListener);
|
|
192
|
+
resizeListener = null;
|
|
193
|
+
}
|
|
194
|
+
if (contributionsUnsubscribe) {
|
|
195
|
+
contributionsUnsubscribe();
|
|
196
|
+
contributionsUnsubscribe = null;
|
|
197
|
+
}
|
|
198
|
+
rafScheduled = false;
|
|
199
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { __resetContributionsForTest, register, } from '../contributions/registry';
|
|
3
|
+
import { attachDecoration, __resetDecorationLayerForTest } from './decoration';
|
|
4
|
+
import { FIELD_POINT_ID } from './types';
|
|
5
|
+
function registerElementField(shardId, fieldId, el) {
|
|
6
|
+
register(FIELD_POINT_ID, {
|
|
7
|
+
owner: { shardId },
|
|
8
|
+
descriptor: {
|
|
9
|
+
shape: 'imperative',
|
|
10
|
+
fieldId,
|
|
11
|
+
label: fieldId,
|
|
12
|
+
kind: 'string',
|
|
13
|
+
get: () => '',
|
|
14
|
+
element: el,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function flushFrame() {
|
|
19
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
20
|
+
}
|
|
21
|
+
describe('attachDecoration', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
__resetContributionsForTest();
|
|
24
|
+
__resetDecorationLayerForTest();
|
|
25
|
+
document.body.innerHTML = '';
|
|
26
|
+
});
|
|
27
|
+
it('rejects when the target field is unknown', () => {
|
|
28
|
+
expect(() => attachDecoration({ shardId: 'a', fieldId: 'x' }, () => document.createElement('div'))).toThrow(/unknown field/);
|
|
29
|
+
});
|
|
30
|
+
it('rejects when the target field has no element', () => {
|
|
31
|
+
register(FIELD_POINT_ID, {
|
|
32
|
+
owner: { shardId: 'a' },
|
|
33
|
+
descriptor: {
|
|
34
|
+
shape: 'imperative', fieldId: 'abstract', label: 'A', kind: 'string',
|
|
35
|
+
get: () => '',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(() => attachDecoration({ shardId: 'a', fieldId: 'abstract' }, () => document.createElement('div'))).toThrow(/no element/);
|
|
39
|
+
});
|
|
40
|
+
it('mounts the factory result into the overlay layer', () => {
|
|
41
|
+
const target = document.createElement('input');
|
|
42
|
+
document.body.appendChild(target);
|
|
43
|
+
registerElementField('a', 'x', target);
|
|
44
|
+
attachDecoration({ shardId: 'a', fieldId: 'x' }, () => {
|
|
45
|
+
const d = document.createElement('div');
|
|
46
|
+
d.dataset.kind = 'badge';
|
|
47
|
+
return d;
|
|
48
|
+
});
|
|
49
|
+
const layer = document.querySelector('.sh3-decoration-layer');
|
|
50
|
+
expect(layer).not.toBeNull();
|
|
51
|
+
expect(layer.querySelector('[data-kind="badge"]')).not.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
it('sets absolute position matching the target rect', () => {
|
|
54
|
+
const target = document.createElement('input');
|
|
55
|
+
document.body.appendChild(target);
|
|
56
|
+
target.getBoundingClientRect = () => ({
|
|
57
|
+
x: 10, y: 20, width: 100, height: 30,
|
|
58
|
+
top: 20, left: 10, right: 110, bottom: 50,
|
|
59
|
+
toJSON() { return this; },
|
|
60
|
+
});
|
|
61
|
+
registerElementField('a', 'x', target);
|
|
62
|
+
attachDecoration({ shardId: 'a', fieldId: 'x' }, () => document.createElement('div'));
|
|
63
|
+
const wrapper = document.querySelector('.sh3-decoration');
|
|
64
|
+
expect(wrapper.style.position).toBe('absolute');
|
|
65
|
+
expect(wrapper.style.left).toBe('10px');
|
|
66
|
+
expect(wrapper.style.top).toBe('20px');
|
|
67
|
+
expect(wrapper.style.width).toBe('100px');
|
|
68
|
+
expect(wrapper.style.height).toBe('30px');
|
|
69
|
+
});
|
|
70
|
+
it('disposer removes the wrapper and calls handle.dispose', () => {
|
|
71
|
+
const target = document.createElement('input');
|
|
72
|
+
document.body.appendChild(target);
|
|
73
|
+
registerElementField('a', 'x', target);
|
|
74
|
+
const dispose = vi.fn();
|
|
75
|
+
const off = attachDecoration({ shardId: 'a', fieldId: 'x' }, () => ({
|
|
76
|
+
element: document.createElement('div'),
|
|
77
|
+
dispose,
|
|
78
|
+
}));
|
|
79
|
+
expect(document.querySelector('.sh3-decoration')).not.toBeNull();
|
|
80
|
+
off();
|
|
81
|
+
expect(document.querySelector('.sh3-decoration')).toBeNull();
|
|
82
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
it('disposer is idempotent (double-call no-op)', () => {
|
|
85
|
+
const target = document.createElement('input');
|
|
86
|
+
document.body.appendChild(target);
|
|
87
|
+
registerElementField('a', 'x', target);
|
|
88
|
+
const dispose = vi.fn();
|
|
89
|
+
const off = attachDecoration({ shardId: 'a', fieldId: 'x' }, () => ({
|
|
90
|
+
element: document.createElement('div'),
|
|
91
|
+
dispose,
|
|
92
|
+
}));
|
|
93
|
+
off();
|
|
94
|
+
off();
|
|
95
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
it('decoration is auto-disposed when the contribution is unregistered', () => {
|
|
98
|
+
const target = document.createElement('input');
|
|
99
|
+
document.body.appendChild(target);
|
|
100
|
+
const unreg = register(FIELD_POINT_ID, {
|
|
101
|
+
owner: { shardId: 'a' },
|
|
102
|
+
descriptor: {
|
|
103
|
+
shape: 'imperative',
|
|
104
|
+
fieldId: 'x',
|
|
105
|
+
label: 'x',
|
|
106
|
+
kind: 'string',
|
|
107
|
+
get: () => '',
|
|
108
|
+
element: target,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const dispose = vi.fn();
|
|
112
|
+
attachDecoration({ shardId: 'a', fieldId: 'x' }, () => ({
|
|
113
|
+
element: document.createElement('div'),
|
|
114
|
+
dispose,
|
|
115
|
+
}));
|
|
116
|
+
expect(document.querySelector('.sh3-decoration')).not.toBeNull();
|
|
117
|
+
unreg();
|
|
118
|
+
expect(document.querySelector('.sh3-decoration')).toBeNull();
|
|
119
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
it('factory may return a plain HTMLElement (no DecorationHandle)', () => {
|
|
122
|
+
const target = document.createElement('input');
|
|
123
|
+
document.body.appendChild(target);
|
|
124
|
+
registerElementField('a', 'x', target);
|
|
125
|
+
const off = attachDecoration({ shardId: 'a', fieldId: 'x' }, () => {
|
|
126
|
+
const el = document.createElement('div');
|
|
127
|
+
el.id = 'plain';
|
|
128
|
+
return el;
|
|
129
|
+
});
|
|
130
|
+
expect(document.getElementById('plain')).not.toBeNull();
|
|
131
|
+
off();
|
|
132
|
+
});
|
|
133
|
+
it('window resize triggers a wrapper reflow', async () => {
|
|
134
|
+
const target = document.createElement('input');
|
|
135
|
+
document.body.appendChild(target);
|
|
136
|
+
let rect = { x: 0, y: 0, width: 50, height: 20, top: 0, left: 0, right: 50, bottom: 20 };
|
|
137
|
+
target.getBoundingClientRect = () => rect;
|
|
138
|
+
registerElementField('a', 'x', target);
|
|
139
|
+
attachDecoration({ shardId: 'a', fieldId: 'x' }, () => document.createElement('div'));
|
|
140
|
+
const wrapper = document.querySelector('.sh3-decoration');
|
|
141
|
+
expect(wrapper.style.left).toBe('0px');
|
|
142
|
+
rect = { x: 100, y: 200, width: 50, height: 20, top: 200, left: 100, right: 150, bottom: 220 };
|
|
143
|
+
window.dispatchEvent(new Event('resize'));
|
|
144
|
+
await flushFrame();
|
|
145
|
+
expect(wrapper.style.left).toBe('100px');
|
|
146
|
+
expect(wrapper.style.top).toBe('200px');
|
|
147
|
+
});
|
|
148
|
+
it('handle.update is called with the new rect on reflow', async () => {
|
|
149
|
+
const target = document.createElement('input');
|
|
150
|
+
document.body.appendChild(target);
|
|
151
|
+
let rect = { x: 0, y: 0, width: 50, height: 20, top: 0, left: 0, right: 50, bottom: 20 };
|
|
152
|
+
target.getBoundingClientRect = () => rect;
|
|
153
|
+
registerElementField('a', 'x', target);
|
|
154
|
+
const update = vi.fn();
|
|
155
|
+
attachDecoration({ shardId: 'a', fieldId: 'x' }, () => ({
|
|
156
|
+
element: document.createElement('div'),
|
|
157
|
+
update,
|
|
158
|
+
}));
|
|
159
|
+
rect = { x: 5, y: 5, width: 60, height: 25, top: 5, left: 5, right: 65, bottom: 30 };
|
|
160
|
+
window.dispatchEvent(new Event('resize'));
|
|
161
|
+
await flushFrame();
|
|
162
|
+
expect(update).toHaveBeenCalled();
|
|
163
|
+
expect(update.mock.calls.at(-1)[0].left).toBe(5);
|
|
164
|
+
});
|
|
165
|
+
it('overlay layer is created lazily on first attach and reused thereafter', () => {
|
|
166
|
+
expect(document.querySelector('.sh3-decoration-layer')).toBeNull();
|
|
167
|
+
const t1 = document.createElement('input');
|
|
168
|
+
const t2 = document.createElement('input');
|
|
169
|
+
document.body.append(t1, t2);
|
|
170
|
+
registerElementField('a', 'x', t1);
|
|
171
|
+
registerElementField('a', 'y', t2);
|
|
172
|
+
attachDecoration({ shardId: 'a', fieldId: 'x' }, () => document.createElement('div'));
|
|
173
|
+
attachDecoration({ shardId: 'a', fieldId: 'y' }, () => document.createElement('div'));
|
|
174
|
+
expect(document.querySelectorAll('.sh3-decoration-layer').length).toBe(1);
|
|
175
|
+
expect(document.querySelectorAll('.sh3-decoration').length).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FieldAddress, FieldView, FieldKind } from './types';
|
|
2
|
+
export interface ListFieldsOpts {
|
|
3
|
+
slotId?: string;
|
|
4
|
+
shardId?: string;
|
|
5
|
+
walker?: 'off' | 'fallback' | 'always';
|
|
6
|
+
kind?: FieldKind;
|
|
7
|
+
}
|
|
8
|
+
export declare function listFields(opts?: ListFieldsOpts): FieldView[];
|
|
9
|
+
export declare function getField(addr: FieldAddress): unknown;
|
|
10
|
+
/**
|
|
11
|
+
* Internal helper exported for the walker too: writes to a native form
|
|
12
|
+
* element and dispatches both 'input' and 'change' events.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeElement(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: unknown): void;
|
|
15
|
+
export declare function setField(addr: FieldAddress, value: unknown): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Synthesize FieldView descriptors for native form elements + [data-sh3-field]
|
|
18
|
+
* within `container`. Walker output uses shardId === WALKER_SHARD_ID and
|
|
19
|
+
* carries the passed `slotId` for addressability. Skips elements that are
|
|
20
|
+
* already the `element` ref of an element-ref contribution.
|
|
21
|
+
*/
|
|
22
|
+
export declare function walkSlotContainer(slotId: string, container: HTMLElement): FieldView[];
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Field dispatch — read from contributions registry, route get/set against
|
|
3
|
+
* descriptor shape. Element-ref I/O lives here; walker (DOM scan) lives in
|
|
4
|
+
* the same module but is only exercised by walkSlot (added Task 6).
|
|
5
|
+
*/
|
|
6
|
+
import { list as listContributions } from '../contributions';
|
|
7
|
+
import { FIELD_POINT_ID, WALKER_SHARD_ID } from './types';
|
|
8
|
+
function getEntries() {
|
|
9
|
+
return listContributions(FIELD_POINT_ID);
|
|
10
|
+
}
|
|
11
|
+
function findEntry(addr) {
|
|
12
|
+
var _a, _b;
|
|
13
|
+
for (const entry of getEntries()) {
|
|
14
|
+
if (entry.owner.shardId !== addr.shardId)
|
|
15
|
+
continue;
|
|
16
|
+
if (((_a = entry.owner.slotId) !== null && _a !== void 0 ? _a : undefined) !== ((_b = addr.slotId) !== null && _b !== void 0 ? _b : undefined))
|
|
17
|
+
continue;
|
|
18
|
+
if (entry.descriptor.fieldId !== addr.fieldId)
|
|
19
|
+
continue;
|
|
20
|
+
return entry;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function descriptorReadonly(d) {
|
|
25
|
+
if (d.shape === 'readonly')
|
|
26
|
+
return true;
|
|
27
|
+
if (d.shape === 'imperative')
|
|
28
|
+
return typeof d.set !== 'function';
|
|
29
|
+
return false; // 'element' shape is always read-write
|
|
30
|
+
}
|
|
31
|
+
function descriptorElement(d) {
|
|
32
|
+
if (d.shape === 'element')
|
|
33
|
+
return d.element;
|
|
34
|
+
if (d.shape === 'imperative' || d.shape === 'readonly')
|
|
35
|
+
return d.element;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function entryToView(entry) {
|
|
39
|
+
const d = entry.descriptor;
|
|
40
|
+
const view = {
|
|
41
|
+
shardId: entry.owner.shardId,
|
|
42
|
+
fieldId: d.fieldId,
|
|
43
|
+
label: d.label,
|
|
44
|
+
kind: d.kind,
|
|
45
|
+
description: d.description,
|
|
46
|
+
enumValues: d.enumValues,
|
|
47
|
+
readonly: descriptorReadonly(d),
|
|
48
|
+
source: 'contributed',
|
|
49
|
+
};
|
|
50
|
+
if (entry.owner.slotId !== undefined)
|
|
51
|
+
view.slotId = entry.owner.slotId;
|
|
52
|
+
const el = descriptorElement(d);
|
|
53
|
+
if (el !== undefined)
|
|
54
|
+
view.element = el;
|
|
55
|
+
return view;
|
|
56
|
+
}
|
|
57
|
+
export function listFields(opts = {}) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const entry of getEntries()) {
|
|
60
|
+
if (opts.shardId !== undefined && entry.owner.shardId !== opts.shardId)
|
|
61
|
+
continue;
|
|
62
|
+
if (opts.slotId !== undefined && entry.owner.slotId !== opts.slotId)
|
|
63
|
+
continue;
|
|
64
|
+
if (opts.kind !== undefined && entry.descriptor.kind !== opts.kind)
|
|
65
|
+
continue;
|
|
66
|
+
out.push(entryToView(entry));
|
|
67
|
+
}
|
|
68
|
+
// Walker integration is added in Task 6 (walkSlot is no-op here for now).
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function readImperativeOrReadonly(d) {
|
|
72
|
+
return d.get();
|
|
73
|
+
}
|
|
74
|
+
function readElementRef(d) {
|
|
75
|
+
const el = d.element;
|
|
76
|
+
if (el instanceof HTMLInputElement && el.type === 'checkbox')
|
|
77
|
+
return el.checked;
|
|
78
|
+
return el.value;
|
|
79
|
+
}
|
|
80
|
+
export function getField(addr) {
|
|
81
|
+
var _a;
|
|
82
|
+
const entry = findEntry(addr);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
throw new Error(`unknown field: ${addr.shardId}::${(_a = addr.slotId) !== null && _a !== void 0 ? _a : ''}::${addr.fieldId}`);
|
|
85
|
+
}
|
|
86
|
+
const d = entry.descriptor;
|
|
87
|
+
if (d.shape === 'element')
|
|
88
|
+
return readElementRef(d);
|
|
89
|
+
return readImperativeOrReadonly(d);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Internal helper exported for the walker too: writes to a native form
|
|
93
|
+
* element and dispatches both 'input' and 'change' events.
|
|
94
|
+
*/
|
|
95
|
+
export function writeElement(el, value) {
|
|
96
|
+
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
|
|
97
|
+
el.checked = Boolean(value);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
el.value = String(value);
|
|
101
|
+
}
|
|
102
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
103
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
104
|
+
}
|
|
105
|
+
export async function setField(addr, value) {
|
|
106
|
+
var _a, _b, _c;
|
|
107
|
+
const entry = findEntry(addr);
|
|
108
|
+
if (!entry) {
|
|
109
|
+
throw new Error(`unknown field: ${addr.shardId}::${(_a = addr.slotId) !== null && _a !== void 0 ? _a : ''}::${addr.fieldId}`);
|
|
110
|
+
}
|
|
111
|
+
const d = entry.descriptor;
|
|
112
|
+
if (d.shape === 'readonly') {
|
|
113
|
+
throw new Error(`field is read-only: ${addr.shardId}::${(_b = addr.slotId) !== null && _b !== void 0 ? _b : ''}::${addr.fieldId}`);
|
|
114
|
+
}
|
|
115
|
+
if (d.shape === 'imperative') {
|
|
116
|
+
if (typeof d.set !== 'function') {
|
|
117
|
+
throw new Error(`field is read-only: ${addr.shardId}::${(_c = addr.slotId) !== null && _c !== void 0 ? _c : ''}::${addr.fieldId}`);
|
|
118
|
+
}
|
|
119
|
+
await d.set(value);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// 'element' shape
|
|
123
|
+
writeElement(d.element, value);
|
|
124
|
+
}
|
|
125
|
+
/* ── Walker ────────────────────────────────────────────────────────── */
|
|
126
|
+
const SKIP_INPUT_TYPES = new Set(['submit', 'button', 'reset', 'file', 'hidden']);
|
|
127
|
+
function inferInputKind(el) {
|
|
128
|
+
if (el.type === 'number')
|
|
129
|
+
return 'number';
|
|
130
|
+
if (el.type === 'checkbox')
|
|
131
|
+
return 'boolean';
|
|
132
|
+
return 'string';
|
|
133
|
+
}
|
|
134
|
+
function selectEnumValues(sel) {
|
|
135
|
+
return Array.from(sel.options).map((o) => o.value);
|
|
136
|
+
}
|
|
137
|
+
function slugify(s) {
|
|
138
|
+
return s
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.trim()
|
|
141
|
+
.replace(/[^a-z0-9.\-_ ]/g, '')
|
|
142
|
+
.replace(/\s+/g, '-')
|
|
143
|
+
.replace(/^-+|-+$/g, '');
|
|
144
|
+
}
|
|
145
|
+
function findLabelText(el) {
|
|
146
|
+
if (el.id) {
|
|
147
|
+
const explicit = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
148
|
+
if (explicit && explicit.textContent)
|
|
149
|
+
return explicit.textContent;
|
|
150
|
+
}
|
|
151
|
+
let cur = el.parentElement;
|
|
152
|
+
while (cur) {
|
|
153
|
+
if (cur.tagName === 'LABEL' && cur.textContent)
|
|
154
|
+
return cur.textContent;
|
|
155
|
+
cur = cur.parentElement;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function deriveFieldId(el, fallbackIndex) {
|
|
160
|
+
const dataAttr = el.getAttribute('data-sh3-field');
|
|
161
|
+
if (dataAttr && dataAttr.length > 0)
|
|
162
|
+
return dataAttr;
|
|
163
|
+
const name = el.name;
|
|
164
|
+
if (name)
|
|
165
|
+
return name;
|
|
166
|
+
// Prefer an explicit/ancestor <label> over a bare id: a label is more
|
|
167
|
+
// human-meaningful, and id values are often opaque (e.g. framework-generated).
|
|
168
|
+
const label = findLabelText(el);
|
|
169
|
+
if (label) {
|
|
170
|
+
const slug = slugify(label);
|
|
171
|
+
if (slug.length > 0)
|
|
172
|
+
return slug;
|
|
173
|
+
}
|
|
174
|
+
if (el.id)
|
|
175
|
+
return el.id;
|
|
176
|
+
const placeholder = el.placeholder;
|
|
177
|
+
if (placeholder) {
|
|
178
|
+
const slug = slugify(placeholder);
|
|
179
|
+
if (slug.length > 0)
|
|
180
|
+
return slug;
|
|
181
|
+
}
|
|
182
|
+
return `field-${fallbackIndex}`;
|
|
183
|
+
}
|
|
184
|
+
function deriveLabel(el) {
|
|
185
|
+
var _a, _b, _c;
|
|
186
|
+
return (_c = (_b = (_a = findLabelText(el)) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : el.placeholder) !== null && _c !== void 0 ? _c : null;
|
|
187
|
+
}
|
|
188
|
+
function isWalkerCandidate(el) {
|
|
189
|
+
if (el.hasAttribute('data-sh3-field'))
|
|
190
|
+
return true;
|
|
191
|
+
if (el instanceof HTMLInputElement)
|
|
192
|
+
return !SKIP_INPUT_TYPES.has(el.type);
|
|
193
|
+
if (el instanceof HTMLTextAreaElement)
|
|
194
|
+
return true;
|
|
195
|
+
if (el instanceof HTMLSelectElement)
|
|
196
|
+
return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Synthesize FieldView descriptors for native form elements + [data-sh3-field]
|
|
201
|
+
* within `container`. Walker output uses shardId === WALKER_SHARD_ID and
|
|
202
|
+
* carries the passed `slotId` for addressability. Skips elements that are
|
|
203
|
+
* already the `element` ref of an element-ref contribution.
|
|
204
|
+
*/
|
|
205
|
+
export function walkSlotContainer(slotId, container) {
|
|
206
|
+
var _a;
|
|
207
|
+
const contributedElements = new Set();
|
|
208
|
+
for (const entry of getEntries()) {
|
|
209
|
+
const d = entry.descriptor;
|
|
210
|
+
if (d.shape === 'element')
|
|
211
|
+
contributedElements.add(d.element);
|
|
212
|
+
}
|
|
213
|
+
const all = container.querySelectorAll('input, textarea, select, [data-sh3-field]');
|
|
214
|
+
const out = [];
|
|
215
|
+
let fallbackCounter = 0;
|
|
216
|
+
for (const el of Array.from(all)) {
|
|
217
|
+
if (!isWalkerCandidate(el))
|
|
218
|
+
continue;
|
|
219
|
+
if (contributedElements.has(el))
|
|
220
|
+
continue;
|
|
221
|
+
let kind = 'string';
|
|
222
|
+
let enumValues;
|
|
223
|
+
if (el instanceof HTMLInputElement) {
|
|
224
|
+
kind = inferInputKind(el);
|
|
225
|
+
}
|
|
226
|
+
else if (el instanceof HTMLSelectElement) {
|
|
227
|
+
kind = 'enum';
|
|
228
|
+
enumValues = selectEnumValues(el);
|
|
229
|
+
}
|
|
230
|
+
else if (el.hasAttribute('data-sh3-field')) {
|
|
231
|
+
const declared = el.getAttribute('data-sh3-field-kind');
|
|
232
|
+
if (declared && ['string', 'number', 'integer', 'boolean', 'enum', 'json'].includes(declared)) {
|
|
233
|
+
kind = declared;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const fieldId = deriveFieldId(el, fallbackCounter);
|
|
237
|
+
if (/^field-\d+$/.test(fieldId))
|
|
238
|
+
fallbackCounter += 1;
|
|
239
|
+
const view = {
|
|
240
|
+
shardId: WALKER_SHARD_ID,
|
|
241
|
+
slotId,
|
|
242
|
+
fieldId,
|
|
243
|
+
label: (_a = deriveLabel(el)) !== null && _a !== void 0 ? _a : fieldId,
|
|
244
|
+
kind,
|
|
245
|
+
readonly: false,
|
|
246
|
+
source: 'walker',
|
|
247
|
+
element: el,
|
|
248
|
+
};
|
|
249
|
+
if (enumValues !== undefined)
|
|
250
|
+
view.enumValues = enumValues;
|
|
251
|
+
out.push(view);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|