@ashley-shrok/viewmodel-shell 3.0.2 → 3.1.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 CHANGED
@@ -66,6 +66,7 @@ export declare class BrowserAdapter implements Adapter {
66
66
  private button;
67
67
  private text;
68
68
  private link;
69
+ private divider;
69
70
  private statBar;
70
71
  /** TabsNode — on click, write tab.value to state at node.bind, then dispatch
71
72
  * the tab's own action name. */
package/dist/browser.js CHANGED
@@ -238,6 +238,7 @@ export class BrowserAdapter {
238
238
  case "modal": return this.modal(n, parent, on);
239
239
  case "table": return this.table(n, parent, on);
240
240
  case "copy-button": return this.copyButton(n, parent);
241
+ case "divider": return this.divider(n, parent);
241
242
  case "fits": return this.fits(n, parent, on);
242
243
  }
243
244
  }
@@ -510,7 +511,24 @@ export class BrowserAdapter {
510
511
  ev.files = files;
511
512
  on(ev);
512
513
  };
513
- if (n.submitAction) {
514
+ // #22 — submitButton takes precedence: the form renders the consumer's own
515
+ // button (its label + emphasis/tone/size/width) as the submit and fires its
516
+ // action; submitLabel/submitAction for the implicit button are then ignored.
517
+ const sb = n.submitButton;
518
+ const effectiveSubmit = sb ? sb.action : n.submitAction;
519
+ if (sb) {
520
+ const submitAction = sb.action;
521
+ const submit = document.createElement("button");
522
+ submit.type = "submit";
523
+ submit.className = `vms-button${sb.emphasis ? ` vms-button--${sb.emphasis}` : ""}${sb.tone ? ` vms-button--${sb.tone}` : ""}${sb.size ? ` vms-button--${sb.size}` : ""}${sb.width === "full" ? " vms-button--full" : ""}`;
524
+ submit.textContent = sb.label;
525
+ form.appendChild(submit);
526
+ form.addEventListener("submit", (e) => {
527
+ e.preventDefault();
528
+ dispatchWithFiles(submitAction);
529
+ });
530
+ }
531
+ else if (n.submitAction) {
514
532
  const submitAction = n.submitAction;
515
533
  const submit = document.createElement("button");
516
534
  submit.type = "submit";
@@ -532,8 +550,8 @@ export class BrowserAdapter {
532
550
  // never submits). Modifier-Enter falls through to a normal newline, and an
533
551
  // IME composition Enter (candidate confirmation) must NOT submit. No-op
534
552
  // when submitAction is absent. Same dispatch path as the submit button.
535
- if (n.submitOnEnter && n.submitAction) {
536
- const submitAction = n.submitAction;
553
+ if (n.submitOnEnter && effectiveSubmit) {
554
+ const submitAction = effectiveSubmit;
537
555
  form.querySelectorAll("textarea").forEach(ta => {
538
556
  ta.addEventListener("keydown", (e) => {
539
557
  if (e.key !== "Enter")
@@ -774,7 +792,7 @@ export class BrowserAdapter {
774
792
  button(n, parent, on) {
775
793
  const btn = document.createElement("button");
776
794
  btn.type = "button";
777
- btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}`;
795
+ btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
778
796
  btn.textContent = n.label;
779
797
  btn.addEventListener("click", () => {
780
798
  // pendingLabel: instant client-side feedback. Swap text + add
@@ -808,6 +826,20 @@ export class BrowserAdapter {
808
826
  }
809
827
  parent.appendChild(a);
810
828
  }
829
+ divider(n, parent) {
830
+ if (n.orientation === "vertical") {
831
+ // <hr> is semantically horizontal; a vertical rule is a separator div.
832
+ const el = document.createElement("div");
833
+ el.className = "vms-divider vms-divider--vertical";
834
+ el.setAttribute("role", "separator");
835
+ el.setAttribute("aria-orientation", "vertical");
836
+ parent.appendChild(el);
837
+ return;
838
+ }
839
+ const hr = document.createElement("hr"); // implicit role="separator"
840
+ hr.className = "vms-divider";
841
+ parent.appendChild(hr);
842
+ }
811
843
  statBar(n, parent) {
812
844
  const bar = document.createElement("div");
813
845
  bar.className = "vms-stat-bar";
@@ -1142,7 +1174,7 @@ export class BrowserAdapter {
1142
1174
  copyButton(n, parent) {
1143
1175
  const btn = document.createElement("button");
1144
1176
  btn.type = "button";
1145
- btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}`;
1177
+ btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
1146
1178
  btn.textContent = n.label ?? "Copy";
1147
1179
  btn.addEventListener("click", () => {
1148
1180
  const write = navigator.clipboard?.writeText(n.text);
package/dist/index.d.ts CHANGED
@@ -63,7 +63,7 @@ export interface Adapter {
63
63
  * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
64
64
  setBusy?(active: boolean): void;
65
65
  }
66
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | FitsNode;
66
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode;
67
67
  export interface PageNode {
68
68
  type: "page";
69
69
  title?: string;
@@ -203,6 +203,15 @@ export interface FormNode {
203
203
  * FieldNode.action still fires per-field). */
204
204
  submitAction?: ActionEvent;
205
205
  submitLabel?: string;
206
+ /** Full control of the submit button (#22). When set, the form renders THIS
207
+ * button as its submit — carrying its own `label` + `emphasis`/`tone`/`size`/
208
+ * `width`/`pendingLabel` — instead of synthesizing a plain one from
209
+ * `submitLabel`. The form fires `submitButton.action` on click, on native
210
+ * submit (Enter in a text field), and on textarea Enter (`submitOnEnter`).
211
+ * Mirrors the universal "write your own submit button" pattern (e.g. a
212
+ * full-width submit: `width:"full"`). Takes precedence over
213
+ * `submitLabel`/`submitAction`, which are ignored for the button when set. */
214
+ submitButton?: ButtonNode;
206
215
  /** 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). */
207
216
  layout?: "stack" | "inline";
208
217
  /** Multi-action submit buttons (#15). Each is a full ButtonNode (so
@@ -261,6 +270,11 @@ export interface ButtonNode {
261
270
  tone?: "danger" | "warning" | "success" | "info";
262
271
  /** Box geometry (padding + font), orthogonal to color/emphasis — the one axis no design system bakes into variant. Emits .vms-button--{size}. Omitted = the default (md) size. Closed union. */
263
272
  size?: "sm" | "lg";
273
+ /** Width axis — `"full"` stretches the button to fill its container's cross
274
+ * axis (the standard full-width / "block" button: MUI `fullWidth`, Ant
275
+ * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
276
+ * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
277
+ width?: "auto" | "full";
264
278
  /** Transient label shown from click until the dispatch resolves (response
265
279
  * arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
266
280
  * lifecycle pattern at a different beat: shown DURING the round-trip
@@ -431,6 +445,11 @@ export interface CopyButtonNode {
431
445
  tone?: "danger" | "warning" | "success" | "info";
432
446
  /** Box geometry — mirrors ButtonNode.size. Emits .vms-button--{size}. Omitted = md. Closed union. */
433
447
  size?: "sm" | "lg";
448
+ /** Width axis — `"full"` stretches the button to fill its container's cross
449
+ * axis (the standard full-width / "block" button: MUI `fullWidth`, Ant
450
+ * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
451
+ * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
452
+ width?: "auto" | "full";
434
453
  }
435
454
  /**
436
455
  * The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
@@ -463,6 +482,14 @@ export interface CopyButtonNode {
463
482
  * meaningful. For list/detail and similar text panes use `split` / `sidebar`,
464
483
  * which collapse to a single column intrinsically on their own (zero @media).
465
484
  */
485
+ /** A thematic break / separator (#22) — the standard divider primitive (MUI/Ant
486
+ * `<Divider>`, Radix `<Separator>`). Horizontal (default) renders an `<hr>` with
487
+ * its implicit `role="separator"`; vertical renders a `role="separator"` div with
488
+ * `aria-orientation="vertical"` for row layouts. No content. */
489
+ export interface DividerNode {
490
+ type: "divider";
491
+ orientation?: "horizontal" | "vertical";
492
+ }
466
493
  export interface FitsNode {
467
494
  type: "fits";
468
495
  /** Axis on which the container's fit is tested. CLOSED union; OMITTED =
package/dist/tui.js CHANGED
@@ -750,6 +750,7 @@ function renderNode(node, ctx, key) {
750
750
  case "stat-bar": return _jsx(StatBarView, { node: node }, key);
751
751
  case "modal": return _jsx(ModalView, { node: node, ctx: ctx }, key);
752
752
  case "copy-button": return _jsx(CopyButtonView, { node: node, ctx: ctx }, key);
753
+ case "divider": return _jsx("text", { fg: "#555555", children: node.orientation === "vertical" ? "│" : "─".repeat(40) }, key);
753
754
  case "form": return _jsx(FormView, { node: node, ctx: ctx }, key);
754
755
  case "field": return _jsx(FieldView, { node: node, ctx: ctx }, key);
755
756
  case "fits": {
@@ -1158,7 +1159,7 @@ function FormView({ node, ctx }) {
1158
1159
  ctx.onAction({ name: base.name });
1159
1160
  };
1160
1161
  // Enter-in-a-field submits the default action — only wired when present.
1161
- const submitAction = node.submitAction;
1162
+ const submitAction = node.submitButton ? node.submitButton.action : node.submitAction;
1162
1163
  const childCtx = {
1163
1164
  ...ctx,
1164
1165
  isTopLevel: false,
@@ -1171,7 +1172,7 @@ function FormView({ node, ctx }) {
1171
1172
  // Layout preset on form: "stack" (default — fields stacked) or "inline"
1172
1173
  // (field row + submit on one line, the add-bar/search-bar pattern).
1173
1174
  const isInline = node.layout === "inline";
1174
- 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] }));
1175
+ 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.submitButton ? node.submitButton.label : (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] }));
1175
1176
  }
1176
1177
  // ── field ─────────────────────────────────────────────────────────────────
1177
1178
  // 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": "3.0.2",
3
+ "version": "3.1.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",
@@ -569,6 +569,15 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
569
569
  control line lives on the base rule + --vms-control-line). */
570
570
  .vms-button--sm { padding: var(--vms-space-2xs) var(--vms-space-sm); font-size: var(--vms-text-sm); line-height: 1.125rem; }
571
571
  .vms-button--lg { padding: var(--vms-space-sm) var(--vms-space-lg); font-size: var(--vms-text-lg); line-height: 1.75rem; }
572
+ /* width — the standard full-width / "block" button (#22). Stretches to fill the
573
+ container's cross axis (overriding the base align-self:flex-start), so a form
574
+ submit lines up with a column of width:100% inputs. */
575
+ .vms-button--full { align-self: stretch; width: 100%; }
576
+
577
+ /* ── Divider / separator (#22) — the standard thematic break. Horizontal is an
578
+ <hr>; vertical is a role="separator" div for row layouts. ── */
579
+ .vms-divider { border: none; border-top: 1px solid var(--vms-border); width: 100%; margin-block: var(--vms-space-sm); }
580
+ .vms-divider--vertical { border-top: none; border-left: 1px solid var(--vms-border); width: 0; align-self: stretch; margin: 0 var(--vms-space-sm); }
572
581
 
573
582
  /* ── Pending state (0.8.0 / issue #11) ──
574
583
  Applied by BrowserAdapter when a ButtonNode with `pendingLabel` is clicked.