@ashley-shrok/viewmodel-shell 0.15.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 +20 -9
- package/dist/browser.js +168 -266
- package/dist/index.d.ts +142 -45
- package/dist/index.js +262 -11
- package/dist/server.d.ts +79 -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 +27 -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,23 +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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
* work is pending, omits it (or sets false) when done." Idempotent. */
|
|
108
|
+
setBusy(active) {
|
|
109
|
+
this.container.classList.toggle("vms-busy", active);
|
|
110
|
+
}
|
|
112
111
|
unloadHandler = null;
|
|
113
112
|
setPreventUnload(active) {
|
|
114
113
|
if (active && this.unloadHandler == null) {
|
|
115
114
|
this.unloadHandler = (e) => {
|
|
116
|
-
// Two signals because browsers historically disagreed on which they
|
|
117
|
-
// honor; modern Chromium/Firefox honor preventDefault, older Safari
|
|
118
|
-
// and ancient browsers look at returnValue. The dialog text itself is
|
|
119
|
-
// browser-controlled ("Leave site?…") and cannot be customized.
|
|
120
115
|
e.preventDefault();
|
|
121
116
|
e.returnValue = "";
|
|
122
117
|
};
|
|
@@ -130,70 +125,40 @@ export class BrowserAdapter {
|
|
|
130
125
|
async transport(input, init, hooks) {
|
|
131
126
|
const onUploadProgress = hooks?.onUploadProgress;
|
|
132
127
|
if (!onUploadProgress) {
|
|
133
|
-
// No progress requested → identical to the core fetch path (D-02 fallback parity).
|
|
134
128
|
return fetch(input, init);
|
|
135
129
|
}
|
|
136
130
|
return new Promise((resolve, reject) => {
|
|
137
131
|
const xhr = new XMLHttpRequest();
|
|
138
|
-
// IN-01: this seam only exists to carry a body+files action request, so
|
|
139
|
-
// a method-less init is non-sensical. dispatch() (the sole caller) always
|
|
140
|
-
// passes "POST"; default to "POST" (not "GET") so a future caller bug
|
|
141
|
-
// never silently produces a body-bearing GET.
|
|
142
132
|
xhr.open(init.method ?? "POST", input);
|
|
143
|
-
// WR-02: every header dispatch() builds in `init.headers` (Accept +
|
|
144
|
-
// getRequestHeaders()) is applied here, so the XHR path's request
|
|
145
|
-
// headers are byte-identical to the fetch path's. Scope note: this
|
|
146
|
-
// seam is same-origin only. The fetch fallback sends cookies on
|
|
147
|
-
// same-origin requests via its default `credentials: "same-origin"`;
|
|
148
|
-
// XHR sends same-origin cookies without `withCredentials`, so the
|
|
149
|
-
// common (same-origin `actionEndpoint`) case matches fetch exactly.
|
|
150
|
-
// Cross-origin action endpoints are out of scope for this transport.
|
|
151
133
|
for (const [k, v] of Object.entries(init.headers ?? {})) {
|
|
152
134
|
xhr.setRequestHeader(k, v);
|
|
153
135
|
}
|
|
154
|
-
let knownTotal = 0;
|
|
155
|
-
let lastLoaded = 0;
|
|
136
|
+
let knownTotal = 0;
|
|
137
|
+
let lastLoaded = 0;
|
|
156
138
|
xhr.upload.onprogress = (e) => {
|
|
157
139
|
lastLoaded = e.loaded;
|
|
158
140
|
if (e.lengthComputable) {
|
|
159
141
|
knownTotal = e.total;
|
|
160
|
-
onUploadProgress(e.loaded, e.total);
|
|
142
|
+
onUploadProgress(e.loaded, e.total);
|
|
161
143
|
}
|
|
162
144
|
else {
|
|
163
|
-
onUploadProgress(e.loaded, 0);
|
|
145
|
+
onUploadProgress(e.loaded, 0);
|
|
164
146
|
}
|
|
165
147
|
};
|
|
166
148
|
xhr.onload = () => {
|
|
167
|
-
// D-05 terminal emission: mirror whichever value was being reported.
|
|
168
|
-
// Known total → (total, total); indeterminate → (finalLoaded, finalLoaded).
|
|
169
|
-
// NEVER (0,0) once any progress event has fired; a body that produces
|
|
170
|
-
// no progress event (e.g. a zero-byte upload, or a transport that
|
|
171
|
-
// completes before the browser emits any upload progress) legitimately
|
|
172
|
-
// terminates at (0,0), which the documented `total > 0` consumer guard
|
|
173
|
-
// (MIGRATION.md 5b) handles.
|
|
174
149
|
if (knownTotal > 0)
|
|
175
150
|
onUploadProgress(knownTotal, knownTotal);
|
|
176
151
|
else
|
|
177
152
|
onUploadProgress(lastLoaded, lastLoaded);
|
|
178
|
-
// D-08: status 0 means a network-level failure (CORS rejection / blocked
|
|
179
|
-
// request) where onload fired but onerror did not. The Fetch Response
|
|
180
|
-
// constructor throws RangeError for status 0, and that throw would land
|
|
181
|
-
// OUTSIDE the Promise executor (never settling it → dispatch() hangs).
|
|
182
|
-
// Reject instead so dispatch()'s try/catch routes it to onError —
|
|
183
|
-
// byte-identical to fetch, which rejects on CORS/network failure.
|
|
184
153
|
if (xhr.status === 0) {
|
|
185
154
|
reject(new Error(`Transport request to ${input} failed (status 0)`));
|
|
186
155
|
return;
|
|
187
156
|
}
|
|
188
|
-
// D-08: resolve a real Response so dispatch()'s res.ok / await res.json()
|
|
189
|
-
// / processResponse() is byte-identical to the fetch path.
|
|
190
157
|
resolve(new Response(xhr.responseText, {
|
|
191
158
|
status: xhr.status,
|
|
192
159
|
statusText: xhr.statusText,
|
|
193
160
|
}));
|
|
194
161
|
};
|
|
195
|
-
// D-07: error / timeout / abort → reject so dispatch()'s existing
|
|
196
|
-
// try/catch routes it to onError exactly like a failed fetch.
|
|
197
162
|
xhr.onerror = () => reject(new Error(`Transport request to ${input} failed`));
|
|
198
163
|
xhr.ontimeout = () => reject(new Error(`Transport request to ${input} timed out`));
|
|
199
164
|
xhr.onabort = () => reject(new Error(`Transport request to ${input} aborted`));
|
|
@@ -226,10 +191,7 @@ export class BrowserAdapter {
|
|
|
226
191
|
}
|
|
227
192
|
page(n, parent, on) {
|
|
228
193
|
const el = document.createElement("div");
|
|
229
|
-
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}${
|
|
230
|
-
// 0.7.0 (#13) — width override: "wide" or "full" opt-in via a closed
|
|
231
|
-
// union. Omitted = no modifier class (existing 1080px cap holds).
|
|
232
|
-
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}` : ""}`;
|
|
233
195
|
if (n.title) {
|
|
234
196
|
const h = document.createElement("h1");
|
|
235
197
|
h.className = "vms-page__title";
|
|
@@ -267,45 +229,25 @@ export class BrowserAdapter {
|
|
|
267
229
|
this.kids(n.children, li, on);
|
|
268
230
|
parent.appendChild(li);
|
|
269
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). */
|
|
270
235
|
form(n, parent, on) {
|
|
271
236
|
const form = document.createElement("form");
|
|
272
237
|
form.className = `vms-form${n.layout && n.layout !== "stack" ? ` vms-form--${n.layout}` : ""}`;
|
|
273
238
|
form.noValidate = true;
|
|
274
239
|
this.kids(n.children, form, on);
|
|
275
|
-
|
|
276
|
-
// given action's context, and dispatch. Factored out of the submit
|
|
277
|
-
// handler so both the default submit AND each buttons[] entry can call it
|
|
278
|
-
// with a DIFFERENT action carrying the SAME live field values.
|
|
279
|
-
const harvest = (base) => {
|
|
280
|
-
const ctx = { ...(base.context ?? {}) };
|
|
240
|
+
const dispatchWithFiles = (action) => {
|
|
281
241
|
const files = {};
|
|
282
|
-
form.querySelectorAll("input:not([type=checkbox]):not([type=file]), textarea").forEach(el => { if (el.name)
|
|
283
|
-
ctx[el.name] = el.value; });
|
|
284
|
-
// Form-collected checkboxes (FieldNode inputType="checkbox").
|
|
285
|
-
// CheckboxNode renders with .vms-checkbox__input and is excluded so its
|
|
286
|
-
// immediate-dispatch path stays the only way it talks to the server.
|
|
287
|
-
form.querySelectorAll("input.vms-field__input[type=checkbox]").forEach(el => { if (el.name)
|
|
288
|
-
ctx[el.name] = el.checked; });
|
|
289
|
-
form.querySelectorAll("select:not([multiple])").forEach(sel => {
|
|
290
|
-
if (sel.name)
|
|
291
|
-
ctx[sel.name] = sel.value;
|
|
292
|
-
});
|
|
293
|
-
form.querySelectorAll("select[multiple]").forEach(sel => {
|
|
294
|
-
if (sel.name)
|
|
295
|
-
ctx[sel.name] = Array.from(sel.selectedOptions).map(o => o.value).join(",");
|
|
296
|
-
});
|
|
297
242
|
form.querySelectorAll("input[type=file]").forEach(inp => {
|
|
298
243
|
if (inp.name && inp.files?.[0])
|
|
299
244
|
files[inp.name] = inp.files[0];
|
|
300
245
|
});
|
|
301
|
-
const
|
|
246
|
+
const ev = { name: action.name };
|
|
302
247
|
if (Object.keys(files).length > 0)
|
|
303
|
-
|
|
304
|
-
on(
|
|
248
|
+
ev.files = files;
|
|
249
|
+
on(ev);
|
|
305
250
|
};
|
|
306
|
-
// Default submit button + Enter-to-submit, only when submitAction is set.
|
|
307
|
-
// (0.10.0: submitAction is now optional — a buttons[]-only form renders
|
|
308
|
-
// no default button, and Enter does not submit at the form level.)
|
|
309
251
|
if (n.submitAction) {
|
|
310
252
|
const submitAction = n.submitAction;
|
|
311
253
|
const submit = document.createElement("button");
|
|
@@ -315,39 +257,42 @@ export class BrowserAdapter {
|
|
|
315
257
|
form.appendChild(submit);
|
|
316
258
|
form.addEventListener("submit", (e) => {
|
|
317
259
|
e.preventDefault();
|
|
318
|
-
|
|
260
|
+
dispatchWithFiles(submitAction);
|
|
319
261
|
});
|
|
320
262
|
}
|
|
321
263
|
else {
|
|
322
|
-
// No default submit —
|
|
264
|
+
// No default submit — neutralize implicit Enter submission so a
|
|
323
265
|
// single-field buttons[]-only form doesn't reload via native submit.
|
|
324
266
|
form.addEventListener("submit", (e) => e.preventDefault());
|
|
325
267
|
}
|
|
326
|
-
// 0.10.0 (#15) — multi-action buttons. Each renders through the normal
|
|
327
|
-
// button() path (so variant + pendingLabel work) but its onAction is
|
|
328
|
-
// wrapped to harvest the form first. We render them in a footer row so
|
|
329
|
-
// they group like the default submit.
|
|
330
268
|
if (n.buttons && n.buttons.length > 0) {
|
|
331
269
|
const row = document.createElement("div");
|
|
332
270
|
row.className = "vms-form__buttons";
|
|
333
|
-
const
|
|
271
|
+
const buttonOn = (action) => dispatchWithFiles(action);
|
|
334
272
|
for (const btn of n.buttons)
|
|
335
|
-
this.button(btn, row,
|
|
273
|
+
this.button(btn, row, buttonOn);
|
|
336
274
|
form.appendChild(row);
|
|
337
275
|
}
|
|
338
276
|
parent.appendChild(form);
|
|
339
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. */
|
|
340
281
|
field(n, parent, on) {
|
|
282
|
+
const stateValue = this.sa.read(n.bind);
|
|
341
283
|
if (n.inputType === "hidden") {
|
|
284
|
+
// Hidden fields don't write back — server is authoritative for hidden.
|
|
342
285
|
const inp = document.createElement("input");
|
|
343
286
|
inp.type = "hidden";
|
|
344
287
|
inp.name = n.name;
|
|
345
|
-
|
|
346
|
-
inp.value = n.value;
|
|
288
|
+
inp.value = stateValue == null ? "" : String(stateValue);
|
|
347
289
|
parent.appendChild(inp);
|
|
348
290
|
return;
|
|
349
291
|
}
|
|
350
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.
|
|
351
296
|
const wrapper = document.createElement("div");
|
|
352
297
|
wrapper.className = "vms-field vms-field--checkbox";
|
|
353
298
|
const inp = document.createElement("input");
|
|
@@ -355,7 +300,10 @@ export class BrowserAdapter {
|
|
|
355
300
|
inp.className = "vms-field__input";
|
|
356
301
|
inp.id = `vms-${n.name}`;
|
|
357
302
|
inp.name = n.name;
|
|
358
|
-
inp.checked =
|
|
303
|
+
inp.checked = Boolean(stateValue);
|
|
304
|
+
inp.addEventListener("change", () => {
|
|
305
|
+
this.sa.write(n.bind, inp.checked);
|
|
306
|
+
});
|
|
359
307
|
wrapper.appendChild(inp);
|
|
360
308
|
if (n.label) {
|
|
361
309
|
const lbl = document.createElement("label");
|
|
@@ -382,21 +330,29 @@ export class BrowserAdapter {
|
|
|
382
330
|
sel.id = `vms-${n.name}`;
|
|
383
331
|
sel.name = n.name;
|
|
384
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) : "";
|
|
385
338
|
(n.options ?? []).forEach(opt => {
|
|
386
339
|
const o = document.createElement("option");
|
|
387
340
|
o.value = opt.value;
|
|
388
341
|
o.textContent = opt.label;
|
|
389
|
-
o.selected =
|
|
390
|
-
? (n.value ?? "").split(",").map(s => s.trim()).includes(opt.value)
|
|
391
|
-
: opt.value === n.value;
|
|
342
|
+
o.selected = isMulti ? selectedSet.has(opt.value) : opt.value === selectedSingle;
|
|
392
343
|
sel.appendChild(o);
|
|
393
344
|
});
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
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
|
+
});
|
|
400
356
|
wrapper.appendChild(sel);
|
|
401
357
|
}
|
|
402
358
|
else if (n.inputType === "file") {
|
|
@@ -405,6 +361,7 @@ export class BrowserAdapter {
|
|
|
405
361
|
inp.className = "vms-field__input";
|
|
406
362
|
inp.id = `vms-${n.name}`;
|
|
407
363
|
inp.name = n.name;
|
|
364
|
+
// File-input persistence: re-apply any registered file to the new node.
|
|
408
365
|
const existingFile = this.fileRegistry.get(n.name);
|
|
409
366
|
if (existingFile) {
|
|
410
367
|
try {
|
|
@@ -412,14 +369,21 @@ export class BrowserAdapter {
|
|
|
412
369
|
dt.items.add(existingFile);
|
|
413
370
|
inp.files = dt.files;
|
|
414
371
|
}
|
|
415
|
-
catch { }
|
|
372
|
+
catch { /* nothing */ }
|
|
416
373
|
}
|
|
417
374
|
inp.addEventListener("change", () => {
|
|
418
375
|
const file = inp.files?.[0];
|
|
419
|
-
if (file)
|
|
376
|
+
if (file) {
|
|
420
377
|
this.fileRegistry.set(n.name, file);
|
|
421
|
-
|
|
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 {
|
|
422
384
|
this.fileRegistry.delete(n.name);
|
|
385
|
+
this.sa.write(n.bind, null);
|
|
386
|
+
}
|
|
423
387
|
});
|
|
424
388
|
wrapper.appendChild(inp);
|
|
425
389
|
}
|
|
@@ -430,10 +394,10 @@ export class BrowserAdapter {
|
|
|
430
394
|
ta.name = n.name;
|
|
431
395
|
if (n.placeholder)
|
|
432
396
|
ta.placeholder = n.placeholder;
|
|
433
|
-
|
|
434
|
-
ta.value = n.value;
|
|
397
|
+
ta.value = stateValue == null ? "" : String(stateValue);
|
|
435
398
|
if (n.required)
|
|
436
399
|
ta.required = true;
|
|
400
|
+
ta.addEventListener("input", () => { this.sa.write(n.bind, ta.value); });
|
|
437
401
|
wrapper.appendChild(ta);
|
|
438
402
|
}
|
|
439
403
|
else if (n.inputType === "code") {
|
|
@@ -453,10 +417,10 @@ export class BrowserAdapter {
|
|
|
453
417
|
ta.setAttribute("autocorrect", "off");
|
|
454
418
|
if (n.placeholder)
|
|
455
419
|
ta.placeholder = n.placeholder;
|
|
456
|
-
|
|
457
|
-
ta.value = n.value;
|
|
420
|
+
ta.value = stateValue == null ? "" : String(stateValue);
|
|
458
421
|
if (n.required)
|
|
459
422
|
ta.required = true;
|
|
423
|
+
ta.addEventListener("input", () => { this.sa.write(n.bind, ta.value); });
|
|
460
424
|
ta.addEventListener("keydown", (e) => {
|
|
461
425
|
if (e.key === "Tab") {
|
|
462
426
|
e.preventDefault();
|
|
@@ -464,6 +428,7 @@ export class BrowserAdapter {
|
|
|
464
428
|
const end = ta.selectionEnd ?? 0;
|
|
465
429
|
ta.value = ta.value.slice(0, start) + "\t" + ta.value.slice(end);
|
|
466
430
|
ta.selectionStart = ta.selectionEnd = start + 1;
|
|
431
|
+
this.sa.write(n.bind, ta.value);
|
|
467
432
|
}
|
|
468
433
|
});
|
|
469
434
|
wrapper.appendChild(ta);
|
|
@@ -476,16 +441,20 @@ export class BrowserAdapter {
|
|
|
476
441
|
inp.name = n.name;
|
|
477
442
|
if (n.placeholder)
|
|
478
443
|
inp.placeholder = n.placeholder;
|
|
479
|
-
|
|
480
|
-
inp.value = n.value;
|
|
444
|
+
inp.value = stateValue == null ? "" : String(stateValue);
|
|
481
445
|
if (n.required)
|
|
482
446
|
inp.required = true;
|
|
447
|
+
inp.addEventListener("input", () => { this.sa.write(n.bind, inp.value); });
|
|
483
448
|
if (n.action) {
|
|
484
449
|
const action = n.action;
|
|
485
450
|
inp.addEventListener("keydown", (e) => {
|
|
486
451
|
if (e.key === "Enter") {
|
|
487
452
|
e.preventDefault();
|
|
488
|
-
|
|
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 });
|
|
489
458
|
}
|
|
490
459
|
});
|
|
491
460
|
}
|
|
@@ -493,6 +462,8 @@ export class BrowserAdapter {
|
|
|
493
462
|
}
|
|
494
463
|
parent.appendChild(wrapper);
|
|
495
464
|
}
|
|
465
|
+
/** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
|
|
466
|
+
* write to state then dispatch the action name (if any). */
|
|
496
467
|
checkbox(n, parent, on) {
|
|
497
468
|
const lbl = document.createElement("label");
|
|
498
469
|
lbl.className = "vms-checkbox";
|
|
@@ -500,7 +471,7 @@ export class BrowserAdapter {
|
|
|
500
471
|
inp.type = "checkbox";
|
|
501
472
|
inp.className = "vms-checkbox__input";
|
|
502
473
|
inp.name = n.name;
|
|
503
|
-
inp.checked = n.
|
|
474
|
+
inp.checked = Boolean(this.sa.read(n.bind));
|
|
504
475
|
const mark = document.createElement("span");
|
|
505
476
|
mark.className = "vms-checkbox__mark";
|
|
506
477
|
lbl.appendChild(inp);
|
|
@@ -511,12 +482,11 @@ export class BrowserAdapter {
|
|
|
511
482
|
span.textContent = n.label;
|
|
512
483
|
lbl.appendChild(span);
|
|
513
484
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
on({ name: action.name
|
|
518
|
-
|
|
519
|
-
}
|
|
485
|
+
inp.addEventListener("change", () => {
|
|
486
|
+
this.sa.write(n.bind, inp.checked);
|
|
487
|
+
if (n.action)
|
|
488
|
+
on({ name: n.action.name });
|
|
489
|
+
});
|
|
520
490
|
parent.appendChild(lbl);
|
|
521
491
|
}
|
|
522
492
|
button(n, parent, on) {
|
|
@@ -525,13 +495,10 @@ export class BrowserAdapter {
|
|
|
525
495
|
btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
|
|
526
496
|
btn.textContent = n.label;
|
|
527
497
|
btn.addEventListener("click", () => {
|
|
528
|
-
//
|
|
529
|
-
//
|
|
498
|
+
// pendingLabel: instant client-side feedback. Swap text + add
|
|
499
|
+
// .vms-button--pending BEFORE handing off to the dispatcher. On
|
|
530
500
|
// success the next render replaces the button entirely. On dispatch
|
|
531
|
-
// error, the shell
|
|
532
|
-
// the original label snaps back automatically — no per-button cleanup
|
|
533
|
-
// wiring needed in the adapter. Pure-client ephemeral state; never
|
|
534
|
-
// round-trips through the wire.
|
|
501
|
+
// error, the shell re-renders so the original label snaps back.
|
|
535
502
|
if (n.pendingLabel) {
|
|
536
503
|
btn.textContent = n.pendingLabel;
|
|
537
504
|
btn.classList.add("vms-button--pending");
|
|
@@ -575,6 +542,8 @@ export class BrowserAdapter {
|
|
|
575
542
|
});
|
|
576
543
|
parent.appendChild(bar);
|
|
577
544
|
}
|
|
545
|
+
/** TabsNode — on click, write tab.value to state at node.bind, then dispatch
|
|
546
|
+
* the tab's own action name. */
|
|
578
547
|
tabs(n, parent, on) {
|
|
579
548
|
const nav = document.createElement("nav");
|
|
580
549
|
nav.className = "vms-tabs";
|
|
@@ -586,7 +555,8 @@ export class BrowserAdapter {
|
|
|
586
555
|
btn.setAttribute("role", "tab");
|
|
587
556
|
btn.setAttribute("aria-selected", String(tab.value === n.selected));
|
|
588
557
|
btn.addEventListener("click", () => {
|
|
589
|
-
|
|
558
|
+
this.sa.write(n.bind, tab.value);
|
|
559
|
+
on({ name: tab.action.name });
|
|
590
560
|
});
|
|
591
561
|
nav.appendChild(btn);
|
|
592
562
|
});
|
|
@@ -652,6 +622,12 @@ export class BrowserAdapter {
|
|
|
652
622
|
backdrop.appendChild(modal);
|
|
653
623
|
parent.appendChild(backdrop);
|
|
654
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. */
|
|
655
631
|
table(n, parent, on) {
|
|
656
632
|
const wrapper = document.createElement("div");
|
|
657
633
|
wrapper.className = "vms-table-wrapper";
|
|
@@ -659,41 +635,13 @@ export class BrowserAdapter {
|
|
|
659
635
|
table.className = "vms-table";
|
|
660
636
|
const thead = document.createElement("thead");
|
|
661
637
|
const headerRow = document.createElement("tr");
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
const selectedSet = sel ? new Set(sel.selectedIds) : null;
|
|
666
|
-
if (sel) {
|
|
667
|
-
const th = document.createElement("th");
|
|
668
|
-
th.className = "vms-table__th vms-table__th--select";
|
|
669
|
-
const allOnPage = n.rows.length > 0 && n.rows.every(r => r.id != null && selectedSet.has(r.id));
|
|
670
|
-
const someOnPage = n.rows.some(r => r.id != null && selectedSet.has(r.id));
|
|
671
|
-
const box = document.createElement("input");
|
|
672
|
-
box.type = "checkbox";
|
|
673
|
-
box.className = "vms-table__select vms-table__select--all";
|
|
674
|
-
box.checked = allOnPage;
|
|
675
|
-
box.indeterminate = someOnPage && !allOnPage;
|
|
676
|
-
box.addEventListener("change", () => {
|
|
677
|
-
// Toggle every row checkbox + class in this table to match the header.
|
|
678
|
-
// No dispatch — selection is purely DOM-local; bulk actions harvest the
|
|
679
|
-
// checked rows when a `selection.buttons[]` entry is clicked.
|
|
680
|
-
const want = box.checked;
|
|
681
|
-
table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
|
|
682
|
-
if (rowBox.disabled)
|
|
683
|
-
return;
|
|
684
|
-
rowBox.checked = want;
|
|
685
|
-
const tr = rowBox.closest(".vms-table__row");
|
|
686
|
-
if (tr)
|
|
687
|
-
tr.classList.toggle("vms-table__row--selected", want);
|
|
688
|
-
});
|
|
689
|
-
});
|
|
690
|
-
th.appendChild(box);
|
|
691
|
-
headerRow.appendChild(th);
|
|
692
|
-
}
|
|
638
|
+
const sortIntent = (n.sortBind != null ? this.sa.read(n.sortBind) : null);
|
|
639
|
+
const sortedCol = sortIntent?.column;
|
|
640
|
+
const sortedDir = sortIntent?.direction;
|
|
693
641
|
n.columns.forEach(col => {
|
|
694
642
|
const th = document.createElement("th");
|
|
695
|
-
const isSorted = col.key ===
|
|
696
|
-
const dir = isSorted ? (
|
|
643
|
+
const isSorted = col.key === sortedCol;
|
|
644
|
+
const dir = isSorted ? (sortedDir ?? "asc") : null;
|
|
697
645
|
let classes = "vms-table__th";
|
|
698
646
|
if (col.sortable)
|
|
699
647
|
classes += " vms-table__th--sortable";
|
|
@@ -703,11 +651,17 @@ export class BrowserAdapter {
|
|
|
703
651
|
classes += " vms-table__th--desc";
|
|
704
652
|
th.className = classes;
|
|
705
653
|
th.textContent = col.label;
|
|
706
|
-
|
|
707
|
-
|
|
654
|
+
const sortAction = n.sortActions?.[col.key];
|
|
655
|
+
if (col.sortable && sortAction && n.sortBind != null) {
|
|
656
|
+
const sortBind = n.sortBind;
|
|
708
657
|
th.addEventListener("click", () => {
|
|
709
|
-
|
|
710
|
-
|
|
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 });
|
|
711
665
|
});
|
|
712
666
|
}
|
|
713
667
|
headerRow.appendChild(th);
|
|
@@ -718,8 +672,6 @@ export class BrowserAdapter {
|
|
|
718
672
|
const filterAction = n.filterAction;
|
|
719
673
|
const filterRow = document.createElement("tr");
|
|
720
674
|
filterRow.className = "vms-table__filter-row";
|
|
721
|
-
if (sel)
|
|
722
|
-
filterRow.appendChild(document.createElement("th")); // align under the select column
|
|
723
675
|
n.columns.forEach(col => {
|
|
724
676
|
const th = document.createElement("th");
|
|
725
677
|
if (col.filterable) {
|
|
@@ -727,15 +679,20 @@ export class BrowserAdapter {
|
|
|
727
679
|
inp.type = "text";
|
|
728
680
|
inp.className = "vms-table__filter-input";
|
|
729
681
|
inp.dataset.col = col.key;
|
|
730
|
-
|
|
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 ?? "");
|
|
731
687
|
inp.placeholder = `Filter…`;
|
|
688
|
+
if (bindPath != null) {
|
|
689
|
+
inp.addEventListener("input", () => { this.sa.write(bindPath, inp.value); });
|
|
690
|
+
}
|
|
732
691
|
inp.addEventListener("keydown", (e) => {
|
|
733
692
|
if (e.key === "Enter") {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
});
|
|
738
|
-
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 });
|
|
739
696
|
}
|
|
740
697
|
});
|
|
741
698
|
th.appendChild(inp);
|
|
@@ -751,53 +708,9 @@ export class BrowserAdapter {
|
|
|
751
708
|
let rowClass = "vms-table__row";
|
|
752
709
|
if (row.variant)
|
|
753
710
|
rowClass += ` vms-table__row--${row.variant}`;
|
|
754
|
-
if (row.action)
|
|
755
|
-
rowClass += " vms-table__row--clickable";
|
|
756
|
-
const isSelected = sel != null && row.id != null && selectedSet.has(row.id);
|
|
757
|
-
if (isSelected)
|
|
758
|
-
rowClass += " vms-table__row--selected";
|
|
759
711
|
tr.className = rowClass;
|
|
760
712
|
if (row.id)
|
|
761
713
|
tr.dataset.id = row.id;
|
|
762
|
-
if (row.action) {
|
|
763
|
-
const rowAction = row.action;
|
|
764
|
-
tr.addEventListener("click", () => on(rowAction));
|
|
765
|
-
}
|
|
766
|
-
if (sel) {
|
|
767
|
-
const td = document.createElement("td");
|
|
768
|
-
td.className = "vms-table__td vms-table__td--select";
|
|
769
|
-
// A click in the checkbox cell must not also fire the row's click action.
|
|
770
|
-
td.addEventListener("click", (e) => e.stopPropagation());
|
|
771
|
-
const box = document.createElement("input");
|
|
772
|
-
box.type = "checkbox";
|
|
773
|
-
box.className = "vms-table__select";
|
|
774
|
-
box.checked = isSelected;
|
|
775
|
-
if (row.id != null) {
|
|
776
|
-
const rowId = row.id;
|
|
777
|
-
// data-id is what selection.buttons[] harvest reads on click.
|
|
778
|
-
box.dataset.id = rowId;
|
|
779
|
-
box.addEventListener("change", () => {
|
|
780
|
-
// Flip the row class to mirror the box, then reconcile the header
|
|
781
|
-
// select-all (could now be all / some / none). No dispatch — bulk
|
|
782
|
-
// actions read the DOM via the selection.buttons[] harvest.
|
|
783
|
-
tr.classList.toggle("vms-table__row--selected", box.checked);
|
|
784
|
-
const headerBox = table.querySelector("thead input.vms-table__select--all");
|
|
785
|
-
if (headerBox) {
|
|
786
|
-
const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
|
|
787
|
-
let checked = 0;
|
|
788
|
-
all.forEach(b => { if (b.checked)
|
|
789
|
-
checked++; });
|
|
790
|
-
headerBox.checked = all.length > 0 && checked === all.length;
|
|
791
|
-
headerBox.indeterminate = checked > 0 && checked < all.length;
|
|
792
|
-
}
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
else {
|
|
796
|
-
box.disabled = true; // selection addresses rows by id; a row without one can't be selected
|
|
797
|
-
}
|
|
798
|
-
td.appendChild(box);
|
|
799
|
-
tr.appendChild(td);
|
|
800
|
-
}
|
|
801
714
|
n.columns.forEach(col => {
|
|
802
715
|
const td = document.createElement("td");
|
|
803
716
|
td.className = "vms-table__td";
|
|
@@ -818,29 +731,18 @@ export class BrowserAdapter {
|
|
|
818
731
|
}
|
|
819
732
|
tr.appendChild(td);
|
|
820
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
|
+
}
|
|
821
742
|
tbody.appendChild(tr);
|
|
822
743
|
});
|
|
823
744
|
table.appendChild(tbody);
|
|
824
745
|
wrapper.appendChild(table);
|
|
825
|
-
// 0.13.0 — bulk-action toolbar rendered ABOVE the table. Each button's
|
|
826
|
-
// click harvests the table's currently-checked row ids and merges them as
|
|
827
|
-
// `selectedIds` into the action's context. Works with both server-truth
|
|
828
|
-
// mode (the DOM mirrors selectedIds so the harvest matches state) and
|
|
829
|
-
// local mode (the DOM is the only source of selection truth).
|
|
830
|
-
if (sel?.buttons && sel.buttons.length > 0) {
|
|
831
|
-
const toolbar = document.createElement("div");
|
|
832
|
-
toolbar.className = "vms-table__bulk-actions";
|
|
833
|
-
const harvest = (action) => {
|
|
834
|
-
const ids = [];
|
|
835
|
-
table.querySelectorAll("tbody input.vms-table__select:checked").forEach(b => {
|
|
836
|
-
if (b.dataset.id)
|
|
837
|
-
ids.push(b.dataset.id);
|
|
838
|
-
});
|
|
839
|
-
on({ name: action.name, context: { ...(action.context ?? {}), selectedIds: ids } });
|
|
840
|
-
};
|
|
841
|
-
sel.buttons.forEach(btn => this.button(btn, toolbar, harvest));
|
|
842
|
-
wrapper.insertBefore(toolbar, table);
|
|
843
|
-
}
|
|
844
746
|
if (n.pagination) {
|
|
845
747
|
const pg = n.pagination;
|
|
846
748
|
const footer = document.createElement("div");
|
|
@@ -852,19 +754,26 @@ export class BrowserAdapter {
|
|
|
852
754
|
range.className = "vms-table__pagination-range";
|
|
853
755
|
range.textContent = `${from}–${to} of ${pg.totalRows}`;
|
|
854
756
|
footer.appendChild(range);
|
|
855
|
-
const
|
|
856
|
-
const mkBtn = (label, targetPage, disabled) => {
|
|
757
|
+
const paginationBind = n.paginationBind;
|
|
758
|
+
const mkBtn = (label, targetPage, action, disabled) => {
|
|
857
759
|
const b = document.createElement("button");
|
|
858
760
|
b.type = "button";
|
|
859
761
|
b.className = "vms-button vms-button--secondary vms-table__pagination-btn";
|
|
860
762
|
b.textContent = label;
|
|
861
763
|
b.disabled = disabled;
|
|
862
|
-
if (!disabled)
|
|
863
|
-
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
|
+
}
|
|
864
771
|
return b;
|
|
865
772
|
};
|
|
866
|
-
|
|
867
|
-
|
|
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));
|
|
868
777
|
wrapper.appendChild(footer);
|
|
869
778
|
}
|
|
870
779
|
parent.appendChild(wrapper);
|
|
@@ -872,9 +781,6 @@ export class BrowserAdapter {
|
|
|
872
781
|
copyButton(n, parent) {
|
|
873
782
|
const btn = document.createElement("button");
|
|
874
783
|
btn.type = "button";
|
|
875
|
-
// 0.9.0 (#14): variant modifier class, mirroring button() exactly. The
|
|
876
|
-
// existing .vms-button--{primary,secondary,danger} CSS rules apply
|
|
877
|
-
// automatically — no new style surface.
|
|
878
784
|
btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
|
|
879
785
|
btn.textContent = n.label ?? "Copy";
|
|
880
786
|
btn.addEventListener("click", () => {
|
|
@@ -884,21 +790,17 @@ export class BrowserAdapter {
|
|
|
884
790
|
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
885
791
|
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
886
792
|
}).catch(() => {
|
|
887
|
-
// primary failed — try legacy execCommand fallback
|
|
888
793
|
if (legacyCopy(n.text)) {
|
|
889
794
|
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
890
795
|
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
891
796
|
}
|
|
892
|
-
// both paths failed: silent, no confirmation
|
|
893
797
|
});
|
|
894
798
|
}
|
|
895
799
|
else {
|
|
896
|
-
// navigator.clipboard absent — try legacy
|
|
897
800
|
if (legacyCopy(n.text)) {
|
|
898
801
|
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
899
802
|
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
900
803
|
}
|
|
901
|
-
// else: silent
|
|
902
804
|
}
|
|
903
805
|
});
|
|
904
806
|
parent.appendChild(btn);
|