@ashley-shrok/viewmodel-shell 1.1.0 → 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;
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) {
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "1.1.0",
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); }