@ashley-shrok/viewmodel-shell 0.8.0 → 0.10.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 CHANGED
@@ -249,14 +249,12 @@ export class BrowserAdapter {
249
249
  form.className = `vms-form${n.layout && n.layout !== "stack" ? ` vms-form--${n.layout}` : ""}`;
250
250
  form.noValidate = true;
251
251
  this.kids(n.children, form, on);
252
- const submit = document.createElement("button");
253
- submit.type = "submit";
254
- submit.className = "vms-button vms-button--primary";
255
- submit.textContent = n.submitLabel ?? "Submit";
256
- form.appendChild(submit);
257
- form.addEventListener("submit", (e) => {
258
- e.preventDefault();
259
- const ctx = { ...(n.submitAction.context ?? {}) };
252
+ // 0.10.0 (#15) — harvest this form's current field values, merge into the
253
+ // given action's context, and dispatch. Factored out of the submit
254
+ // handler so both the default submit AND each buttons[] entry can call it
255
+ // with a DIFFERENT action carrying the SAME live field values.
256
+ const harvest = (base) => {
257
+ const ctx = { ...(base.context ?? {}) };
260
258
  const files = {};
261
259
  form.querySelectorAll("input:not([type=checkbox]):not([type=file]), textarea").forEach(el => { if (el.name)
262
260
  ctx[el.name] = el.value; });
@@ -277,11 +275,43 @@ export class BrowserAdapter {
277
275
  if (inp.name && inp.files?.[0])
278
276
  files[inp.name] = inp.files[0];
279
277
  });
280
- const action = { name: n.submitAction.name, context: ctx };
278
+ const action = { name: base.name, context: ctx };
281
279
  if (Object.keys(files).length > 0)
282
280
  action.files = files;
283
281
  on(action);
284
- });
282
+ };
283
+ // Default submit button + Enter-to-submit, only when submitAction is set.
284
+ // (0.10.0: submitAction is now optional — a buttons[]-only form renders
285
+ // no default button, and Enter does not submit at the form level.)
286
+ if (n.submitAction) {
287
+ const submitAction = n.submitAction;
288
+ const submit = document.createElement("button");
289
+ submit.type = "submit";
290
+ submit.className = "vms-button vms-button--primary";
291
+ submit.textContent = n.submitLabel ?? "Submit";
292
+ form.appendChild(submit);
293
+ form.addEventListener("submit", (e) => {
294
+ e.preventDefault();
295
+ harvest(submitAction);
296
+ });
297
+ }
298
+ else {
299
+ // No default submit — still neutralize implicit Enter submission so a
300
+ // single-field buttons[]-only form doesn't reload via native submit.
301
+ form.addEventListener("submit", (e) => e.preventDefault());
302
+ }
303
+ // 0.10.0 (#15) — multi-action buttons. Each renders through the normal
304
+ // button() path (so variant + pendingLabel work) but its onAction is
305
+ // wrapped to harvest the form first. We render them in a footer row so
306
+ // they group like the default submit.
307
+ if (n.buttons && n.buttons.length > 0) {
308
+ const row = document.createElement("div");
309
+ row.className = "vms-form__buttons";
310
+ const harvestOn = (action) => harvest(action);
311
+ for (const btn of n.buttons)
312
+ this.button(btn, row, harvestOn);
313
+ form.appendChild(row);
314
+ }
285
315
  parent.appendChild(form);
286
316
  }
287
317
  field(n, parent, on) {
@@ -690,7 +720,10 @@ export class BrowserAdapter {
690
720
  copyButton(n, parent) {
691
721
  const btn = document.createElement("button");
692
722
  btn.type = "button";
693
- btn.className = "vms-button";
723
+ // 0.9.0 (#14): variant modifier class, mirroring button() exactly. The
724
+ // existing .vms-button--{primary,secondary,danger} CSS rules apply
725
+ // automatically — no new style surface.
726
+ btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
694
727
  btn.textContent = n.label ?? "Copy";
695
728
  btn.addEventListener("click", () => {
696
729
  const write = navigator.clipboard?.writeText(n.text);
package/dist/index.d.ts CHANGED
@@ -68,10 +68,23 @@ export interface ListItemNode {
68
68
  }
69
69
  export interface FormNode {
70
70
  type: "form";
71
- submitAction: ActionEvent;
71
+ /** The default submit action — renders an auto submit button + fires on
72
+ * Enter in a text field. OPTIONAL since 0.10.0 (#15): omit it for a form
73
+ * whose only triggers are `buttons[]`. When omitted, no default submit
74
+ * button renders and Enter does not submit at the form level (a
75
+ * FieldNode.action still fires per-field). */
76
+ submitAction?: ActionEvent;
72
77
  submitLabel?: string;
73
78
  /** Layout preset for the form's controls. Omitted or "stack" = fields stacked (current, no modifier class). "inline" = field row + submit on one line (add/search bar) — emits .vms-form--inline. Closed union (D-29). */
74
79
  layout?: "stack" | "inline";
80
+ /** Multi-action submit buttons (#15). Each is a full ButtonNode (so
81
+ * `variant` + `pendingLabel` apply) that, on activation, HARVESTS this
82
+ * form's current field values into its `action.context` and dispatches —
83
+ * the same harvest the default submit performs, but carrying a different
84
+ * action. Mirrors HTML's multiple submit buttons / `formaction`. A plain
85
+ * ButtonNode placed in `children` keeps its no-harvest behavior; only
86
+ * buttons in THIS slot harvest. */
87
+ buttons?: ButtonNode[];
75
88
  children: ViewNode[];
76
89
  }
77
90
  export interface FieldNode {
@@ -196,6 +209,11 @@ export interface CopyButtonNode {
196
209
  label?: string;
197
210
  /** Ephemeral label shown after a successful copy, reverts after ~1.5 s. Adapter default: "Copied!". */
198
211
  copiedLabel?: string;
212
+ /** Visual variant — mirrors ButtonNode.variant (issue #14). Adapter emits
213
+ * `vms-button vms-button--{variant}` (browser) / variant-tinted text
214
+ * (TUI), so a copy-button can read distinctly from neighboring default
215
+ * buttons. Closed union; omitted = current behavior (no modifier). */
216
+ variant?: "primary" | "secondary" | "danger";
199
217
  }
200
218
  export interface ShellOptions {
201
219
  endpoint: string;
package/dist/tui.js CHANGED
@@ -1019,7 +1019,13 @@ function CopyButtonView({ node, ctx }) {
1019
1019
  const label = isCopied
1020
1020
  ? (node.copiedLabel ?? "Copied!")
1021
1021
  : (node.label ?? "Copy");
1022
- return (_jsxs("text", { onMouseDown: () => ctx.copy(node.text), children: ["[ ", label, " ]"] }));
1022
+ // 0.9.0 (#14): variant coloring mirrors ButtonView's fg derivation
1023
+ // verbatim. Adapter-medium-adaptation parity: the browser uses CSS
1024
+ // classes; the TUI uses ANSI fg colors. Same semantic mapping.
1025
+ const fg = node.variant === "danger" ? "#ff5555"
1026
+ : node.variant === "primary" ? "#88aaff"
1027
+ : undefined;
1028
+ return (_jsxs("text", { ...(fg != null ? { fg } : {}), onMouseDown: () => ctx.copy(node.text), children: ["[ ", label, " ]"] }));
1023
1029
  }
1024
1030
  // ── form ──────────────────────────────────────────────────────────────────
1025
1031
  // Renders children + the submit button (decorative for B3 — Enter on any
@@ -1028,12 +1034,12 @@ function CopyButtonView({ node, ctx }) {
1028
1034
  // reads each from the adapter's fieldValues map (falling back to wire), and
1029
1035
  // dispatches submitAction with `{ [name]: value, ... }` merged into context.
1030
1036
  function FormView({ node, ctx }) {
1031
- // Snapshot the form for the closure same-instance fine since the closure
1032
- // is recreated on every render (which is every server response).
1033
- const submitThisForm = () => {
1034
- const merged = {
1035
- ...(node.submitAction.context ?? {}),
1036
- };
1037
+ // 0.10.0 (#15) harvest this form's current field values, merge into the
1038
+ // given action's context, and dispatch. Generalized from the single-submit
1039
+ // closure so the default submit AND each buttons[] entry can call it with
1040
+ // a DIFFERENT action carrying the SAME live field values.
1041
+ const submitFormWith = (base) => {
1042
+ const merged = { ...(base.context ?? {}) };
1037
1043
  const collect = (n) => {
1038
1044
  if (n.type === "field") {
1039
1045
  const wireValue = n.value ?? "";
@@ -1052,17 +1058,23 @@ function FormView({ node, ctx }) {
1052
1058
  };
1053
1059
  for (const child of node.children)
1054
1060
  collect(child);
1055
- ctx.onAction({ name: node.submitAction.name, context: merged });
1061
+ ctx.onAction({ name: base.name, context: merged });
1056
1062
  };
1063
+ // Enter-in-a-field submits the default action — only wired when present.
1064
+ const submitAction = node.submitAction;
1057
1065
  const childCtx = {
1058
1066
  ...ctx,
1059
1067
  isTopLevel: false,
1060
- submitForm: submitThisForm,
1068
+ submitForm: submitAction ? () => submitFormWith(submitAction) : null,
1061
1069
  };
1070
+ // buttons[] (#15) render through ButtonView so variant + pendingLabel work;
1071
+ // their onAction is wrapped to harvest the form first. pendingButtonKey
1072
+ // plumbing (0.8.0) flows through ctx unchanged.
1073
+ const buttonCtx = { ...childCtx, onAction: submitFormWith };
1062
1074
  // Layout preset on form: "stack" (default — fields stacked) or "inline"
1063
1075
  // (field row + submit on one line, the add-bar/search-bar pattern).
1064
1076
  const isInline = node.layout === "inline";
1065
- return (_jsxs("box", { flexDirection: isInline ? "row" : "column", gap: 1, children: [node.children.map((child, i) => renderNode(child, childCtx, i)), _jsxs("text", { attributes: 1 /* BOLD */, children: ["[ ", node.submitLabel ?? "Submit", " ]"] })] }));
1077
+ return (_jsxs("box", { flexDirection: isInline ? "row" : "column", gap: 1, children: [node.children.map((child, i) => renderNode(child, childCtx, i)), submitAction != null ? (_jsxs("text", { attributes: 1 /* BOLD */, children: ["[ ", node.submitLabel ?? "Submit", " ]"] })) : null, node.buttons && node.buttons.length > 0 ? (_jsx("box", { flexDirection: "row", gap: 2, children: node.buttons.map((btn, i) => (_jsx(ButtonView, { node: btn, ctx: buttonCtx }, `b${i}`))) })) : null] }));
1066
1078
  }
1067
1079
  // ── field ─────────────────────────────────────────────────────────────────
1068
1080
  // Real OpenTUI input/textarea/select wired to the adapter's field state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -231,6 +231,16 @@ body {
231
231
  flex-wrap: wrap;
232
232
  }
233
233
  .vms-form--inline > .vms-field { flex: 1 1 12rem; }
234
+ /* Multi-action submit buttons row (0.10.0 / #15) — groups the form's
235
+ buttons[] entries on one line, like a dialog action bar. align-self
236
+ flex-start keeps the row from stretching full-width in the stacked form. */
237
+ .vms-form__buttons {
238
+ display: flex;
239
+ flex-direction: row;
240
+ gap: var(--vms-space-sm);
241
+ align-self: flex-start;
242
+ flex-wrap: wrap;
243
+ }
234
244
  .vms-field { display: flex; flex-direction: column; gap: var(--vms-space-2xs); }
235
245
  .vms-field__label { font-size: var(--vms-text-sm); color: var(--vms-text-muted); }
236
246
  .vms-field__input {