@ashley-shrok/viewmodel-shell 0.16.0 → 1.1.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/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 (to top, or wherever HTMLElement.focus()
31
- // scrolled the restored-focus element into view). Same preservation
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; // last computable total (0 = never computable)
162
- let lastLoaded = 0; // last reported bytes sent
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); // D-05 in-flight, computable
142
+ onUploadProgress(e.loaded, e.total);
168
143
  }
169
144
  else {
170
- onUploadProgress(e.loaded, 0); // D-05 indeterminate sentinel (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
- // 0.10.0 (#15) harvest this form's current field values, merge into the
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 action = { name: base.name, context: ctx };
246
+ const ev = { name: action.name };
309
247
  if (Object.keys(files).length > 0)
310
- action.files = files;
311
- on(action);
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
- harvest(submitAction);
260
+ dispatchWithFiles(submitAction);
326
261
  });
327
262
  }
328
263
  else {
329
- // No default submit — still neutralize implicit Enter submission so a
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 harvestOn = (action) => harvest(action);
271
+ const buttonOn = (action) => dispatchWithFiles(action);
341
272
  for (const btn of n.buttons)
342
- this.button(btn, row, harvestOn);
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
- if (n.value)
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 = !!n.value && n.value !== "false" && n.value !== "0";
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 = n.inputType === "select-multiple"
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
- if (n.action) {
402
- const action = n.action;
403
- sel.addEventListener("change", () => {
404
- on({ name: action.name, context: { ...(action.context ?? {}), [n.name]: sel.value } });
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
- else
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
- if (n.value)
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
- if (n.value)
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
- if (n.value)
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
- on({ name: action.name, context: { ...(action.context ?? {}), [n.name]: inp.value } });
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.checked;
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
- if (n.action) {
522
- const action = n.action;
523
- inp.addEventListener("change", () => {
524
- on({ name: action.name, context: { ...(action.context ?? {}), checked: inp.checked } });
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
- // 0.8.0 (#11) — pendingLabel: instant client-side feedback. Swap text +
536
- // add .vms-button--pending BEFORE handing off to the dispatcher. On
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's dispatch() catch re-renders this.currentVm so
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
- on({ name: n.action.name, context: { ...(n.action.context ?? {}), value: tab.value } });
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,18 @@ 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 controls (row.actions[]) are a mix of
630
+ * ButtonNode and CheckboxNode dispatched by entry.type. When row.action
631
+ * is set, the entire <tr> becomes clickable + keyboard-activatable
632
+ * (Enter / Space — Space preventDefaults page scroll) and exposes
633
+ * role="button", tabindex=0, and an aria-label derived from cell text;
634
+ * clicks on per-row controls or cell linkLabel anchors stopPropagation
635
+ * so they don't also fire row.action. Selection is no longer a framework
636
+ * concept. */
662
637
  table(n, parent, on) {
663
638
  const wrapper = document.createElement("div");
664
639
  wrapper.className = "vms-table-wrapper";
@@ -666,41 +641,13 @@ export class BrowserAdapter {
666
641
  table.className = "vms-table";
667
642
  const thead = document.createElement("thead");
668
643
  const headerRow = document.createElement("tr");
669
- // Selection: a leading checkbox column. The header box is a select-all over
670
- // the rows CURRENTLY rendered (the current page) — never unloaded rows.
671
- const sel = n.selection;
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
- }
644
+ const sortIntent = (n.sortBind != null ? this.sa.read(n.sortBind) : null);
645
+ const sortedCol = sortIntent?.column;
646
+ const sortedDir = sortIntent?.direction;
700
647
  n.columns.forEach(col => {
701
648
  const th = document.createElement("th");
702
- const isSorted = col.key === n.sortColumn;
703
- const dir = isSorted ? (n.sortDirection ?? "asc") : null;
649
+ const isSorted = col.key === sortedCol;
650
+ const dir = isSorted ? (sortedDir ?? "asc") : null;
704
651
  let classes = "vms-table__th";
705
652
  if (col.sortable)
706
653
  classes += " vms-table__th--sortable";
@@ -710,11 +657,17 @@ export class BrowserAdapter {
710
657
  classes += " vms-table__th--desc";
711
658
  th.className = classes;
712
659
  th.textContent = col.label;
713
- if (col.sortable && n.sortAction) {
714
- const sortAction = n.sortAction;
660
+ const sortAction = n.sortActions?.[col.key];
661
+ if (col.sortable && sortAction && n.sortBind != null) {
662
+ const sortBind = n.sortBind;
715
663
  th.addEventListener("click", () => {
716
- const nextDir = isSorted && n.sortDirection === "asc" ? "desc" : "asc";
717
- on({ name: sortAction.name, context: { ...(sortAction.context ?? {}), column: col.key, direction: nextDir } });
664
+ // Read current sort intent at click time (not render time): if no
665
+ // re-render has happened between clicks, the closure-captured
666
+ // sortedDir would be stale.
667
+ const cur = this.sa.read(sortBind);
668
+ const nextDir = cur?.column === col.key && cur?.direction === "asc" ? "desc" : "asc";
669
+ this.sa.write(sortBind, { column: col.key, direction: nextDir });
670
+ on({ name: sortAction.name });
718
671
  });
719
672
  }
720
673
  headerRow.appendChild(th);
@@ -725,8 +678,6 @@ export class BrowserAdapter {
725
678
  const filterAction = n.filterAction;
726
679
  const filterRow = document.createElement("tr");
727
680
  filterRow.className = "vms-table__filter-row";
728
- if (sel)
729
- filterRow.appendChild(document.createElement("th")); // align under the select column
730
681
  n.columns.forEach(col => {
731
682
  const th = document.createElement("th");
732
683
  if (col.filterable) {
@@ -734,15 +685,20 @@ export class BrowserAdapter {
734
685
  inp.type = "text";
735
686
  inp.className = "vms-table__filter-input";
736
687
  inp.dataset.col = col.key;
737
- inp.value = col.filterValue ?? "";
688
+ const bindPath = n.filterBinds?.[col.key];
689
+ const bound = bindPath != null ? this.sa.read(bindPath) : undefined;
690
+ inp.value = bound != null
691
+ ? String(bound)
692
+ : (col.filterValue ?? "");
738
693
  inp.placeholder = `Filter…`;
694
+ if (bindPath != null) {
695
+ inp.addEventListener("input", () => { this.sa.write(bindPath, inp.value); });
696
+ }
739
697
  inp.addEventListener("keydown", (e) => {
740
698
  if (e.key === "Enter") {
741
- const filters = {};
742
- filterRow.querySelectorAll("[data-col]").forEach(el => {
743
- filters[el.dataset.col] = el.value;
744
- });
745
- on({ name: filterAction.name, context: { ...(filterAction.context ?? {}), column: col.key, value: inp.value, filters } });
699
+ if (bindPath != null)
700
+ this.sa.write(bindPath, inp.value);
701
+ on({ name: filterAction.name });
746
702
  }
747
703
  });
748
704
  th.appendChild(inp);
@@ -760,50 +716,33 @@ export class BrowserAdapter {
760
716
  rowClass += ` vms-table__row--${row.variant}`;
761
717
  if (row.action)
762
718
  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
719
  tr.className = rowClass;
767
720
  if (row.id)
768
721
  tr.dataset.id = row.id;
722
+ // row.action — click-anywhere + keyboard + ARIA. Per-row controls and
723
+ // cell linkLabel anchors stopPropagation below so they don't double-fire.
769
724
  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);
725
+ const rowActionName = row.action.name;
726
+ tr.tabIndex = 0;
727
+ tr.setAttribute("role", "button");
728
+ const labelParts = Object.values(row.cells)
729
+ .filter(v => v && v.trim())
730
+ .map(v => v.trim());
731
+ const ariaLabel = labelParts.length > 0
732
+ ? labelParts.join(" · ")
733
+ : (row.id ? `Row ${row.id}` : "");
734
+ if (ariaLabel)
735
+ tr.setAttribute("aria-label", ariaLabel);
736
+ tr.addEventListener("click", () => { on({ name: rowActionName }); });
737
+ tr.addEventListener("keydown", (e) => {
738
+ if (e.key === "Enter") {
739
+ on({ name: rowActionName });
740
+ }
741
+ else if (e.key === " " || e.key === "Spacebar") {
742
+ e.preventDefault(); // suppress page scroll
743
+ on({ name: rowActionName });
744
+ }
745
+ });
807
746
  }
808
747
  n.columns.forEach(col => {
809
748
  const td = document.createElement("td");
@@ -818,6 +757,9 @@ export class BrowserAdapter {
818
757
  a.target = "_blank";
819
758
  a.rel = "noopener noreferrer";
820
759
  }
760
+ if (row.action) {
761
+ a.addEventListener("click", (e) => { e.stopPropagation(); });
762
+ }
821
763
  td.appendChild(a);
822
764
  }
823
765
  else {
@@ -825,29 +767,30 @@ export class BrowserAdapter {
825
767
  }
826
768
  tr.appendChild(td);
827
769
  });
770
+ // Per-row interactive controls — dispatch by entry.type so a CheckboxNode
771
+ // renders as a real <input type="checkbox"> rather than silently as an
772
+ // empty button. When row.action is set, swallow clicks on the actions td
773
+ // so toggling a per-row control doesn't ALSO fire the row action.
774
+ if (row.actions && row.actions.length > 0) {
775
+ const td = document.createElement("td");
776
+ td.className = "vms-table__td vms-table__td--actions";
777
+ for (const entry of row.actions) {
778
+ if (entry.type === "button")
779
+ this.button(entry, td, on);
780
+ else if (entry.type === "checkbox")
781
+ this.checkbox(entry, td, on);
782
+ // forward-compatible: unknown entry types no-op (the closed TS union
783
+ // narrows; the runtime guard is for late-arriving wire shapes).
784
+ }
785
+ if (row.action) {
786
+ td.addEventListener("click", (e) => { e.stopPropagation(); });
787
+ }
788
+ tr.appendChild(td);
789
+ }
828
790
  tbody.appendChild(tr);
829
791
  });
830
792
  table.appendChild(tbody);
831
793
  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
794
  if (n.pagination) {
852
795
  const pg = n.pagination;
853
796
  const footer = document.createElement("div");
@@ -859,19 +802,26 @@ export class BrowserAdapter {
859
802
  range.className = "vms-table__pagination-range";
860
803
  range.textContent = `${from}–${to} of ${pg.totalRows}`;
861
804
  footer.appendChild(range);
862
- const pgAction = pg.action;
863
- const mkBtn = (label, targetPage, disabled) => {
805
+ const paginationBind = n.paginationBind;
806
+ const mkBtn = (label, targetPage, action, disabled) => {
864
807
  const b = document.createElement("button");
865
808
  b.type = "button";
866
809
  b.className = "vms-button vms-button--secondary vms-table__pagination-btn";
867
810
  b.textContent = label;
868
811
  b.disabled = disabled;
869
- if (!disabled)
870
- b.addEventListener("click", () => on({ name: pgAction.name, context: { ...(pgAction.context ?? {}), page: targetPage } }));
812
+ if (!disabled && action) {
813
+ b.addEventListener("click", () => {
814
+ if (paginationBind != null)
815
+ this.sa.write(paginationBind, targetPage);
816
+ on({ name: action.name });
817
+ });
818
+ }
871
819
  return b;
872
820
  };
873
- footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.page <= 1));
874
- footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.page >= totalPages));
821
+ const prevDisabled = pg.page <= 1 || pg.prevAction == null;
822
+ const nextDisabled = pg.page >= totalPages || pg.nextAction == null;
823
+ footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.prevAction, prevDisabled));
824
+ footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.nextAction, nextDisabled));
875
825
  wrapper.appendChild(footer);
876
826
  }
877
827
  parent.appendChild(wrapper);
@@ -879,9 +829,6 @@ export class BrowserAdapter {
879
829
  copyButton(n, parent) {
880
830
  const btn = document.createElement("button");
881
831
  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
832
  btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
886
833
  btn.textContent = n.label ?? "Copy";
887
834
  btn.addEventListener("click", () => {
@@ -891,21 +838,17 @@ export class BrowserAdapter {
891
838
  btn.textContent = n.copiedLabel ?? "Copied!";
892
839
  setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
893
840
  }).catch(() => {
894
- // primary failed — try legacy execCommand fallback
895
841
  if (legacyCopy(n.text)) {
896
842
  btn.textContent = n.copiedLabel ?? "Copied!";
897
843
  setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
898
844
  }
899
- // both paths failed: silent, no confirmation
900
845
  });
901
846
  }
902
847
  else {
903
- // navigator.clipboard absent — try legacy
904
848
  if (legacyCopy(n.text)) {
905
849
  btn.textContent = n.copiedLabel ?? "Copied!";
906
850
  setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
907
851
  }
908
- // else: silent
909
852
  }
910
853
  });
911
854
  parent.appendChild(btn);