@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.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,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
- /** 0.14.0 — install / clear the browser's `beforeunload` guard. The shell
109
- * calls this on every response with `response.preventUnload ?? false`, so
110
- * the lock-and-clear cycle is just "server sets preventUnload:true while
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; // last computable total (0 = never computable)
155
- let lastLoaded = 0; // last reported bytes sent
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); // D-05 in-flight, computable
142
+ onUploadProgress(e.loaded, e.total);
161
143
  }
162
144
  else {
163
- onUploadProgress(e.loaded, 0); // D-05 indeterminate sentinel (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
- // 0.10.0 (#15) harvest this form's current field values, merge into the
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 action = { name: base.name, context: ctx };
246
+ const ev = { name: action.name };
302
247
  if (Object.keys(files).length > 0)
303
- action.files = files;
304
- on(action);
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
- harvest(submitAction);
260
+ dispatchWithFiles(submitAction);
319
261
  });
320
262
  }
321
263
  else {
322
- // No default submit — still neutralize implicit Enter submission so a
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 harvestOn = (action) => harvest(action);
271
+ const buttonOn = (action) => dispatchWithFiles(action);
334
272
  for (const btn of n.buttons)
335
- this.button(btn, row, harvestOn);
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
- if (n.value)
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 = !!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
+ });
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 = n.inputType === "select-multiple"
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
- if (n.action) {
395
- const action = n.action;
396
- sel.addEventListener("change", () => {
397
- on({ name: action.name, context: { ...(action.context ?? {}), [n.name]: sel.value } });
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
- 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 {
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
- if (n.value)
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
- if (n.value)
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
- if (n.value)
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
- 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 });
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.checked;
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
- if (n.action) {
515
- const action = n.action;
516
- inp.addEventListener("change", () => {
517
- on({ name: action.name, context: { ...(action.context ?? {}), checked: inp.checked } });
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
- // 0.8.0 (#11) — pendingLabel: instant client-side feedback. Swap text +
529
- // 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
530
500
  // success the next render replaces the button entirely. On dispatch
531
- // error, the shell's dispatch() catch re-renders this.currentVm so
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
- 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 });
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
- // Selection: a leading checkbox column. The header box is a select-all over
663
- // the rows CURRENTLY rendered (the current page) — never unloaded rows.
664
- const sel = n.selection;
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 === n.sortColumn;
696
- const dir = isSorted ? (n.sortDirection ?? "asc") : null;
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
- if (col.sortable && n.sortAction) {
707
- const sortAction = n.sortAction;
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
- const nextDir = isSorted && n.sortDirection === "asc" ? "desc" : "asc";
710
- on({ name: sortAction.name, context: { ...(sortAction.context ?? {}), column: col.key, direction: nextDir } });
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
- inp.value = col.filterValue ?? "";
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
- const filters = {};
735
- filterRow.querySelectorAll("[data-col]").forEach(el => {
736
- filters[el.dataset.col] = el.value;
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 pgAction = pg.action;
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", () => on({ name: pgAction.name, context: { ...(pgAction.context ?? {}), page: targetPage } }));
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
- footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.page <= 1));
867
- footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.page >= totalPages));
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);