@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 +2 -0
- package/dist/browser.js +63 -0
- package/dist/index.d.ts +4 -0
- package/package.json +1 -1
- package/styles/default.css +20 -0
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.
|
|
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",
|
package/styles/default.css
CHANGED
|
@@ -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); }
|