@ashley-shrok/viewmodel-shell 1.0.1 → 1.2.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.d.ts CHANGED
@@ -3,6 +3,8 @@ export declare class BrowserAdapter implements Adapter {
3
3
  private container;
4
4
  private fileRegistry;
5
5
  private sa;
6
+ private detailsOpenSnapshot;
7
+ private sectionKeyCounter;
6
8
  constructor(container: HTMLElement);
7
9
  render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
8
10
  navigate(url: string): void;
@@ -49,8 +51,14 @@ export declare class BrowserAdapter implements Adapter {
49
51
  * sortActions[col.key]; filter inputs are bound to filterBinds[col.key],
50
52
  * every keystroke writes, Enter dispatches filterAction; pagination
51
53
  * prev/next write the target page to paginationBind then dispatch
52
- * prevAction/nextAction. Per-row buttons are plain ButtonNodes. Selection
53
- * is no longer a framework concept. */
54
+ * prevAction/nextAction. Per-row controls (row.actions[]) are a mix of
55
+ * ButtonNode and CheckboxNode dispatched by entry.type. When row.action
56
+ * is set, the entire <tr> becomes clickable + keyboard-activatable
57
+ * (Enter / Space — Space preventDefaults page scroll) and exposes
58
+ * role="button", tabindex=0, and an aria-label derived from cell text;
59
+ * clicks on per-row controls or cell linkLabel anchors stopPropagation
60
+ * so they don't also fire row.action. Selection is no longer a framework
61
+ * concept. */
54
62
  private table;
55
63
  private copyButton;
56
64
  }
package/dist/browser.js CHANGED
@@ -40,6 +40,17 @@ export class BrowserAdapter {
40
40
  container;
41
41
  fileRegistry = new Map();
42
42
  sa = noopStateAccess;
43
+ // 1.2.0 — open-state snapshot for SectionNode.collapsible. Captured by
44
+ // render() BEFORE this.container.innerHTML = "" by walking
45
+ // [data-section-key] details elements; consumed by render() AFTER node()
46
+ // rebuilds the tree to restore user-opened sections. Cleared at the bottom
47
+ // of every render(). Same conceptual seam as focusId / scrollMap above.
48
+ detailsOpenSnapshot = new Map();
49
+ // 1.2.0 — per-render disambiguator for collapsible-section preservation
50
+ // keys. Reset at the top of every render(); incremented in section() when
51
+ // collapsible:true so that multiple sections sharing the same base key
52
+ // (anonymous, or duplicate heading) get distinct final keys.
53
+ sectionKeyCounter = new Map();
43
54
  constructor(container) {
44
55
  this.container = container;
45
56
  }
@@ -60,6 +71,19 @@ export class BrowserAdapter {
60
71
  if (el.scrollTop !== 0 || el.scrollLeft !== 0)
61
72
  scrollMap.set(el.id, { top: el.scrollTop, left: el.scrollLeft });
62
73
  });
74
+ // 1.2.0 — snapshot collapsible-section open state by stable key. Same
75
+ // pattern as focusId/scrollMap above: capture before innerHTML wipe, walk
76
+ // the rebuilt tree after node() returns, restore matching keys. Reset
77
+ // the per-render section-key counter to 0 so snapshot keys and restore
78
+ // keys compute identically across the two walks.
79
+ const openMap = new Map();
80
+ this.container.querySelectorAll("[data-section-key]").forEach(el => {
81
+ const key = el.dataset.sectionKey;
82
+ if (key != null)
83
+ openMap.set(key, el.open);
84
+ });
85
+ this.detailsOpenSnapshot = openMap;
86
+ this.sectionKeyCounter = new Map();
63
87
  this.container.innerHTML = "";
64
88
  this.node(vm, this.container, onAction);
65
89
  if (focusId) {
@@ -82,6 +106,20 @@ export class BrowserAdapter {
82
106
  }
83
107
  });
84
108
  window.scrollTo(winScrollX, winScrollY);
109
+ // 1.2.0 — restore collapsible-section open state after node() rebuild +
110
+ // after focus/scroll restore. Keys absent from the new tree are
111
+ // naturally dropped (querySelectorAll just doesn't find them); new
112
+ // sections that didn't exist pre-render are naturally fresh-closed (no
113
+ // map entry). Only true entries need restore action — false entries
114
+ // match the native default and are no-ops.
115
+ this.container.querySelectorAll("[data-section-key]").forEach(el => {
116
+ const key = el.dataset.sectionKey;
117
+ if (key != null && this.detailsOpenSnapshot.get(key) === true) {
118
+ el.open = true;
119
+ }
120
+ });
121
+ this.detailsOpenSnapshot.clear();
122
+ this.sectionKeyCounter.clear();
85
123
  }
86
124
  navigate(url) {
87
125
  window.location.href = url;
@@ -202,6 +240,31 @@ export class BrowserAdapter {
202
240
  parent.appendChild(el);
203
241
  }
204
242
  section(n, parent, on) {
243
+ // 1.2.0 — collapsible:true branch emits native <details>/<summary>; the
244
+ // open/closed state is DOM-local and preserved across re-renders by the
245
+ // render() snapshot/restore loop. Omitted/false renders byte-identical
246
+ // to the pre-1.2.0 <section> tree (no className drift, no data-* attr).
247
+ if (n.collapsible === true) {
248
+ const baseKey = n.id ?? n.heading ?? "vms-section-anon";
249
+ const ordinal = this.sectionKeyCounter.get(baseKey) ?? 0;
250
+ this.sectionKeyCounter.set(baseKey, ordinal + 1);
251
+ const finalKey = `${baseKey}:${ordinal}`;
252
+ const el = document.createElement("details");
253
+ el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
254
+ el.dataset.sectionKey = finalKey;
255
+ // Initial render is always closed — the post-render restore loop in
256
+ // render() re-applies `open=true` for keys the user had open before.
257
+ const summary = document.createElement("summary");
258
+ summary.className = "vms-section__summary";
259
+ // Headingless fallback label — documented in TSDoc on
260
+ // SectionNode.collapsible and in AGENTS.md "Non-obvious framework
261
+ // behaviors". Choice locked.
262
+ summary.textContent = n.heading ?? "Show details";
263
+ el.appendChild(summary);
264
+ this.kids(n.children, el, on);
265
+ parent.appendChild(el);
266
+ return;
267
+ }
205
268
  const el = document.createElement("section");
206
269
  el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
207
270
  if (n.heading) {
@@ -626,8 +689,14 @@ export class BrowserAdapter {
626
689
  * sortActions[col.key]; filter inputs are bound to filterBinds[col.key],
627
690
  * every keystroke writes, Enter dispatches filterAction; pagination
628
691
  * 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. */
692
+ * prevAction/nextAction. Per-row controls (row.actions[]) are a mix of
693
+ * ButtonNode and CheckboxNode dispatched by entry.type. When row.action
694
+ * is set, the entire <tr> becomes clickable + keyboard-activatable
695
+ * (Enter / Space — Space preventDefaults page scroll) and exposes
696
+ * role="button", tabindex=0, and an aria-label derived from cell text;
697
+ * clicks on per-row controls or cell linkLabel anchors stopPropagation
698
+ * so they don't also fire row.action. Selection is no longer a framework
699
+ * concept. */
631
700
  table(n, parent, on) {
632
701
  const wrapper = document.createElement("div");
633
702
  wrapper.className = "vms-table-wrapper";
@@ -708,9 +777,36 @@ export class BrowserAdapter {
708
777
  let rowClass = "vms-table__row";
709
778
  if (row.variant)
710
779
  rowClass += ` vms-table__row--${row.variant}`;
780
+ if (row.action)
781
+ rowClass += " vms-table__row--clickable";
711
782
  tr.className = rowClass;
712
783
  if (row.id)
713
784
  tr.dataset.id = row.id;
785
+ // row.action — click-anywhere + keyboard + ARIA. Per-row controls and
786
+ // cell linkLabel anchors stopPropagation below so they don't double-fire.
787
+ if (row.action) {
788
+ const rowActionName = row.action.name;
789
+ tr.tabIndex = 0;
790
+ tr.setAttribute("role", "button");
791
+ const labelParts = Object.values(row.cells)
792
+ .filter(v => v && v.trim())
793
+ .map(v => v.trim());
794
+ const ariaLabel = labelParts.length > 0
795
+ ? labelParts.join(" · ")
796
+ : (row.id ? `Row ${row.id}` : "");
797
+ if (ariaLabel)
798
+ tr.setAttribute("aria-label", ariaLabel);
799
+ tr.addEventListener("click", () => { on({ name: rowActionName }); });
800
+ tr.addEventListener("keydown", (e) => {
801
+ if (e.key === "Enter") {
802
+ on({ name: rowActionName });
803
+ }
804
+ else if (e.key === " " || e.key === "Spacebar") {
805
+ e.preventDefault(); // suppress page scroll
806
+ on({ name: rowActionName });
807
+ }
808
+ });
809
+ }
714
810
  n.columns.forEach(col => {
715
811
  const td = document.createElement("td");
716
812
  td.className = "vms-table__td";
@@ -724,6 +820,9 @@ export class BrowserAdapter {
724
820
  a.target = "_blank";
725
821
  a.rel = "noopener noreferrer";
726
822
  }
823
+ if (row.action) {
824
+ a.addEventListener("click", (e) => { e.stopPropagation(); });
825
+ }
727
826
  td.appendChild(a);
728
827
  }
729
828
  else {
@@ -731,12 +830,24 @@ export class BrowserAdapter {
731
830
  }
732
831
  tr.appendChild(td);
733
832
  });
734
- // Per-row buttons render as plain ButtonNodes in a trailing actions cell.
833
+ // Per-row interactive controls dispatch by entry.type so a CheckboxNode
834
+ // renders as a real <input type="checkbox"> rather than silently as an
835
+ // empty button. When row.action is set, swallow clicks on the actions td
836
+ // so toggling a per-row control doesn't ALSO fire the row action.
735
837
  if (row.actions && row.actions.length > 0) {
736
838
  const td = document.createElement("td");
737
839
  td.className = "vms-table__td vms-table__td--actions";
738
- for (const btn of row.actions)
739
- this.button(btn, td, on);
840
+ for (const entry of row.actions) {
841
+ if (entry.type === "button")
842
+ this.button(entry, td, on);
843
+ else if (entry.type === "checkbox")
844
+ this.checkbox(entry, td, on);
845
+ // forward-compatible: unknown entry types no-op (the closed TS union
846
+ // narrows; the runtime guard is for late-arriving wire shapes).
847
+ }
848
+ if (row.action) {
849
+ td.addEventListener("click", (e) => { e.stopPropagation(); });
850
+ }
740
851
  tr.appendChild(td);
741
852
  }
742
853
  tbody.appendChild(tr);
package/dist/index.d.ts CHANGED
@@ -82,6 +82,10 @@ export interface SectionNode {
82
82
  variant?: "card";
83
83
  /** Layout preset arranging direct children. Omitted or "stack" = current vertical flow (no modifier class). "split" (equal 2-up), "cards" (uniform grid), "sidebar" (thin + wide app shell) emit .vms-section--{value}. Closed union (D-01/D-02; sidebar D-28). */
84
84
  layout?: "stack" | "split" | "cards" | "sidebar";
85
+ /** Optional stable preservation key for the renderer's collapsible-section open-state snapshot. Used only when `collapsible: true`. Provide when `heading` isn't unique within a page or is absent — otherwise the renderer falls back to `heading ?? "vms-section-anon"`, disambiguated by per-render ordinal. Omitted = use the heading fallback. */
86
+ id?: string;
87
+ /** When true, the section renders as a native `<details>`/`<summary>` disclosure widget (closed by default). Aesthetic, client-side primitive — the open/closed state is DOM-local and the server does NOT round-trip it (same conceptual model as draft text values in unsubmitted form inputs). The browser adapter snapshots `<details>.open` before each re-render and restores it after, keyed by `id ?? heading ?? "vms-section-anon"` (disambiguated by per-render ordinal); a re-key drops the preserved state (the documented escape hatch for rare server-driven expansion). The summary label is the section's `heading`; a headingless collapsible section uses the fallback string `"Show details"`. If a section needs to start open, do not mark it collapsible. Omitted/false = today's `<section>` rendering, byte-identical. */
88
+ collapsible?: boolean;
85
89
  children: ViewNode[];
86
90
  }
87
91
  export interface ListNode {
@@ -237,10 +241,22 @@ export interface TableColumn {
237
241
  export interface TableRow {
238
242
  id?: string;
239
243
  cells: Record<string, string>;
240
- /** Per-row action buttons. Each is a full ButtonNode with its own unique
241
- * action name (e.g. `delete-row-42`, `close-ticket-42`)per-row identity
242
- * is encoded in the name, not as a separate context payload. */
243
- actions?: ButtonNode[];
244
+ /** Click-anywhere row dispatch primitive. When set, the renderer makes the
245
+ * entire row clickable AND keyboard-activatable (Enter / Space Space
246
+ * preventDefaults page scroll) AND exposes accessibility (role="button",
247
+ * tabindex=0, aria-label derived from cell text). Per-row identity is
248
+ * encoded in the action name (e.g. `select-ticket-42`) — no context field,
249
+ * consistent with the Phase 6 wire. Coexists with `actions[]`: clicking a
250
+ * per-row button, checkbox, or cell linkLabel anchor does NOT also fire
251
+ * `row.action` (the renderer stops propagation on those targets). */
252
+ action?: ActionEvent;
253
+ /** Per-row interactive controls rendered in a trailing actions cell. Each
254
+ * entry is either a ButtonNode (its own unique action name encodes per-row
255
+ * identity, e.g. `delete-row-42`) or a CheckboxNode (its own `bind` path
256
+ * per row). The renderer dispatches by `entry.type` so both types render
257
+ * correctly — a previous version typed this as `ButtonNode[]` and called
258
+ * the button renderer blindly, silently dropping non-button entries. */
259
+ actions?: (ButtonNode | CheckboxNode)[];
244
260
  variant?: string;
245
261
  }
246
262
  export interface TablePagination {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -217,6 +217,26 @@ body {
217
217
  padding: var(--vms-space-md);
218
218
  }
219
219
 
220
+ /* ── Section: collapsible (1.2.0 — client-side disclosure via native <details>) ──
221
+ Open/closed state is DOM-local; the BrowserAdapter snapshots and restores it
222
+ across server-driven re-renders the same way it does for focus and scroll.
223
+ :focus-visible matches the 1.1.0 row-click idiom (.vms-table__row--clickable). */
224
+ .vms-section--collapsible {
225
+ /* native <details> block layout is correct as-is; .vms-section flex inheritance still applies. */
226
+ }
227
+ .vms-section__summary {
228
+ font-size: var(--vms-text-xs);
229
+ letter-spacing: 0.08em;
230
+ text-transform: uppercase;
231
+ color: var(--vms-text-muted);
232
+ cursor: pointer;
233
+ list-style: revert; /* keep the native disclosure triangle visible — universal "expand" affordance */
234
+ }
235
+ .vms-section__summary:focus-visible {
236
+ outline: 2px solid var(--vms-accent);
237
+ outline-offset: 2px;
238
+ }
239
+
220
240
  /* ── Stat bar ── */
221
241
  .vms-stat-bar { display: flex; gap: var(--vms-space-lg); flex-wrap: wrap; }
222
242
  .vms-stat-bar__item { display: flex; align-items: baseline; gap: var(--vms-space-xs); }
@@ -605,6 +625,10 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
605
625
  .vms-table__row:not(:last-child) .vms-table__td { border-bottom: 1px solid var(--vms-border); }
606
626
  .vms-table__row--clickable { cursor: pointer; }
607
627
  .vms-table__row--clickable:hover { background: var(--vms-surface); }
628
+ .vms-table__row--clickable:focus-visible {
629
+ outline: 2px solid var(--vms-accent);
630
+ outline-offset: -2px;
631
+ }
608
632
  /* Row status variants — tints derive from theme vars (color-mix), so a
609
633
  custom :root theme recolors them automatically; no app CSS, no literals. */
610
634
  .vms-table__row--done { opacity: 0.6; }