@ashley-shrok/viewmodel-shell 1.3.0 → 1.5.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 +92 -1
- package/dist/index.d.ts +70 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +149 -0
- package/dist/tui-cli.js +0 -0
- package/dist/tui.js +21 -0
- package/package.json +1 -1
- package/styles/default.css +30 -0
package/dist/browser.js
CHANGED
|
@@ -265,8 +265,60 @@ export class BrowserAdapter {
|
|
|
265
265
|
parent.appendChild(el);
|
|
266
266
|
return;
|
|
267
267
|
}
|
|
268
|
+
// 1.5.0 — SectionNode.link URL-wrapper variant (issue #21). When set,
|
|
269
|
+
// emit a wrapping <a href> element instead of <section> so every native
|
|
270
|
+
// browser link affordance works for free (middle-click / Ctrl/Cmd-click
|
|
271
|
+
// new tab, right-click context menu, drag-to-bookmarks, status-bar URL).
|
|
272
|
+
// Validation guarantees link + action and link + collapsible are
|
|
273
|
+
// mutually exclusive, and link cannot be nested inside another link or
|
|
274
|
+
// action — see validateSectionAction in server.ts.
|
|
275
|
+
if (n.link) {
|
|
276
|
+
const a = document.createElement("a");
|
|
277
|
+
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
|
|
278
|
+
a.href = n.link.url;
|
|
279
|
+
// Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
|
|
280
|
+
// byte-for-byte: target=_blank + rel=noopener noreferrer when external.
|
|
281
|
+
if (n.link.external) {
|
|
282
|
+
a.target = "_blank";
|
|
283
|
+
a.rel = "noopener noreferrer";
|
|
284
|
+
}
|
|
285
|
+
if (n.heading) {
|
|
286
|
+
const h = document.createElement("h2");
|
|
287
|
+
h.className = "vms-section__heading";
|
|
288
|
+
h.textContent = n.heading;
|
|
289
|
+
a.appendChild(h);
|
|
290
|
+
}
|
|
291
|
+
this.kids(n.children, a, on);
|
|
292
|
+
// Containment: clicks on nested interactive controls must NOT trigger
|
|
293
|
+
// the wrapper anchor's navigation. For non-anchor controls, stopPropagation
|
|
294
|
+
// is enough — the wrapper anchor's default navigation only fires on the
|
|
295
|
+
// anchor element itself, and stopPropagation prevents bubbled re-fires.
|
|
296
|
+
// For nested anchors (cell linkLabels), we additionally preventDefault on
|
|
297
|
+
// the click so a bubbled click cannot re-trigger the wrapper anchor's
|
|
298
|
+
// default navigation in browsers that handle nested <a> ambiguously. The
|
|
299
|
+
// catch-all `a[href]` selector includes the wrapper itself — skip it via
|
|
300
|
+
// `ctrl === a` so the wrapper's own click is NOT preventDefaulted.
|
|
301
|
+
//
|
|
302
|
+
// TODO: LinkNode-inside-section.link is left to the existing LinkNode
|
|
303
|
+
// renderer; spec-wise nested <a> is invalid HTML (issue #21 deliberately
|
|
304
|
+
// does NOT block it because the tree-validation rule only catches the
|
|
305
|
+
// sibling SectionNode-level case). A follow-up runtime warning when an
|
|
306
|
+
// inner LinkNode lives inside a section.link wrapper could surface this
|
|
307
|
+
// to consumers; until then, consumers can avoid the combo.
|
|
308
|
+
a.querySelectorAll(".vms-button, .vms-checkbox__input, .vms-checkbox, .vms-field__input, .vms-table__link, a[href]").forEach(ctrl => {
|
|
309
|
+
if (ctrl === a)
|
|
310
|
+
return;
|
|
311
|
+
ctrl.addEventListener("click", (e) => {
|
|
312
|
+
e.stopPropagation();
|
|
313
|
+
if (ctrl instanceof HTMLAnchorElement)
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
parent.appendChild(a);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
268
320
|
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}` : ""}`;
|
|
321
|
+
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
322
|
if (n.heading) {
|
|
271
323
|
const h = document.createElement("h2");
|
|
272
324
|
h.className = "vms-section__heading";
|
|
@@ -274,6 +326,45 @@ export class BrowserAdapter {
|
|
|
274
326
|
el.appendChild(h);
|
|
275
327
|
}
|
|
276
328
|
this.kids(n.children, el, on);
|
|
329
|
+
// SectionNode.action — click-anywhere + keyboard + ARIA. Mirrors
|
|
330
|
+
// TableRow.action (1.1.0). Containment via stopPropagation on nested
|
|
331
|
+
// interactive controls AFTER kids() has rendered them.
|
|
332
|
+
if (n.action) {
|
|
333
|
+
const actionName = n.action.name;
|
|
334
|
+
el.tabIndex = 0;
|
|
335
|
+
el.setAttribute("role", "button");
|
|
336
|
+
// aria-label derivation: heading > flattened descendant text (capped) > "Card".
|
|
337
|
+
// Whitespace runs (textContent collapses across child elements, so we
|
|
338
|
+
// get long runs of spaces / newlines from the DOM tree) are collapsed
|
|
339
|
+
// to a single space — preserving normal in-text spacing like
|
|
340
|
+
// "Choose plan" intact instead of mangling it to "Choose · plan".
|
|
341
|
+
let ariaLabel = "";
|
|
342
|
+
if (n.heading && n.heading.trim().length > 0) {
|
|
343
|
+
ariaLabel = n.heading.trim();
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
347
|
+
ariaLabel = text.length > 0 ? text.slice(0, 200) : "Card";
|
|
348
|
+
}
|
|
349
|
+
el.setAttribute("aria-label", ariaLabel);
|
|
350
|
+
el.addEventListener("click", () => { on({ name: actionName }); });
|
|
351
|
+
el.addEventListener("keydown", (e) => {
|
|
352
|
+
if (e.key === "Enter") {
|
|
353
|
+
on({ name: actionName });
|
|
354
|
+
}
|
|
355
|
+
else if (e.key === " " || e.key === "Spacebar") {
|
|
356
|
+
e.preventDefault(); // suppress page scroll
|
|
357
|
+
on({ name: actionName });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
// Containment: clicks on nested interactive controls must NOT bubble to
|
|
361
|
+
// the section's click handler. Selectors mirror the TableRow.action
|
|
362
|
+
// wiring (per-row button / checkbox / linkLabel anchor) plus a catch-all
|
|
363
|
+
// for any anchor inside the card (LinkNode renders as <a>).
|
|
364
|
+
el.querySelectorAll(".vms-button, .vms-checkbox__input, .vms-checkbox, .vms-table__link, a[href]").forEach(ctrl => {
|
|
365
|
+
ctrl.addEventListener("click", (e) => { e.stopPropagation(); });
|
|
366
|
+
});
|
|
367
|
+
}
|
|
277
368
|
parent.appendChild(el);
|
|
278
369
|
}
|
|
279
370
|
list(n, parent, on) {
|
package/dist/index.d.ts
CHANGED
|
@@ -86,6 +86,76 @@ 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;
|
|
114
|
+
/** URL-link navigator variant of the clickable-card primitive — the sibling
|
|
115
|
+
* of `action` (1.4.0). Set this to make the entire section a navigational
|
|
116
|
+
* anchor: the BrowserAdapter emits a wrapping `<a href={url}>` element so
|
|
117
|
+
* every NATIVE browser link affordance works for free — left-click navigate,
|
|
118
|
+
* middle-click new tab, Ctrl/Cmd-click new tab, Shift-click new window,
|
|
119
|
+
* right-click context menu, drag-to-bookmarks, status-bar URL preview, and
|
|
120
|
+
* accessible link semantics. No JS substitute exists for those; browsers
|
|
121
|
+
* implement them at the anchor-element level.
|
|
122
|
+
*
|
|
123
|
+
* Reach for `link` (this field) when the card is conceptually a
|
|
124
|
+
* NAVIGATIONAL target (docs tile, gallery item, launcher tile). Reach for
|
|
125
|
+
* `action` (the sibling above) when the card is a DISPATCHER that runs
|
|
126
|
+
* server-side work. Closes [issue #21](https://github.com/ashley-shrok/ViewModelShell/issues/21).
|
|
127
|
+
*
|
|
128
|
+
* Wire shape — INLINE object `{ url, external? }`, not flat sibling fields.
|
|
129
|
+
* When `external: true` is set, the renderer additionally adds
|
|
130
|
+
* `target="_blank"` and `rel="noopener noreferrer"` (mirroring LinkNode's
|
|
131
|
+
* external attribute pattern byte-for-byte). Clicks on nested ButtonNode /
|
|
132
|
+
* CheckboxNode / FieldNode / LinkNode / cell `linkLabel` anchors INSIDE a
|
|
133
|
+
* linked card do NOT also fire the wrapper anchor's navigation (the
|
|
134
|
+
* renderer stops propagation on those targets; for nested anchors it
|
|
135
|
+
* additionally `preventDefault`s the wrapper's default so the inner anchor
|
|
136
|
+
* wins). No `role`, no `tabindex`, no `aria-label` — the anchor element
|
|
137
|
+
* provides every link / keyboard / focus / a11y semantic natively.
|
|
138
|
+
*
|
|
139
|
+
* Tree validation rejects four invalid combos at the server edge with
|
|
140
|
+
* `invalid_tree`:
|
|
141
|
+
* (a) `link` set together with `action` on the same section — a
|
|
142
|
+
* SectionNode is either a dispatcher (action) or a navigator (link);
|
|
143
|
+
* they create different user expectations of what a click means.
|
|
144
|
+
* Pick one.
|
|
145
|
+
* (b) `link` set together with `collapsible: true` on the same section —
|
|
146
|
+
* same rationale as action+collapsible (the summary IS the click
|
|
147
|
+
* target; a linked card makes the whole section the click target).
|
|
148
|
+
* (c) a SectionNode with `link` nested inside another SectionNode with
|
|
149
|
+
* `link` — HTML5 prohibits nested `<a>` elements.
|
|
150
|
+
* (d) a SectionNode with `link` nested inside a SectionNode with `action`
|
|
151
|
+
* (or vice versa) — click-ownership in the overlap is ambiguous.
|
|
152
|
+
*
|
|
153
|
+
* Omitted = today's section rendering, byte-identical (no `<a>` wrapper,
|
|
154
|
+
* no class drift, no extra attrs). */
|
|
155
|
+
link?: {
|
|
156
|
+
url: string;
|
|
157
|
+
external?: boolean;
|
|
158
|
+
};
|
|
89
159
|
children: ViewNode[];
|
|
90
160
|
}
|
|
91
161
|
export interface ListNode {
|
package/dist/server.d.ts
CHANGED
|
@@ -15,6 +15,17 @@ 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 five invalid SectionNode.action / .link
|
|
20
|
+
* combos: (a) action + collapsible:true; (b) nested action-in-action or
|
|
21
|
+
* action-in-link; (c) action + link on the same section; (d) link +
|
|
22
|
+
* collapsible:true; (e) nested link-in-link or link-in-action. Pure check —
|
|
23
|
+
* does not mutate the tree.
|
|
24
|
+
*
|
|
25
|
+
* @throws Error when any invalid combo is found. The message names the
|
|
26
|
+
* offending section(s) by heading (or `(headingless)`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function validateSectionAction(vm: ViewNode): void;
|
|
18
29
|
export interface ActionPayload<TState> {
|
|
19
30
|
name: string;
|
|
20
31
|
state: TState;
|
package/dist/server.js
CHANGED
|
@@ -174,6 +174,152 @@ 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 / .link shape checks (1.4.0 / 1.5.0) ─────────────────
|
|
178
|
+
//
|
|
179
|
+
// Five invalid combos a clickable-card or linked-card primitive can produce at
|
|
180
|
+
// build time:
|
|
181
|
+
// (a) SectionNode.action set together with collapsible:true on the same
|
|
182
|
+
// section — a collapsible section's <summary> IS the click target; a
|
|
183
|
+
// clickable card makes the whole section the click target. Pick one.
|
|
184
|
+
// (b) A SectionNode with .action nested inside another SectionNode with
|
|
185
|
+
// .action OR .link — nested role="button"/nested-<a> is an a11y/HTML5
|
|
186
|
+
// violation, and click-ownership in the overlap is ambiguous.
|
|
187
|
+
// (c) (1.5.0) SectionNode.action set together with SectionNode.link on the
|
|
188
|
+
// same section — a section is either a dispatcher (action) or a
|
|
189
|
+
// navigator (link); they create different user expectations of a click.
|
|
190
|
+
// (d) (1.5.0) SectionNode.link set together with collapsible:true — same
|
|
191
|
+
// rationale as (a).
|
|
192
|
+
// (e) (1.5.0) A SectionNode with .link nested inside another SectionNode
|
|
193
|
+
// with .link OR .action — HTML5 prohibits nested <a> elements; the
|
|
194
|
+
// mixed case is ambiguous click-ownership.
|
|
195
|
+
// A styling-only SectionNode { variant: "card" } (no .action and no .link)
|
|
196
|
+
// inside a clickable or linked card with internal buttons is VALID — only
|
|
197
|
+
// nested .action / .link errors.
|
|
198
|
+
//
|
|
199
|
+
// Mirrors viewmodel-shell-dotnet/ViewModels.cs's
|
|
200
|
+
// ViewTreeValidation.ValidateSectionAction. The createAction wrapper invokes
|
|
201
|
+
// this alongside validateActionNames so a server-built tree that violates
|
|
202
|
+
// any rule surfaces as a 500 with code "invalid_tree" before the response
|
|
203
|
+
// leaves the wire.
|
|
204
|
+
/**
|
|
205
|
+
* Walk a ViewNode tree and reject five invalid SectionNode.action / .link
|
|
206
|
+
* combos: (a) action + collapsible:true; (b) nested action-in-action or
|
|
207
|
+
* action-in-link; (c) action + link on the same section; (d) link +
|
|
208
|
+
* collapsible:true; (e) nested link-in-link or link-in-action. Pure check —
|
|
209
|
+
* does not mutate the tree.
|
|
210
|
+
*
|
|
211
|
+
* @throws Error when any invalid combo is found. The message names the
|
|
212
|
+
* offending section(s) by heading (or `(headingless)`).
|
|
213
|
+
*/
|
|
214
|
+
export function validateSectionAction(vm) {
|
|
215
|
+
walkForSectionAction(vm, null);
|
|
216
|
+
}
|
|
217
|
+
function walkForSectionAction(node, outerInteractive) {
|
|
218
|
+
switch (node.type) {
|
|
219
|
+
case "page": {
|
|
220
|
+
const page = node;
|
|
221
|
+
for (const child of page.children)
|
|
222
|
+
walkForSectionAction(child, outerInteractive);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case "section": {
|
|
226
|
+
const section = node;
|
|
227
|
+
const hdr = section.heading && section.heading.length > 0
|
|
228
|
+
? section.heading
|
|
229
|
+
: "(headingless)";
|
|
230
|
+
// (c) action + link on the same section — invalid. Checked FIRST so
|
|
231
|
+
// the most actionable message wins when the consumer accidentally sets
|
|
232
|
+
// both (they get told "pick action OR link" instead of any nested or
|
|
233
|
+
// collapsible message that follows from a still-ambiguous tree).
|
|
234
|
+
if (section.action != null && section.link != null) {
|
|
235
|
+
throw new Error(`SectionNode '${hdr}' has both Action and Link set. ` +
|
|
236
|
+
"A SectionNode is either a dispatcher (action) or a navigator (link) — " +
|
|
237
|
+
"they create different user expectations of what a click means. Pick one.");
|
|
238
|
+
}
|
|
239
|
+
// (d) link + collapsible:true — invalid.
|
|
240
|
+
if (section.link != null && section.collapsible === true) {
|
|
241
|
+
throw new Error(`SectionNode '${hdr}' has both Link and Collapsible: true set. ` +
|
|
242
|
+
"A collapsible section's summary IS the click target; a linked card " +
|
|
243
|
+
"makes the whole section the click target. Pick one.");
|
|
244
|
+
}
|
|
245
|
+
// (a) action + collapsible:true — invalid (existing, unchanged).
|
|
246
|
+
if (section.action != null && section.collapsible === true) {
|
|
247
|
+
throw new Error(`SectionNode '${hdr}' has both Action and Collapsible: true set. ` +
|
|
248
|
+
"A collapsible section's summary IS the click target; a clickable card " +
|
|
249
|
+
"makes the whole section the click target. Pick one.");
|
|
250
|
+
}
|
|
251
|
+
// (e) nested link-in-link / link-in-action — invalid.
|
|
252
|
+
if (section.link != null && outerInteractive !== null) {
|
|
253
|
+
const outerHdr = outerInteractive.heading && outerInteractive.heading.length > 0
|
|
254
|
+
? outerInteractive.heading
|
|
255
|
+
: "(headingless)";
|
|
256
|
+
if (outerInteractive.link != null) {
|
|
257
|
+
throw new Error(`Nested SectionNode.Link: inner section '${hdr}' is inside linked outer ` +
|
|
258
|
+
`section '${outerHdr}'. HTML5 prohibits nested <a> elements.`);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
throw new Error(`SectionNode.Link inner section '${hdr}' is inside clickable outer ` +
|
|
262
|
+
`SectionNode.Action '${outerHdr}'. Click-ownership in the overlap is ambiguous — ` +
|
|
263
|
+
"a linked card inside a dispatcher card creates two competing primary interactions.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// (b) nested action-in-action / action-in-link — invalid.
|
|
267
|
+
if (section.action != null && outerInteractive !== null) {
|
|
268
|
+
const outerHdr = outerInteractive.heading && outerInteractive.heading.length > 0
|
|
269
|
+
? outerInteractive.heading
|
|
270
|
+
: "(headingless)";
|
|
271
|
+
if (outerInteractive.action != null) {
|
|
272
|
+
throw new Error(`Nested SectionNode.Action: inner section '${hdr}' is inside clickable outer ` +
|
|
273
|
+
`section '${outerHdr}'. Nested role='button' elements are an accessibility violation, ` +
|
|
274
|
+
"and click-ownership in the overlap is ambiguous. Use a styling-only inner section " +
|
|
275
|
+
"(variant: 'card', no Action) with internal buttons instead.");
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
throw new Error(`SectionNode.Action inner section '${hdr}' is inside linked outer ` +
|
|
279
|
+
`SectionNode.Link '${outerHdr}'. Click-ownership in the overlap is ambiguous — ` +
|
|
280
|
+
"a dispatcher card inside a linked card creates two competing primary interactions.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const nextOuter = (section.action != null || section.link != null) ? section : outerInteractive;
|
|
284
|
+
for (const child of section.children)
|
|
285
|
+
walkForSectionAction(child, nextOuter);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
case "list": {
|
|
289
|
+
const list = node;
|
|
290
|
+
for (const child of list.children)
|
|
291
|
+
walkForSectionAction(child, outerInteractive);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
case "list-item": {
|
|
295
|
+
const li = node;
|
|
296
|
+
for (const child of li.children)
|
|
297
|
+
walkForSectionAction(child, outerInteractive);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
case "form": {
|
|
301
|
+
const form = node;
|
|
302
|
+
for (const child of form.children)
|
|
303
|
+
walkForSectionAction(child, outerInteractive);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
case "modal": {
|
|
307
|
+
const modal = node;
|
|
308
|
+
for (const child of modal.children)
|
|
309
|
+
walkForSectionAction(child, outerInteractive);
|
|
310
|
+
if (modal.footer) {
|
|
311
|
+
for (const child of modal.footer)
|
|
312
|
+
walkForSectionAction(child, outerInteractive);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
|
|
317
|
+
// tabs, progress, table, copy-button) carry no SectionNode descendants —
|
|
318
|
+
// TableNode rows hold strings + per-row controls, not sections.
|
|
319
|
+
default:
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
177
323
|
/** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
|
|
178
324
|
export function parseFormDataAction(formData) {
|
|
179
325
|
const actionRaw = formData.get("_action");
|
|
@@ -374,6 +520,9 @@ export function createAction(handler) {
|
|
|
374
520
|
if (result.vm) {
|
|
375
521
|
try {
|
|
376
522
|
validateActionNames(result.vm);
|
|
523
|
+
// 1.4.0 — SectionNode.action shape checks (action+collapsible,
|
|
524
|
+
// nested action-in-action). Same invalid_tree exit path.
|
|
525
|
+
validateSectionAction(result.vm);
|
|
377
526
|
}
|
|
378
527
|
catch (err) {
|
|
379
528
|
return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.INVALID_TREE }]), 500);
|
package/dist/tui-cli.js
CHANGED
|
File without changes
|
package/dist/tui.js
CHANGED
|
@@ -292,6 +292,12 @@ export class TuiAdapter {
|
|
|
292
292
|
else if (a.type === "link") {
|
|
293
293
|
this.navigate(a.href);
|
|
294
294
|
}
|
|
295
|
+
else if (a.type === "section-link") {
|
|
296
|
+
// 1.5.0 — SectionNode.link parity for the TUI: focused pane Enter
|
|
297
|
+
// navigates to the section's URL via the same `navigate` verb that
|
|
298
|
+
// LinkNode uses. Mirrors the BrowserAdapter's <a href> wrapper.
|
|
299
|
+
this.navigate(a.url);
|
|
300
|
+
}
|
|
295
301
|
else if (a.type === "copy-button") {
|
|
296
302
|
this.copy(a.text);
|
|
297
303
|
}
|
|
@@ -586,6 +592,15 @@ function focusedPaneSummary(vm, index) {
|
|
|
586
592
|
let hasInputs = false;
|
|
587
593
|
let primaryActionable = null;
|
|
588
594
|
let primaryCheckbox = null;
|
|
595
|
+
// 1.5.0 — when the pane IS a section with `link.url` set, surface it as a
|
|
596
|
+
// synthetic link-actionable BEFORE scanning descendants so Enter on the
|
|
597
|
+
// focused pane navigates via the wrapper anchor's URL (mirrors how the
|
|
598
|
+
// BrowserAdapter gives a `<a href>` wrapper native link semantics for free).
|
|
599
|
+
// Descendant LinkNode/Button/etc. can still win if no section-level link
|
|
600
|
+
// is set; the section-link only seeds primaryActionable when present.
|
|
601
|
+
if (pane.type === "section" && pane.link && pane.link.url.trim().length > 0) {
|
|
602
|
+
primaryActionable = { type: "section-link", url: pane.link.url };
|
|
603
|
+
}
|
|
589
604
|
const scan = (node) => {
|
|
590
605
|
if (node.type === "field")
|
|
591
606
|
hasInputs = true;
|
|
@@ -687,6 +702,12 @@ function paneActivationHint(summary) {
|
|
|
687
702
|
label = a.label;
|
|
688
703
|
else if (a.type === "link")
|
|
689
704
|
label = a.label;
|
|
705
|
+
else if (a.type === "section-link") {
|
|
706
|
+
// 1.5.0 — synthetic actionable from SectionNode.link. No `label` field;
|
|
707
|
+
// the section's heading is shown separately by StatusBar, so use the
|
|
708
|
+
// pane's heading when available (else generic "open").
|
|
709
|
+
label = summary.heading ?? "open";
|
|
710
|
+
}
|
|
690
711
|
else
|
|
691
712
|
label = a.label ?? "copy"; // copy-button
|
|
692
713
|
// Truncate long labels so the status bar fits a single line cleanly.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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,36 @@ 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
|
+
|
|
237
|
+
/* ── Section: linked (1.5.0 — SectionNode.link URL-wrapper variant, issue #21) ──
|
|
238
|
+
Sibling of .vms-section--clickable, applied to a wrapping <a href> instead of
|
|
239
|
+
a <section role="button">. Mirrors the clickable rules for cursor + hover +
|
|
240
|
+
:focus-visible so a linked card has the same affordance shape as a dispatcher
|
|
241
|
+
card. Additionally resets the anchor-default text styling: without these
|
|
242
|
+
`color: inherit` + `text-decoration: none` rules a linked card would render
|
|
243
|
+
with browser-default blue underlined heading text, since the wrapper element
|
|
244
|
+
is now an <a>. The `:focus-visible` outline uses the same AA-passing
|
|
245
|
+
--vms-accent token gated by the existing pair coverage, so all 12 themes
|
|
246
|
+
pass the contrast guard without additional work. */
|
|
247
|
+
.vms-section--linked { cursor: pointer; color: inherit; text-decoration: none; }
|
|
248
|
+
.vms-section--linked:hover { box-shadow: 0 0 0 1px var(--vms-accent-dim); }
|
|
249
|
+
.vms-section--linked:focus-visible {
|
|
250
|
+
outline: 2px solid var(--vms-accent);
|
|
251
|
+
outline-offset: 2px;
|
|
252
|
+
}
|
|
253
|
+
|
|
224
254
|
/* ── Section: collapsible (1.2.0 — client-side disclosure via native <details>) ──
|
|
225
255
|
Open/closed state is DOM-local; the BrowserAdapter snapshots and restores it
|
|
226
256
|
across server-driven re-renders the same way it does for focus and scroll.
|