@ashley-shrok/viewmodel-shell 1.3.0 → 1.4.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 +40 -1
- package/dist/index.d.ts +25 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +104 -0
- package/dist/tui-cli.js +0 -0
- package/package.json +1 -1
- package/styles/default.css +13 -0
package/dist/browser.js
CHANGED
|
@@ -266,7 +266,7 @@ export class BrowserAdapter {
|
|
|
266
266
|
return;
|
|
267
267
|
}
|
|
268
268
|
const el = document.createElement("section");
|
|
269
|
-
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
|
|
269
|
+
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
270
270
|
if (n.heading) {
|
|
271
271
|
const h = document.createElement("h2");
|
|
272
272
|
h.className = "vms-section__heading";
|
|
@@ -274,6 +274,45 @@ export class BrowserAdapter {
|
|
|
274
274
|
el.appendChild(h);
|
|
275
275
|
}
|
|
276
276
|
this.kids(n.children, el, on);
|
|
277
|
+
// SectionNode.action — click-anywhere + keyboard + ARIA. Mirrors
|
|
278
|
+
// TableRow.action (1.1.0). Containment via stopPropagation on nested
|
|
279
|
+
// interactive controls AFTER kids() has rendered them.
|
|
280
|
+
if (n.action) {
|
|
281
|
+
const actionName = n.action.name;
|
|
282
|
+
el.tabIndex = 0;
|
|
283
|
+
el.setAttribute("role", "button");
|
|
284
|
+
// aria-label derivation: heading > flattened descendant text (capped) > "Card".
|
|
285
|
+
// Whitespace runs (textContent collapses across child elements, so we
|
|
286
|
+
// get long runs of spaces / newlines from the DOM tree) are collapsed
|
|
287
|
+
// to a single space — preserving normal in-text spacing like
|
|
288
|
+
// "Choose plan" intact instead of mangling it to "Choose · plan".
|
|
289
|
+
let ariaLabel = "";
|
|
290
|
+
if (n.heading && n.heading.trim().length > 0) {
|
|
291
|
+
ariaLabel = n.heading.trim();
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
295
|
+
ariaLabel = text.length > 0 ? text.slice(0, 200) : "Card";
|
|
296
|
+
}
|
|
297
|
+
el.setAttribute("aria-label", ariaLabel);
|
|
298
|
+
el.addEventListener("click", () => { on({ name: actionName }); });
|
|
299
|
+
el.addEventListener("keydown", (e) => {
|
|
300
|
+
if (e.key === "Enter") {
|
|
301
|
+
on({ name: actionName });
|
|
302
|
+
}
|
|
303
|
+
else if (e.key === " " || e.key === "Spacebar") {
|
|
304
|
+
e.preventDefault(); // suppress page scroll
|
|
305
|
+
on({ name: actionName });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// Containment: clicks on nested interactive controls must NOT bubble to
|
|
309
|
+
// the section's click handler. Selectors mirror the TableRow.action
|
|
310
|
+
// wiring (per-row button / checkbox / linkLabel anchor) plus a catch-all
|
|
311
|
+
// for any anchor inside the card (LinkNode renders as <a>).
|
|
312
|
+
el.querySelectorAll(".vms-button, .vms-checkbox__input, .vms-checkbox, .vms-table__link, a[href]").forEach(ctrl => {
|
|
313
|
+
ctrl.addEventListener("click", (e) => { e.stopPropagation(); });
|
|
314
|
+
});
|
|
315
|
+
}
|
|
277
316
|
parent.appendChild(el);
|
|
278
317
|
}
|
|
279
318
|
list(n, parent, on) {
|
package/dist/index.d.ts
CHANGED
|
@@ -86,6 +86,31 @@ export interface SectionNode {
|
|
|
86
86
|
id?: string;
|
|
87
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
88
|
collapsible?: boolean;
|
|
89
|
+
/** Click-anywhere section dispatch primitive — mirrors `TableRow.action` (1.1.0)
|
|
90
|
+
* at the section level. When set, the renderer makes the entire section
|
|
91
|
+
* clickable AND keyboard-activatable (Enter / Space — Space preventDefaults
|
|
92
|
+
* page scroll) AND exposes accessibility (role="button", tabindex=0,
|
|
93
|
+
* aria-label derived from `heading` when set, else from joined text content
|
|
94
|
+
* of descendants, else fallback `"Card"`). The per-section identity is
|
|
95
|
+
* encoded in the action name (e.g. `select-card-1`) — no context field,
|
|
96
|
+
* consistent with the Phase 6 wire. Clicks on nested ButtonNode / CheckboxNode /
|
|
97
|
+
* LinkNode / cell `linkLabel` anchors INSIDE the section do NOT also fire
|
|
98
|
+
* `action` (the renderer stops propagation on those targets).
|
|
99
|
+
*
|
|
100
|
+
* Tree validation rejects two invalid combos at the server edge with
|
|
101
|
+
* `invalid_tree`:
|
|
102
|
+
* (a) `action` set together with `collapsible: true` on the same section
|
|
103
|
+
* (a collapsible section's `<summary>` IS the click target; a clickable
|
|
104
|
+
* card makes the whole section the click target — pick one).
|
|
105
|
+
* (b) a SectionNode with `action` nested inside another SectionNode with
|
|
106
|
+
* `action` (nested role="button" is an a11y violation; click-ownership
|
|
107
|
+
* in the overlap is ambiguous). A styling-only `variant: "card"`
|
|
108
|
+
* section (no `action`) with internal buttons inside a clickable card
|
|
109
|
+
* is VALID — only nested `action` errors.
|
|
110
|
+
*
|
|
111
|
+
* Omitted = today's section rendering, byte-identical (no class drift, no
|
|
112
|
+
* extra attrs, no listeners). */
|
|
113
|
+
action?: ActionEvent;
|
|
89
114
|
children: ViewNode[];
|
|
90
115
|
}
|
|
91
116
|
export interface ListNode {
|
package/dist/server.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ export * from "./index.js";
|
|
|
15
15
|
* same enclosing form).
|
|
16
16
|
*/
|
|
17
17
|
export declare function validateActionNames(vm: ViewNode): void;
|
|
18
|
+
/**
|
|
19
|
+
* Walk a ViewNode tree and reject two invalid SectionNode.action combos:
|
|
20
|
+
* (a) action + collapsible:true on the same section, and (b) a clickable
|
|
21
|
+
* section nested inside another clickable section. Pure check — does not
|
|
22
|
+
* mutate the tree.
|
|
23
|
+
*
|
|
24
|
+
* @throws Error when either invalid combo is found. The message names the
|
|
25
|
+
* offending section(s) by heading (or `(headingless)`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateSectionAction(vm: ViewNode): void;
|
|
18
28
|
export interface ActionPayload<TState> {
|
|
19
29
|
name: string;
|
|
20
30
|
state: TState;
|
package/dist/server.js
CHANGED
|
@@ -174,6 +174,107 @@ function collectActions(node, enclosingForm, out) {
|
|
|
174
174
|
function recordAction(action, enclosingForm, out) {
|
|
175
175
|
out.push({ name: action.name, enclosingForm });
|
|
176
176
|
}
|
|
177
|
+
// ─── SectionNode.action shape check (1.4.0 / 260614-9hq) ─────────────────────
|
|
178
|
+
//
|
|
179
|
+
// Two invalid combos a clickable-card primitive can produce at build time:
|
|
180
|
+
// (a) SectionNode.action set together with collapsible:true on the same
|
|
181
|
+
// section — a collapsible section's <summary> IS the click target; a
|
|
182
|
+
// clickable card makes the whole section the click target. Pick one.
|
|
183
|
+
// (b) A SectionNode with .action nested inside another SectionNode with
|
|
184
|
+
// .action — nested role="button" is an a11y violation, and
|
|
185
|
+
// click-ownership in the overlap is ambiguous.
|
|
186
|
+
// A styling-only SectionNode { variant: "card" } (no .action) inside a
|
|
187
|
+
// clickable card with internal buttons is VALID — only nested .action errors.
|
|
188
|
+
//
|
|
189
|
+
// Mirrors viewmodel-shell-dotnet/ViewModels.cs's
|
|
190
|
+
// ViewTreeValidation.ValidateSectionAction. The createAction wrapper invokes
|
|
191
|
+
// this alongside validateActionNames so a server-built tree that violates
|
|
192
|
+
// either rule surfaces as a 500 with code "invalid_tree" before the response
|
|
193
|
+
// leaves the wire.
|
|
194
|
+
/**
|
|
195
|
+
* Walk a ViewNode tree and reject two invalid SectionNode.action combos:
|
|
196
|
+
* (a) action + collapsible:true on the same section, and (b) a clickable
|
|
197
|
+
* section nested inside another clickable section. Pure check — does not
|
|
198
|
+
* mutate the tree.
|
|
199
|
+
*
|
|
200
|
+
* @throws Error when either invalid combo is found. The message names the
|
|
201
|
+
* offending section(s) by heading (or `(headingless)`).
|
|
202
|
+
*/
|
|
203
|
+
export function validateSectionAction(vm) {
|
|
204
|
+
walkForSectionAction(vm, null);
|
|
205
|
+
}
|
|
206
|
+
function walkForSectionAction(node, outerClickable) {
|
|
207
|
+
switch (node.type) {
|
|
208
|
+
case "page": {
|
|
209
|
+
const page = node;
|
|
210
|
+
for (const child of page.children)
|
|
211
|
+
walkForSectionAction(child, outerClickable);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "section": {
|
|
215
|
+
const section = node;
|
|
216
|
+
// (a) action + collapsible:true — invalid.
|
|
217
|
+
if (section.action != null && section.collapsible === true) {
|
|
218
|
+
const hdr = section.heading && section.heading.length > 0
|
|
219
|
+
? section.heading
|
|
220
|
+
: "(headingless)";
|
|
221
|
+
throw new Error(`SectionNode '${hdr}' has both Action and Collapsible: true set. ` +
|
|
222
|
+
"A collapsible section's summary IS the click target; a clickable card " +
|
|
223
|
+
"makes the whole section the click target. Pick one.");
|
|
224
|
+
}
|
|
225
|
+
// (b) nested action-in-action — invalid.
|
|
226
|
+
if (section.action != null && outerClickable !== null) {
|
|
227
|
+
const innerHdr = section.heading && section.heading.length > 0
|
|
228
|
+
? section.heading
|
|
229
|
+
: "(headingless)";
|
|
230
|
+
const outerHdr = outerClickable.heading && outerClickable.heading.length > 0
|
|
231
|
+
? outerClickable.heading
|
|
232
|
+
: "(headingless)";
|
|
233
|
+
throw new Error(`Nested SectionNode.Action: inner section '${innerHdr}' is inside clickable outer ` +
|
|
234
|
+
`section '${outerHdr}'. Nested role='button' elements are an accessibility violation, ` +
|
|
235
|
+
"and click-ownership in the overlap is ambiguous. Use a styling-only inner section " +
|
|
236
|
+
"(variant: 'card', no Action) with internal buttons instead.");
|
|
237
|
+
}
|
|
238
|
+
const nextOuter = section.action != null ? section : outerClickable;
|
|
239
|
+
for (const child of section.children)
|
|
240
|
+
walkForSectionAction(child, nextOuter);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
case "list": {
|
|
244
|
+
const list = node;
|
|
245
|
+
for (const child of list.children)
|
|
246
|
+
walkForSectionAction(child, outerClickable);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
case "list-item": {
|
|
250
|
+
const li = node;
|
|
251
|
+
for (const child of li.children)
|
|
252
|
+
walkForSectionAction(child, outerClickable);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
case "form": {
|
|
256
|
+
const form = node;
|
|
257
|
+
for (const child of form.children)
|
|
258
|
+
walkForSectionAction(child, outerClickable);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case "modal": {
|
|
262
|
+
const modal = node;
|
|
263
|
+
for (const child of modal.children)
|
|
264
|
+
walkForSectionAction(child, outerClickable);
|
|
265
|
+
if (modal.footer) {
|
|
266
|
+
for (const child of modal.footer)
|
|
267
|
+
walkForSectionAction(child, outerClickable);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
|
|
272
|
+
// tabs, progress, table, copy-button) carry no SectionNode descendants —
|
|
273
|
+
// TableNode rows hold strings + per-row controls, not sections.
|
|
274
|
+
default:
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
177
278
|
/** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
|
|
178
279
|
export function parseFormDataAction(formData) {
|
|
179
280
|
const actionRaw = formData.get("_action");
|
|
@@ -374,6 +475,9 @@ export function createAction(handler) {
|
|
|
374
475
|
if (result.vm) {
|
|
375
476
|
try {
|
|
376
477
|
validateActionNames(result.vm);
|
|
478
|
+
// 1.4.0 — SectionNode.action shape checks (action+collapsible,
|
|
479
|
+
// nested action-in-action). Same invalid_tree exit path.
|
|
480
|
+
validateSectionAction(result.vm);
|
|
377
481
|
}
|
|
378
482
|
catch (err) {
|
|
379
483
|
return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.INVALID_TREE }]), 500);
|
package/dist/tui-cli.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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
|
@@ -221,6 +221,19 @@ body {
|
|
|
221
221
|
padding: var(--vms-space-md);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/* ── Section: clickable (1.4.0 — SectionNode.action click-anywhere primitive) ──
|
|
225
|
+
Mirrors the 1.1.0 .vms-table__row--clickable idiom one level up the tree.
|
|
226
|
+
Hover uses a 1px accent-dim ring (rather than a background swap — cards
|
|
227
|
+
already paint --vms-surface as their background, so a background change
|
|
228
|
+
would be invisible). :focus-visible outline lives OUTSIDE the box edge
|
|
229
|
+
(positive outline-offset) since a section is a box, not a table row. */
|
|
230
|
+
.vms-section--clickable { cursor: pointer; }
|
|
231
|
+
.vms-section--clickable:hover { box-shadow: 0 0 0 1px var(--vms-accent-dim); }
|
|
232
|
+
.vms-section--clickable:focus-visible {
|
|
233
|
+
outline: 2px solid var(--vms-accent);
|
|
234
|
+
outline-offset: 2px;
|
|
235
|
+
}
|
|
236
|
+
|
|
224
237
|
/* ── Section: collapsible (1.2.0 — client-side disclosure via native <details>) ──
|
|
225
238
|
Open/closed state is DOM-local; the BrowserAdapter snapshots and restores it
|
|
226
239
|
across server-driven re-renders the same way it does for focus and scroll.
|