@ashley-shrok/viewmodel-shell 0.9.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) {
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 {
package/dist/tui.js CHANGED
@@ -1034,12 +1034,12 @@ function CopyButtonView({ node, ctx }) {
1034
1034
  // reads each from the adapter's fieldValues map (falling back to wire), and
1035
1035
  // dispatches submitAction with `{ [name]: value, ... }` merged into context.
1036
1036
  function FormView({ node, ctx }) {
1037
- // Snapshot the form for the closure same-instance fine since the closure
1038
- // is recreated on every render (which is every server response).
1039
- const submitThisForm = () => {
1040
- const merged = {
1041
- ...(node.submitAction.context ?? {}),
1042
- };
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 ?? {}) };
1043
1043
  const collect = (n) => {
1044
1044
  if (n.type === "field") {
1045
1045
  const wireValue = n.value ?? "";
@@ -1058,17 +1058,23 @@ function FormView({ node, ctx }) {
1058
1058
  };
1059
1059
  for (const child of node.children)
1060
1060
  collect(child);
1061
- ctx.onAction({ name: node.submitAction.name, context: merged });
1061
+ ctx.onAction({ name: base.name, context: merged });
1062
1062
  };
1063
+ // Enter-in-a-field submits the default action — only wired when present.
1064
+ const submitAction = node.submitAction;
1063
1065
  const childCtx = {
1064
1066
  ...ctx,
1065
1067
  isTopLevel: false,
1066
- submitForm: submitThisForm,
1068
+ submitForm: submitAction ? () => submitFormWith(submitAction) : null,
1067
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 };
1068
1074
  // Layout preset on form: "stack" (default — fields stacked) or "inline"
1069
1075
  // (field row + submit on one line, the add-bar/search-bar pattern).
1070
1076
  const isInline = node.layout === "inline";
1071
- 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] }));
1072
1078
  }
1073
1079
  // ── field ─────────────────────────────────────────────────────────────────
1074
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.9.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 {