@ashley-shrok/viewmodel-shell 0.16.0 → 1.0.1
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/browser.d.ts +19 -13
- package/dist/browser.js +165 -270
- package/dist/index.d.ts +125 -45
- package/dist/index.js +230 -9
- package/dist/server.d.ts +73 -2
- package/dist/server.js +293 -12
- package/dist/tui.d.ts +2 -2
- package/dist/tui.js +62 -97
- package/package.json +1 -1
- package/styles/default.css +15 -9
package/dist/browser.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
// Phase 6 — thin-interpreter rewrite.
|
|
2
|
+
//
|
|
3
|
+
// Every input declares a `bind` dotted path into state; the renderer reads
|
|
4
|
+
// that slot to render and writes back on user-input events. Dispatch carries
|
|
5
|
+
// only the action name. The 7 context-assembly sites that lived here
|
|
6
|
+
// (form harvest, select-on-change, field-on-Enter, standalone CheckboxNode,
|
|
7
|
+
// TabsNode, TableNode sort/filter/pagination/selection, ButtonNode pre-baked
|
|
8
|
+
// context) collapse to one pattern: read via sa.read(bind); write via
|
|
9
|
+
// sa.write(bind, value); dispatch { name }. Drafts ARE state. File-input
|
|
10
|
+
// persistence keeps a fileRegistry for the binary side channel; the picked
|
|
11
|
+
// file also lands in state as {filename, size}. Focus/caret/scroll
|
|
12
|
+
// preservation continue to operate on the DOM.
|
|
1
13
|
function legacyCopy(text) {
|
|
2
14
|
try {
|
|
3
15
|
const ta = document.createElement("textarea");
|
|
@@ -14,24 +26,33 @@ function legacyCopy(text) {
|
|
|
14
26
|
return false;
|
|
15
27
|
}
|
|
16
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* No-op StateAccess fallback for callers that mount the adapter without a
|
|
31
|
+
* live shell (theme-modifier tests, conformance fixture walks, etc.). Reads
|
|
32
|
+
* return undefined; writes are dropped. This keeps the static-tree test
|
|
33
|
+
* surface intact while the bind-path contract is mandatory for real apps.
|
|
34
|
+
*/
|
|
35
|
+
const noopStateAccess = {
|
|
36
|
+
read: () => undefined,
|
|
37
|
+
write: () => { },
|
|
38
|
+
};
|
|
17
39
|
export class BrowserAdapter {
|
|
18
40
|
container;
|
|
19
41
|
fileRegistry = new Map();
|
|
42
|
+
sa = noopStateAccess;
|
|
20
43
|
constructor(container) {
|
|
21
44
|
this.container = container;
|
|
22
45
|
}
|
|
23
|
-
render(vm, onAction) {
|
|
46
|
+
render(vm, onAction, stateAccess) {
|
|
47
|
+
this.sa = stateAccess ?? noopStateAccess;
|
|
24
48
|
const active = document.activeElement;
|
|
25
49
|
const focusId = active?.id || null;
|
|
26
50
|
const selStart = active?.selectionStart ?? null;
|
|
27
51
|
const selEnd = active?.selectionEnd ?? null;
|
|
28
52
|
// 0.7.1 (#7) — snapshot the WINDOW scroll position alongside element-level
|
|
29
53
|
// scroll. Without this, an action-driven re-render rebuilds the entire
|
|
30
|
-
// subtree and the viewport jumps
|
|
31
|
-
//
|
|
32
|
-
// contract as element scroll: preserve unless the server explicitly
|
|
33
|
-
// navigates (a redirect IS a navigation, so it correctly does NOT
|
|
34
|
-
// round-trip through render — it goes through navigate()).
|
|
54
|
+
// subtree and the viewport jumps. Same preservation contract as before;
|
|
55
|
+
// unchanged by the Phase 6 rewrite.
|
|
35
56
|
const winScrollX = window.scrollX;
|
|
36
57
|
const winScrollY = window.scrollY;
|
|
37
58
|
const scrollMap = new Map();
|
|
@@ -39,30 +60,17 @@ export class BrowserAdapter {
|
|
|
39
60
|
if (el.scrollTop !== 0 || el.scrollLeft !== 0)
|
|
40
61
|
scrollMap.set(el.id, { top: el.scrollTop, left: el.scrollLeft });
|
|
41
62
|
});
|
|
42
|
-
const draftValues = new Map();
|
|
43
|
-
this.container.querySelectorAll("input:not([type=checkbox]):not([type=hidden]):not([type=file]), textarea").forEach(el => { if (el.name)
|
|
44
|
-
draftValues.set(el.name, el.value); });
|
|
45
63
|
this.container.innerHTML = "";
|
|
46
64
|
this.node(vm, this.container, onAction);
|
|
47
|
-
if (draftValues.size > 0) {
|
|
48
|
-
this.container.querySelectorAll("input:not([type=checkbox]):not([type=hidden]):not([type=file]), textarea").forEach(el => {
|
|
49
|
-
if (el.name && !el.value && draftValues.has(el.name))
|
|
50
|
-
el.value = draftValues.get(el.name);
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
65
|
if (focusId) {
|
|
54
66
|
const el = this.container.querySelector(`#${CSS.escape(focusId)}`);
|
|
55
67
|
if (el) {
|
|
56
|
-
// 0.7.1 (#7) — preventScroll stops focus() from yanking the viewport
|
|
57
|
-
// to the focused element. The window-scroll restore below still
|
|
58
|
-
// overrides any scroll that snuck in, but preventing the scroll in
|
|
59
|
-
// the first place is the cleaner contract.
|
|
60
68
|
el.focus({ preventScroll: true });
|
|
61
69
|
if (selStart !== null && selEnd !== null) {
|
|
62
70
|
try {
|
|
63
71
|
el.setSelectionRange(selStart, selEnd);
|
|
64
72
|
}
|
|
65
|
-
catch { }
|
|
73
|
+
catch { /* nothing */ }
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
}
|
|
@@ -73,9 +81,6 @@ export class BrowserAdapter {
|
|
|
73
81
|
el.scrollLeft = left;
|
|
74
82
|
}
|
|
75
83
|
});
|
|
76
|
-
// 0.7.1 (#7) — restore the window scroll LAST so any defensive
|
|
77
|
-
// browser behavior earlier in this method (e.g. a future element
|
|
78
|
-
// bringing itself into view) gets overridden by the snapshot.
|
|
79
84
|
window.scrollTo(winScrollX, winScrollY);
|
|
80
85
|
}
|
|
81
86
|
navigate(url) {
|
|
@@ -85,9 +90,6 @@ export class BrowserAdapter {
|
|
|
85
90
|
const store = scope === "session" ? sessionStorage : localStorage;
|
|
86
91
|
store.setItem(key, value);
|
|
87
92
|
}
|
|
88
|
-
/** Save an authenticated-download blob via the browser's native Save-As.
|
|
89
|
-
* contentType is informational — the Blob's own .type takes precedence in
|
|
90
|
-
* browsers. We accept the arg for adapter symmetry (other adapters use it). */
|
|
91
93
|
saveFile(data, filename, _contentType) {
|
|
92
94
|
const url = URL.createObjectURL(data);
|
|
93
95
|
try {
|
|
@@ -100,30 +102,16 @@ export class BrowserAdapter {
|
|
|
100
102
|
a.remove();
|
|
101
103
|
}
|
|
102
104
|
finally {
|
|
103
|
-
// Revoke async so the browser has time to start the download. The 0ms
|
|
104
|
-
// setTimeout is the established pattern (Chromium/Firefox/Safari).
|
|
105
105
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
/** 0.16.0 — toggle the `.vms-busy` class on the container. Default CSS makes
|
|
109
|
-
* every interactive descendant non-clickable (cursor:wait + pointer-events:
|
|
110
|
-
* none), so a rapid checkbox click during a round-trip never visually flips
|
|
111
|
-
* the box. Idempotent (classList.toggle with a force value). */
|
|
112
108
|
setBusy(active) {
|
|
113
109
|
this.container.classList.toggle("vms-busy", active);
|
|
114
110
|
}
|
|
115
|
-
/** 0.14.0 — install / clear the browser's `beforeunload` guard. The shell
|
|
116
|
-
* calls this on every response with `response.preventUnload ?? false`, so
|
|
117
|
-
* the lock-and-clear cycle is just "server sets preventUnload:true while
|
|
118
|
-
* work is pending, omits it (or sets false) when done." Idempotent. */
|
|
119
111
|
unloadHandler = null;
|
|
120
112
|
setPreventUnload(active) {
|
|
121
113
|
if (active && this.unloadHandler == null) {
|
|
122
114
|
this.unloadHandler = (e) => {
|
|
123
|
-
// Two signals because browsers historically disagreed on which they
|
|
124
|
-
// honor; modern Chromium/Firefox honor preventDefault, older Safari
|
|
125
|
-
// and ancient browsers look at returnValue. The dialog text itself is
|
|
126
|
-
// browser-controlled ("Leave site?…") and cannot be customized.
|
|
127
115
|
e.preventDefault();
|
|
128
116
|
e.returnValue = "";
|
|
129
117
|
};
|
|
@@ -137,70 +125,40 @@ export class BrowserAdapter {
|
|
|
137
125
|
async transport(input, init, hooks) {
|
|
138
126
|
const onUploadProgress = hooks?.onUploadProgress;
|
|
139
127
|
if (!onUploadProgress) {
|
|
140
|
-
// No progress requested → identical to the core fetch path (D-02 fallback parity).
|
|
141
128
|
return fetch(input, init);
|
|
142
129
|
}
|
|
143
130
|
return new Promise((resolve, reject) => {
|
|
144
131
|
const xhr = new XMLHttpRequest();
|
|
145
|
-
// IN-01: this seam only exists to carry a body+files action request, so
|
|
146
|
-
// a method-less init is non-sensical. dispatch() (the sole caller) always
|
|
147
|
-
// passes "POST"; default to "POST" (not "GET") so a future caller bug
|
|
148
|
-
// never silently produces a body-bearing GET.
|
|
149
132
|
xhr.open(init.method ?? "POST", input);
|
|
150
|
-
// WR-02: every header dispatch() builds in `init.headers` (Accept +
|
|
151
|
-
// getRequestHeaders()) is applied here, so the XHR path's request
|
|
152
|
-
// headers are byte-identical to the fetch path's. Scope note: this
|
|
153
|
-
// seam is same-origin only. The fetch fallback sends cookies on
|
|
154
|
-
// same-origin requests via its default `credentials: "same-origin"`;
|
|
155
|
-
// XHR sends same-origin cookies without `withCredentials`, so the
|
|
156
|
-
// common (same-origin `actionEndpoint`) case matches fetch exactly.
|
|
157
|
-
// Cross-origin action endpoints are out of scope for this transport.
|
|
158
133
|
for (const [k, v] of Object.entries(init.headers ?? {})) {
|
|
159
134
|
xhr.setRequestHeader(k, v);
|
|
160
135
|
}
|
|
161
|
-
let knownTotal = 0;
|
|
162
|
-
let lastLoaded = 0;
|
|
136
|
+
let knownTotal = 0;
|
|
137
|
+
let lastLoaded = 0;
|
|
163
138
|
xhr.upload.onprogress = (e) => {
|
|
164
139
|
lastLoaded = e.loaded;
|
|
165
140
|
if (e.lengthComputable) {
|
|
166
141
|
knownTotal = e.total;
|
|
167
|
-
onUploadProgress(e.loaded, e.total);
|
|
142
|
+
onUploadProgress(e.loaded, e.total);
|
|
168
143
|
}
|
|
169
144
|
else {
|
|
170
|
-
onUploadProgress(e.loaded, 0);
|
|
145
|
+
onUploadProgress(e.loaded, 0);
|
|
171
146
|
}
|
|
172
147
|
};
|
|
173
148
|
xhr.onload = () => {
|
|
174
|
-
// D-05 terminal emission: mirror whichever value was being reported.
|
|
175
|
-
// Known total → (total, total); indeterminate → (finalLoaded, finalLoaded).
|
|
176
|
-
// NEVER (0,0) once any progress event has fired; a body that produces
|
|
177
|
-
// no progress event (e.g. a zero-byte upload, or a transport that
|
|
178
|
-
// completes before the browser emits any upload progress) legitimately
|
|
179
|
-
// terminates at (0,0), which the documented `total > 0` consumer guard
|
|
180
|
-
// (MIGRATION.md 5b) handles.
|
|
181
149
|
if (knownTotal > 0)
|
|
182
150
|
onUploadProgress(knownTotal, knownTotal);
|
|
183
151
|
else
|
|
184
152
|
onUploadProgress(lastLoaded, lastLoaded);
|
|
185
|
-
// D-08: status 0 means a network-level failure (CORS rejection / blocked
|
|
186
|
-
// request) where onload fired but onerror did not. The Fetch Response
|
|
187
|
-
// constructor throws RangeError for status 0, and that throw would land
|
|
188
|
-
// OUTSIDE the Promise executor (never settling it → dispatch() hangs).
|
|
189
|
-
// Reject instead so dispatch()'s try/catch routes it to onError —
|
|
190
|
-
// byte-identical to fetch, which rejects on CORS/network failure.
|
|
191
153
|
if (xhr.status === 0) {
|
|
192
154
|
reject(new Error(`Transport request to ${input} failed (status 0)`));
|
|
193
155
|
return;
|
|
194
156
|
}
|
|
195
|
-
// D-08: resolve a real Response so dispatch()'s res.ok / await res.json()
|
|
196
|
-
// / processResponse() is byte-identical to the fetch path.
|
|
197
157
|
resolve(new Response(xhr.responseText, {
|
|
198
158
|
status: xhr.status,
|
|
199
159
|
statusText: xhr.statusText,
|
|
200
160
|
}));
|
|
201
161
|
};
|
|
202
|
-
// D-07: error / timeout / abort → reject so dispatch()'s existing
|
|
203
|
-
// try/catch routes it to onError exactly like a failed fetch.
|
|
204
162
|
xhr.onerror = () => reject(new Error(`Transport request to ${input} failed`));
|
|
205
163
|
xhr.ontimeout = () => reject(new Error(`Transport request to ${input} timed out`));
|
|
206
164
|
xhr.onabort = () => reject(new Error(`Transport request to ${input} aborted`));
|
|
@@ -233,10 +191,7 @@ export class BrowserAdapter {
|
|
|
233
191
|
}
|
|
234
192
|
page(n, parent, on) {
|
|
235
193
|
const el = document.createElement("div");
|
|
236
|
-
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}${
|
|
237
|
-
// 0.7.0 (#13) — width override: "wide" or "full" opt-in via a closed
|
|
238
|
-
// union. Omitted = no modifier class (existing 1080px cap holds).
|
|
239
|
-
n.width ? ` vms-page--${n.width}` : ""}`;
|
|
194
|
+
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}${n.width ? ` vms-page--${n.width}` : ""}`;
|
|
240
195
|
if (n.title) {
|
|
241
196
|
const h = document.createElement("h1");
|
|
242
197
|
h.className = "vms-page__title";
|
|
@@ -274,45 +229,25 @@ export class BrowserAdapter {
|
|
|
274
229
|
this.kids(n.children, li, on);
|
|
275
230
|
parent.appendChild(li);
|
|
276
231
|
}
|
|
232
|
+
/** FormNode — no harvest. Field values live in state via their bind paths;
|
|
233
|
+
* submit dispatches just `{name}`. File inputs are walked for binaries to
|
|
234
|
+
* attach to action.files (multipart side channel). */
|
|
277
235
|
form(n, parent, on) {
|
|
278
236
|
const form = document.createElement("form");
|
|
279
237
|
form.className = `vms-form${n.layout && n.layout !== "stack" ? ` vms-form--${n.layout}` : ""}`;
|
|
280
238
|
form.noValidate = true;
|
|
281
239
|
this.kids(n.children, form, on);
|
|
282
|
-
|
|
283
|
-
// given action's context, and dispatch. Factored out of the submit
|
|
284
|
-
// handler so both the default submit AND each buttons[] entry can call it
|
|
285
|
-
// with a DIFFERENT action carrying the SAME live field values.
|
|
286
|
-
const harvest = (base) => {
|
|
287
|
-
const ctx = { ...(base.context ?? {}) };
|
|
240
|
+
const dispatchWithFiles = (action) => {
|
|
288
241
|
const files = {};
|
|
289
|
-
form.querySelectorAll("input:not([type=checkbox]):not([type=file]), textarea").forEach(el => { if (el.name)
|
|
290
|
-
ctx[el.name] = el.value; });
|
|
291
|
-
// Form-collected checkboxes (FieldNode inputType="checkbox").
|
|
292
|
-
// CheckboxNode renders with .vms-checkbox__input and is excluded so its
|
|
293
|
-
// immediate-dispatch path stays the only way it talks to the server.
|
|
294
|
-
form.querySelectorAll("input.vms-field__input[type=checkbox]").forEach(el => { if (el.name)
|
|
295
|
-
ctx[el.name] = el.checked; });
|
|
296
|
-
form.querySelectorAll("select:not([multiple])").forEach(sel => {
|
|
297
|
-
if (sel.name)
|
|
298
|
-
ctx[sel.name] = sel.value;
|
|
299
|
-
});
|
|
300
|
-
form.querySelectorAll("select[multiple]").forEach(sel => {
|
|
301
|
-
if (sel.name)
|
|
302
|
-
ctx[sel.name] = Array.from(sel.selectedOptions).map(o => o.value).join(",");
|
|
303
|
-
});
|
|
304
242
|
form.querySelectorAll("input[type=file]").forEach(inp => {
|
|
305
243
|
if (inp.name && inp.files?.[0])
|
|
306
244
|
files[inp.name] = inp.files[0];
|
|
307
245
|
});
|
|
308
|
-
const
|
|
246
|
+
const ev = { name: action.name };
|
|
309
247
|
if (Object.keys(files).length > 0)
|
|
310
|
-
|
|
311
|
-
on(
|
|
248
|
+
ev.files = files;
|
|
249
|
+
on(ev);
|
|
312
250
|
};
|
|
313
|
-
// Default submit button + Enter-to-submit, only when submitAction is set.
|
|
314
|
-
// (0.10.0: submitAction is now optional — a buttons[]-only form renders
|
|
315
|
-
// no default button, and Enter does not submit at the form level.)
|
|
316
251
|
if (n.submitAction) {
|
|
317
252
|
const submitAction = n.submitAction;
|
|
318
253
|
const submit = document.createElement("button");
|
|
@@ -322,39 +257,42 @@ export class BrowserAdapter {
|
|
|
322
257
|
form.appendChild(submit);
|
|
323
258
|
form.addEventListener("submit", (e) => {
|
|
324
259
|
e.preventDefault();
|
|
325
|
-
|
|
260
|
+
dispatchWithFiles(submitAction);
|
|
326
261
|
});
|
|
327
262
|
}
|
|
328
263
|
else {
|
|
329
|
-
// No default submit —
|
|
264
|
+
// No default submit — neutralize implicit Enter submission so a
|
|
330
265
|
// single-field buttons[]-only form doesn't reload via native submit.
|
|
331
266
|
form.addEventListener("submit", (e) => e.preventDefault());
|
|
332
267
|
}
|
|
333
|
-
// 0.10.0 (#15) — multi-action buttons. Each renders through the normal
|
|
334
|
-
// button() path (so variant + pendingLabel work) but its onAction is
|
|
335
|
-
// wrapped to harvest the form first. We render them in a footer row so
|
|
336
|
-
// they group like the default submit.
|
|
337
268
|
if (n.buttons && n.buttons.length > 0) {
|
|
338
269
|
const row = document.createElement("div");
|
|
339
270
|
row.className = "vms-form__buttons";
|
|
340
|
-
const
|
|
271
|
+
const buttonOn = (action) => dispatchWithFiles(action);
|
|
341
272
|
for (const btn of n.buttons)
|
|
342
|
-
this.button(btn, row,
|
|
273
|
+
this.button(btn, row, buttonOn);
|
|
343
274
|
form.appendChild(row);
|
|
344
275
|
}
|
|
345
276
|
parent.appendChild(form);
|
|
346
277
|
}
|
|
278
|
+
/** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
|
|
279
|
+
* When `action` is set, it fires on Enter (text-like) or change (select) —
|
|
280
|
+
* the new value is already in state by that point. */
|
|
347
281
|
field(n, parent, on) {
|
|
282
|
+
const stateValue = this.sa.read(n.bind);
|
|
348
283
|
if (n.inputType === "hidden") {
|
|
284
|
+
// Hidden fields don't write back — server is authoritative for hidden.
|
|
349
285
|
const inp = document.createElement("input");
|
|
350
286
|
inp.type = "hidden";
|
|
351
287
|
inp.name = n.name;
|
|
352
|
-
|
|
353
|
-
inp.value = n.value;
|
|
288
|
+
inp.value = stateValue == null ? "" : String(stateValue);
|
|
354
289
|
parent.appendChild(inp);
|
|
355
290
|
return;
|
|
356
291
|
}
|
|
357
292
|
if (n.inputType === "checkbox") {
|
|
293
|
+
// FieldNode of type checkbox — used as a form-collected checkbox (the
|
|
294
|
+
// standalone CheckboxNode is the immediate-dispatch variant). Bind path
|
|
295
|
+
// holds a boolean.
|
|
358
296
|
const wrapper = document.createElement("div");
|
|
359
297
|
wrapper.className = "vms-field vms-field--checkbox";
|
|
360
298
|
const inp = document.createElement("input");
|
|
@@ -362,7 +300,10 @@ export class BrowserAdapter {
|
|
|
362
300
|
inp.className = "vms-field__input";
|
|
363
301
|
inp.id = `vms-${n.name}`;
|
|
364
302
|
inp.name = n.name;
|
|
365
|
-
inp.checked =
|
|
303
|
+
inp.checked = Boolean(stateValue);
|
|
304
|
+
inp.addEventListener("change", () => {
|
|
305
|
+
this.sa.write(n.bind, inp.checked);
|
|
306
|
+
});
|
|
366
307
|
wrapper.appendChild(inp);
|
|
367
308
|
if (n.label) {
|
|
368
309
|
const lbl = document.createElement("label");
|
|
@@ -389,21 +330,29 @@ export class BrowserAdapter {
|
|
|
389
330
|
sel.id = `vms-${n.name}`;
|
|
390
331
|
sel.name = n.name;
|
|
391
332
|
sel.multiple = n.inputType === "select-multiple";
|
|
333
|
+
const isMulti = n.inputType === "select-multiple";
|
|
334
|
+
const selectedSet = isMulti && Array.isArray(stateValue)
|
|
335
|
+
? new Set(stateValue.map(String))
|
|
336
|
+
: new Set();
|
|
337
|
+
const selectedSingle = !isMulti && stateValue != null ? String(stateValue) : "";
|
|
392
338
|
(n.options ?? []).forEach(opt => {
|
|
393
339
|
const o = document.createElement("option");
|
|
394
340
|
o.value = opt.value;
|
|
395
341
|
o.textContent = opt.label;
|
|
396
|
-
o.selected =
|
|
397
|
-
? (n.value ?? "").split(",").map(s => s.trim()).includes(opt.value)
|
|
398
|
-
: opt.value === n.value;
|
|
342
|
+
o.selected = isMulti ? selectedSet.has(opt.value) : opt.value === selectedSingle;
|
|
399
343
|
sel.appendChild(o);
|
|
400
344
|
});
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
345
|
+
sel.addEventListener("change", () => {
|
|
346
|
+
if (isMulti) {
|
|
347
|
+
const arr = Array.from(sel.selectedOptions).map(o => o.value);
|
|
348
|
+
this.sa.write(n.bind, arr);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
this.sa.write(n.bind, sel.value);
|
|
352
|
+
}
|
|
353
|
+
if (n.action)
|
|
354
|
+
on({ name: n.action.name });
|
|
355
|
+
});
|
|
407
356
|
wrapper.appendChild(sel);
|
|
408
357
|
}
|
|
409
358
|
else if (n.inputType === "file") {
|
|
@@ -412,6 +361,7 @@ export class BrowserAdapter {
|
|
|
412
361
|
inp.className = "vms-field__input";
|
|
413
362
|
inp.id = `vms-${n.name}`;
|
|
414
363
|
inp.name = n.name;
|
|
364
|
+
// File-input persistence: re-apply any registered file to the new node.
|
|
415
365
|
const existingFile = this.fileRegistry.get(n.name);
|
|
416
366
|
if (existingFile) {
|
|
417
367
|
try {
|
|
@@ -419,14 +369,21 @@ export class BrowserAdapter {
|
|
|
419
369
|
dt.items.add(existingFile);
|
|
420
370
|
inp.files = dt.files;
|
|
421
371
|
}
|
|
422
|
-
catch { }
|
|
372
|
+
catch { /* nothing */ }
|
|
423
373
|
}
|
|
424
374
|
inp.addEventListener("change", () => {
|
|
425
375
|
const file = inp.files?.[0];
|
|
426
|
-
if (file)
|
|
376
|
+
if (file) {
|
|
427
377
|
this.fileRegistry.set(n.name, file);
|
|
428
|
-
|
|
378
|
+
// Per Phase-6 decision: the picked file is visible in state as a
|
|
379
|
+
// serialization-safe placeholder; the binary travels on the
|
|
380
|
+
// multipart side channel.
|
|
381
|
+
this.sa.write(n.bind, { filename: file.name, size: file.size });
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
429
384
|
this.fileRegistry.delete(n.name);
|
|
385
|
+
this.sa.write(n.bind, null);
|
|
386
|
+
}
|
|
430
387
|
});
|
|
431
388
|
wrapper.appendChild(inp);
|
|
432
389
|
}
|
|
@@ -437,10 +394,10 @@ export class BrowserAdapter {
|
|
|
437
394
|
ta.name = n.name;
|
|
438
395
|
if (n.placeholder)
|
|
439
396
|
ta.placeholder = n.placeholder;
|
|
440
|
-
|
|
441
|
-
ta.value = n.value;
|
|
397
|
+
ta.value = stateValue == null ? "" : String(stateValue);
|
|
442
398
|
if (n.required)
|
|
443
399
|
ta.required = true;
|
|
400
|
+
ta.addEventListener("input", () => { this.sa.write(n.bind, ta.value); });
|
|
444
401
|
wrapper.appendChild(ta);
|
|
445
402
|
}
|
|
446
403
|
else if (n.inputType === "code") {
|
|
@@ -460,10 +417,10 @@ export class BrowserAdapter {
|
|
|
460
417
|
ta.setAttribute("autocorrect", "off");
|
|
461
418
|
if (n.placeholder)
|
|
462
419
|
ta.placeholder = n.placeholder;
|
|
463
|
-
|
|
464
|
-
ta.value = n.value;
|
|
420
|
+
ta.value = stateValue == null ? "" : String(stateValue);
|
|
465
421
|
if (n.required)
|
|
466
422
|
ta.required = true;
|
|
423
|
+
ta.addEventListener("input", () => { this.sa.write(n.bind, ta.value); });
|
|
467
424
|
ta.addEventListener("keydown", (e) => {
|
|
468
425
|
if (e.key === "Tab") {
|
|
469
426
|
e.preventDefault();
|
|
@@ -471,6 +428,7 @@ export class BrowserAdapter {
|
|
|
471
428
|
const end = ta.selectionEnd ?? 0;
|
|
472
429
|
ta.value = ta.value.slice(0, start) + "\t" + ta.value.slice(end);
|
|
473
430
|
ta.selectionStart = ta.selectionEnd = start + 1;
|
|
431
|
+
this.sa.write(n.bind, ta.value);
|
|
474
432
|
}
|
|
475
433
|
});
|
|
476
434
|
wrapper.appendChild(ta);
|
|
@@ -483,16 +441,20 @@ export class BrowserAdapter {
|
|
|
483
441
|
inp.name = n.name;
|
|
484
442
|
if (n.placeholder)
|
|
485
443
|
inp.placeholder = n.placeholder;
|
|
486
|
-
|
|
487
|
-
inp.value = n.value;
|
|
444
|
+
inp.value = stateValue == null ? "" : String(stateValue);
|
|
488
445
|
if (n.required)
|
|
489
446
|
inp.required = true;
|
|
447
|
+
inp.addEventListener("input", () => { this.sa.write(n.bind, inp.value); });
|
|
490
448
|
if (n.action) {
|
|
491
449
|
const action = n.action;
|
|
492
450
|
inp.addEventListener("keydown", (e) => {
|
|
493
451
|
if (e.key === "Enter") {
|
|
494
452
|
e.preventDefault();
|
|
495
|
-
|
|
453
|
+
// Belt-and-suspenders: flush the latest value to state before
|
|
454
|
+
// dispatching, in case the browser hasn't fired `input` yet
|
|
455
|
+
// (e.g. an autofill that lands then submits).
|
|
456
|
+
this.sa.write(n.bind, inp.value);
|
|
457
|
+
on({ name: action.name });
|
|
496
458
|
}
|
|
497
459
|
});
|
|
498
460
|
}
|
|
@@ -500,6 +462,8 @@ export class BrowserAdapter {
|
|
|
500
462
|
}
|
|
501
463
|
parent.appendChild(wrapper);
|
|
502
464
|
}
|
|
465
|
+
/** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
|
|
466
|
+
* write to state then dispatch the action name (if any). */
|
|
503
467
|
checkbox(n, parent, on) {
|
|
504
468
|
const lbl = document.createElement("label");
|
|
505
469
|
lbl.className = "vms-checkbox";
|
|
@@ -507,7 +471,7 @@ export class BrowserAdapter {
|
|
|
507
471
|
inp.type = "checkbox";
|
|
508
472
|
inp.className = "vms-checkbox__input";
|
|
509
473
|
inp.name = n.name;
|
|
510
|
-
inp.checked = n.
|
|
474
|
+
inp.checked = Boolean(this.sa.read(n.bind));
|
|
511
475
|
const mark = document.createElement("span");
|
|
512
476
|
mark.className = "vms-checkbox__mark";
|
|
513
477
|
lbl.appendChild(inp);
|
|
@@ -518,12 +482,11 @@ export class BrowserAdapter {
|
|
|
518
482
|
span.textContent = n.label;
|
|
519
483
|
lbl.appendChild(span);
|
|
520
484
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
on({ name: action.name
|
|
525
|
-
|
|
526
|
-
}
|
|
485
|
+
inp.addEventListener("change", () => {
|
|
486
|
+
this.sa.write(n.bind, inp.checked);
|
|
487
|
+
if (n.action)
|
|
488
|
+
on({ name: n.action.name });
|
|
489
|
+
});
|
|
527
490
|
parent.appendChild(lbl);
|
|
528
491
|
}
|
|
529
492
|
button(n, parent, on) {
|
|
@@ -532,13 +495,10 @@ export class BrowserAdapter {
|
|
|
532
495
|
btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
|
|
533
496
|
btn.textContent = n.label;
|
|
534
497
|
btn.addEventListener("click", () => {
|
|
535
|
-
//
|
|
536
|
-
//
|
|
498
|
+
// pendingLabel: instant client-side feedback. Swap text + add
|
|
499
|
+
// .vms-button--pending BEFORE handing off to the dispatcher. On
|
|
537
500
|
// success the next render replaces the button entirely. On dispatch
|
|
538
|
-
// error, the shell
|
|
539
|
-
// the original label snaps back automatically — no per-button cleanup
|
|
540
|
-
// wiring needed in the adapter. Pure-client ephemeral state; never
|
|
541
|
-
// round-trips through the wire.
|
|
501
|
+
// error, the shell re-renders so the original label snaps back.
|
|
542
502
|
if (n.pendingLabel) {
|
|
543
503
|
btn.textContent = n.pendingLabel;
|
|
544
504
|
btn.classList.add("vms-button--pending");
|
|
@@ -582,6 +542,8 @@ export class BrowserAdapter {
|
|
|
582
542
|
});
|
|
583
543
|
parent.appendChild(bar);
|
|
584
544
|
}
|
|
545
|
+
/** TabsNode — on click, write tab.value to state at node.bind, then dispatch
|
|
546
|
+
* the tab's own action name. */
|
|
585
547
|
tabs(n, parent, on) {
|
|
586
548
|
const nav = document.createElement("nav");
|
|
587
549
|
nav.className = "vms-tabs";
|
|
@@ -593,7 +555,8 @@ export class BrowserAdapter {
|
|
|
593
555
|
btn.setAttribute("role", "tab");
|
|
594
556
|
btn.setAttribute("aria-selected", String(tab.value === n.selected));
|
|
595
557
|
btn.addEventListener("click", () => {
|
|
596
|
-
|
|
558
|
+
this.sa.write(n.bind, tab.value);
|
|
559
|
+
on({ name: tab.action.name });
|
|
597
560
|
});
|
|
598
561
|
nav.appendChild(btn);
|
|
599
562
|
});
|
|
@@ -659,6 +622,12 @@ export class BrowserAdapter {
|
|
|
659
622
|
backdrop.appendChild(modal);
|
|
660
623
|
parent.appendChild(backdrop);
|
|
661
624
|
}
|
|
625
|
+
/** TableNode — sort writes {column, direction} to sortBind then dispatches
|
|
626
|
+
* sortActions[col.key]; filter inputs are bound to filterBinds[col.key],
|
|
627
|
+
* every keystroke writes, Enter dispatches filterAction; pagination
|
|
628
|
+
* prev/next write the target page to paginationBind then dispatch
|
|
629
|
+
* prevAction/nextAction. Per-row buttons are plain ButtonNodes. Selection
|
|
630
|
+
* is no longer a framework concept. */
|
|
662
631
|
table(n, parent, on) {
|
|
663
632
|
const wrapper = document.createElement("div");
|
|
664
633
|
wrapper.className = "vms-table-wrapper";
|
|
@@ -666,41 +635,13 @@ export class BrowserAdapter {
|
|
|
666
635
|
table.className = "vms-table";
|
|
667
636
|
const thead = document.createElement("thead");
|
|
668
637
|
const headerRow = document.createElement("tr");
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
const selectedSet = sel ? new Set(sel.selectedIds) : null;
|
|
673
|
-
if (sel) {
|
|
674
|
-
const th = document.createElement("th");
|
|
675
|
-
th.className = "vms-table__th vms-table__th--select";
|
|
676
|
-
const allOnPage = n.rows.length > 0 && n.rows.every(r => r.id != null && selectedSet.has(r.id));
|
|
677
|
-
const someOnPage = n.rows.some(r => r.id != null && selectedSet.has(r.id));
|
|
678
|
-
const box = document.createElement("input");
|
|
679
|
-
box.type = "checkbox";
|
|
680
|
-
box.className = "vms-table__select vms-table__select--all";
|
|
681
|
-
box.checked = allOnPage;
|
|
682
|
-
box.indeterminate = someOnPage && !allOnPage;
|
|
683
|
-
box.addEventListener("change", () => {
|
|
684
|
-
// Toggle every row checkbox + class in this table to match the header.
|
|
685
|
-
// No dispatch — selection is purely DOM-local; bulk actions harvest the
|
|
686
|
-
// checked rows when a `selection.buttons[]` entry is clicked.
|
|
687
|
-
const want = box.checked;
|
|
688
|
-
table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
|
|
689
|
-
if (rowBox.disabled)
|
|
690
|
-
return;
|
|
691
|
-
rowBox.checked = want;
|
|
692
|
-
const tr = rowBox.closest(".vms-table__row");
|
|
693
|
-
if (tr)
|
|
694
|
-
tr.classList.toggle("vms-table__row--selected", want);
|
|
695
|
-
});
|
|
696
|
-
});
|
|
697
|
-
th.appendChild(box);
|
|
698
|
-
headerRow.appendChild(th);
|
|
699
|
-
}
|
|
638
|
+
const sortIntent = (n.sortBind != null ? this.sa.read(n.sortBind) : null);
|
|
639
|
+
const sortedCol = sortIntent?.column;
|
|
640
|
+
const sortedDir = sortIntent?.direction;
|
|
700
641
|
n.columns.forEach(col => {
|
|
701
642
|
const th = document.createElement("th");
|
|
702
|
-
const isSorted = col.key ===
|
|
703
|
-
const dir = isSorted ? (
|
|
643
|
+
const isSorted = col.key === sortedCol;
|
|
644
|
+
const dir = isSorted ? (sortedDir ?? "asc") : null;
|
|
704
645
|
let classes = "vms-table__th";
|
|
705
646
|
if (col.sortable)
|
|
706
647
|
classes += " vms-table__th--sortable";
|
|
@@ -710,11 +651,17 @@ export class BrowserAdapter {
|
|
|
710
651
|
classes += " vms-table__th--desc";
|
|
711
652
|
th.className = classes;
|
|
712
653
|
th.textContent = col.label;
|
|
713
|
-
|
|
714
|
-
|
|
654
|
+
const sortAction = n.sortActions?.[col.key];
|
|
655
|
+
if (col.sortable && sortAction && n.sortBind != null) {
|
|
656
|
+
const sortBind = n.sortBind;
|
|
715
657
|
th.addEventListener("click", () => {
|
|
716
|
-
|
|
717
|
-
|
|
658
|
+
// Read current sort intent at click time (not render time): if no
|
|
659
|
+
// re-render has happened between clicks, the closure-captured
|
|
660
|
+
// sortedDir would be stale.
|
|
661
|
+
const cur = this.sa.read(sortBind);
|
|
662
|
+
const nextDir = cur?.column === col.key && cur?.direction === "asc" ? "desc" : "asc";
|
|
663
|
+
this.sa.write(sortBind, { column: col.key, direction: nextDir });
|
|
664
|
+
on({ name: sortAction.name });
|
|
718
665
|
});
|
|
719
666
|
}
|
|
720
667
|
headerRow.appendChild(th);
|
|
@@ -725,8 +672,6 @@ export class BrowserAdapter {
|
|
|
725
672
|
const filterAction = n.filterAction;
|
|
726
673
|
const filterRow = document.createElement("tr");
|
|
727
674
|
filterRow.className = "vms-table__filter-row";
|
|
728
|
-
if (sel)
|
|
729
|
-
filterRow.appendChild(document.createElement("th")); // align under the select column
|
|
730
675
|
n.columns.forEach(col => {
|
|
731
676
|
const th = document.createElement("th");
|
|
732
677
|
if (col.filterable) {
|
|
@@ -734,15 +679,20 @@ export class BrowserAdapter {
|
|
|
734
679
|
inp.type = "text";
|
|
735
680
|
inp.className = "vms-table__filter-input";
|
|
736
681
|
inp.dataset.col = col.key;
|
|
737
|
-
|
|
682
|
+
const bindPath = n.filterBinds?.[col.key];
|
|
683
|
+
const bound = bindPath != null ? this.sa.read(bindPath) : undefined;
|
|
684
|
+
inp.value = bound != null
|
|
685
|
+
? String(bound)
|
|
686
|
+
: (col.filterValue ?? "");
|
|
738
687
|
inp.placeholder = `Filter…`;
|
|
688
|
+
if (bindPath != null) {
|
|
689
|
+
inp.addEventListener("input", () => { this.sa.write(bindPath, inp.value); });
|
|
690
|
+
}
|
|
739
691
|
inp.addEventListener("keydown", (e) => {
|
|
740
692
|
if (e.key === "Enter") {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
});
|
|
745
|
-
on({ name: filterAction.name, context: { ...(filterAction.context ?? {}), column: col.key, value: inp.value, filters } });
|
|
693
|
+
if (bindPath != null)
|
|
694
|
+
this.sa.write(bindPath, inp.value);
|
|
695
|
+
on({ name: filterAction.name });
|
|
746
696
|
}
|
|
747
697
|
});
|
|
748
698
|
th.appendChild(inp);
|
|
@@ -758,53 +708,9 @@ export class BrowserAdapter {
|
|
|
758
708
|
let rowClass = "vms-table__row";
|
|
759
709
|
if (row.variant)
|
|
760
710
|
rowClass += ` vms-table__row--${row.variant}`;
|
|
761
|
-
if (row.action)
|
|
762
|
-
rowClass += " vms-table__row--clickable";
|
|
763
|
-
const isSelected = sel != null && row.id != null && selectedSet.has(row.id);
|
|
764
|
-
if (isSelected)
|
|
765
|
-
rowClass += " vms-table__row--selected";
|
|
766
711
|
tr.className = rowClass;
|
|
767
712
|
if (row.id)
|
|
768
713
|
tr.dataset.id = row.id;
|
|
769
|
-
if (row.action) {
|
|
770
|
-
const rowAction = row.action;
|
|
771
|
-
tr.addEventListener("click", () => on(rowAction));
|
|
772
|
-
}
|
|
773
|
-
if (sel) {
|
|
774
|
-
const td = document.createElement("td");
|
|
775
|
-
td.className = "vms-table__td vms-table__td--select";
|
|
776
|
-
// A click in the checkbox cell must not also fire the row's click action.
|
|
777
|
-
td.addEventListener("click", (e) => e.stopPropagation());
|
|
778
|
-
const box = document.createElement("input");
|
|
779
|
-
box.type = "checkbox";
|
|
780
|
-
box.className = "vms-table__select";
|
|
781
|
-
box.checked = isSelected;
|
|
782
|
-
if (row.id != null) {
|
|
783
|
-
const rowId = row.id;
|
|
784
|
-
// data-id is what selection.buttons[] harvest reads on click.
|
|
785
|
-
box.dataset.id = rowId;
|
|
786
|
-
box.addEventListener("change", () => {
|
|
787
|
-
// Flip the row class to mirror the box, then reconcile the header
|
|
788
|
-
// select-all (could now be all / some / none). No dispatch — bulk
|
|
789
|
-
// actions read the DOM via the selection.buttons[] harvest.
|
|
790
|
-
tr.classList.toggle("vms-table__row--selected", box.checked);
|
|
791
|
-
const headerBox = table.querySelector("thead input.vms-table__select--all");
|
|
792
|
-
if (headerBox) {
|
|
793
|
-
const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
|
|
794
|
-
let checked = 0;
|
|
795
|
-
all.forEach(b => { if (b.checked)
|
|
796
|
-
checked++; });
|
|
797
|
-
headerBox.checked = all.length > 0 && checked === all.length;
|
|
798
|
-
headerBox.indeterminate = checked > 0 && checked < all.length;
|
|
799
|
-
}
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
else {
|
|
803
|
-
box.disabled = true; // selection addresses rows by id; a row without one can't be selected
|
|
804
|
-
}
|
|
805
|
-
td.appendChild(box);
|
|
806
|
-
tr.appendChild(td);
|
|
807
|
-
}
|
|
808
714
|
n.columns.forEach(col => {
|
|
809
715
|
const td = document.createElement("td");
|
|
810
716
|
td.className = "vms-table__td";
|
|
@@ -825,29 +731,18 @@ export class BrowserAdapter {
|
|
|
825
731
|
}
|
|
826
732
|
tr.appendChild(td);
|
|
827
733
|
});
|
|
734
|
+
// Per-row buttons render as plain ButtonNodes in a trailing actions cell.
|
|
735
|
+
if (row.actions && row.actions.length > 0) {
|
|
736
|
+
const td = document.createElement("td");
|
|
737
|
+
td.className = "vms-table__td vms-table__td--actions";
|
|
738
|
+
for (const btn of row.actions)
|
|
739
|
+
this.button(btn, td, on);
|
|
740
|
+
tr.appendChild(td);
|
|
741
|
+
}
|
|
828
742
|
tbody.appendChild(tr);
|
|
829
743
|
});
|
|
830
744
|
table.appendChild(tbody);
|
|
831
745
|
wrapper.appendChild(table);
|
|
832
|
-
// 0.13.0 — bulk-action toolbar rendered ABOVE the table. Each button's
|
|
833
|
-
// click harvests the table's currently-checked row ids and merges them as
|
|
834
|
-
// `selectedIds` into the action's context. Works with both server-truth
|
|
835
|
-
// mode (the DOM mirrors selectedIds so the harvest matches state) and
|
|
836
|
-
// local mode (the DOM is the only source of selection truth).
|
|
837
|
-
if (sel?.buttons && sel.buttons.length > 0) {
|
|
838
|
-
const toolbar = document.createElement("div");
|
|
839
|
-
toolbar.className = "vms-table__bulk-actions";
|
|
840
|
-
const harvest = (action) => {
|
|
841
|
-
const ids = [];
|
|
842
|
-
table.querySelectorAll("tbody input.vms-table__select:checked").forEach(b => {
|
|
843
|
-
if (b.dataset.id)
|
|
844
|
-
ids.push(b.dataset.id);
|
|
845
|
-
});
|
|
846
|
-
on({ name: action.name, context: { ...(action.context ?? {}), selectedIds: ids } });
|
|
847
|
-
};
|
|
848
|
-
sel.buttons.forEach(btn => this.button(btn, toolbar, harvest));
|
|
849
|
-
wrapper.insertBefore(toolbar, table);
|
|
850
|
-
}
|
|
851
746
|
if (n.pagination) {
|
|
852
747
|
const pg = n.pagination;
|
|
853
748
|
const footer = document.createElement("div");
|
|
@@ -859,19 +754,26 @@ export class BrowserAdapter {
|
|
|
859
754
|
range.className = "vms-table__pagination-range";
|
|
860
755
|
range.textContent = `${from}–${to} of ${pg.totalRows}`;
|
|
861
756
|
footer.appendChild(range);
|
|
862
|
-
const
|
|
863
|
-
const mkBtn = (label, targetPage, disabled) => {
|
|
757
|
+
const paginationBind = n.paginationBind;
|
|
758
|
+
const mkBtn = (label, targetPage, action, disabled) => {
|
|
864
759
|
const b = document.createElement("button");
|
|
865
760
|
b.type = "button";
|
|
866
761
|
b.className = "vms-button vms-button--secondary vms-table__pagination-btn";
|
|
867
762
|
b.textContent = label;
|
|
868
763
|
b.disabled = disabled;
|
|
869
|
-
if (!disabled)
|
|
870
|
-
b.addEventListener("click", () =>
|
|
764
|
+
if (!disabled && action) {
|
|
765
|
+
b.addEventListener("click", () => {
|
|
766
|
+
if (paginationBind != null)
|
|
767
|
+
this.sa.write(paginationBind, targetPage);
|
|
768
|
+
on({ name: action.name });
|
|
769
|
+
});
|
|
770
|
+
}
|
|
871
771
|
return b;
|
|
872
772
|
};
|
|
873
|
-
|
|
874
|
-
|
|
773
|
+
const prevDisabled = pg.page <= 1 || pg.prevAction == null;
|
|
774
|
+
const nextDisabled = pg.page >= totalPages || pg.nextAction == null;
|
|
775
|
+
footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.prevAction, prevDisabled));
|
|
776
|
+
footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.nextAction, nextDisabled));
|
|
875
777
|
wrapper.appendChild(footer);
|
|
876
778
|
}
|
|
877
779
|
parent.appendChild(wrapper);
|
|
@@ -879,9 +781,6 @@ export class BrowserAdapter {
|
|
|
879
781
|
copyButton(n, parent) {
|
|
880
782
|
const btn = document.createElement("button");
|
|
881
783
|
btn.type = "button";
|
|
882
|
-
// 0.9.0 (#14): variant modifier class, mirroring button() exactly. The
|
|
883
|
-
// existing .vms-button--{primary,secondary,danger} CSS rules apply
|
|
884
|
-
// automatically — no new style surface.
|
|
885
784
|
btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
|
|
886
785
|
btn.textContent = n.label ?? "Copy";
|
|
887
786
|
btn.addEventListener("click", () => {
|
|
@@ -891,21 +790,17 @@ export class BrowserAdapter {
|
|
|
891
790
|
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
892
791
|
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
893
792
|
}).catch(() => {
|
|
894
|
-
// primary failed — try legacy execCommand fallback
|
|
895
793
|
if (legacyCopy(n.text)) {
|
|
896
794
|
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
897
795
|
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
898
796
|
}
|
|
899
|
-
// both paths failed: silent, no confirmation
|
|
900
797
|
});
|
|
901
798
|
}
|
|
902
799
|
else {
|
|
903
|
-
// navigator.clipboard absent — try legacy
|
|
904
800
|
if (legacyCopy(n.text)) {
|
|
905
801
|
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
906
802
|
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
907
803
|
}
|
|
908
|
-
// else: silent
|
|
909
804
|
}
|
|
910
805
|
});
|
|
911
806
|
parent.appendChild(btn);
|