@ashley-shrok/viewmodel-shell 3.0.1 → 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 +1 -0
- package/dist/browser.js +37 -5
- package/dist/index.d.ts +28 -1
- package/dist/tui.js +3 -2
- package/package.json +1 -1
- package/styles/default.css +26 -3
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
|
-
|
|
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 &&
|
|
536
|
-
const 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
|
|
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",
|
package/styles/default.css
CHANGED
|
@@ -61,6 +61,13 @@
|
|
|
61
61
|
--vms-text-lg: 1.125rem;
|
|
62
62
|
--vms-text-xl: 1.5rem;
|
|
63
63
|
--vms-text-2xl: 2.25rem;
|
|
64
|
+
/* Shared line-box height for the default-size form controls + buttons. An
|
|
65
|
+
ABSOLUTE value (not a font-relative ratio) so an input (--vms-text-md) and a
|
|
66
|
+
button (--vms-text-base) — which intentionally differ in font size — compute
|
|
67
|
+
to the SAME height when their padding + border match, and therefore line up
|
|
68
|
+
pixel-for-pixel in a filter/toolbar row. Size variants (.vms-button--sm/lg)
|
|
69
|
+
and compact icon buttons set their own line-height. */
|
|
70
|
+
--vms-control-line: 1.5rem;
|
|
64
71
|
/* Page shell (D-12) — additive override seam (issue #13); host/theme-retunable
|
|
65
72
|
via a single :root{} override imported after the theme. The two width
|
|
66
73
|
ratchets — --vms-page-max-wide (1440px default) and --vms-page-max-full
|
|
@@ -434,6 +441,7 @@ body {
|
|
|
434
441
|
color: var(--vms-text);
|
|
435
442
|
font-family: var(--vms-font-body);
|
|
436
443
|
font-size: var(--vms-text-md);
|
|
444
|
+
line-height: var(--vms-control-line);
|
|
437
445
|
padding: var(--vms-space-sm) var(--vms-space-md);
|
|
438
446
|
outline: none;
|
|
439
447
|
transition: border-color var(--vms-t), box-shadow var(--vms-t);
|
|
@@ -526,6 +534,7 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
526
534
|
cursor: pointer;
|
|
527
535
|
font-family: var(--vms-font-body);
|
|
528
536
|
font-size: var(--vms-text-base);
|
|
537
|
+
line-height: var(--vms-control-line);
|
|
529
538
|
padding: var(--vms-space-sm) var(--vms-space-md);
|
|
530
539
|
align-self: flex-start;
|
|
531
540
|
transition: background var(--vms-t), border-color var(--vms-t), transform var(--vms-t);
|
|
@@ -555,9 +564,20 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
555
564
|
}
|
|
556
565
|
.vms-button--secondary:hover { background: var(--vms-accent-glow); }
|
|
557
566
|
|
|
558
|
-
/* size — the ONLY axis that touches box metrics (md = base, no class).
|
|
559
|
-
|
|
560
|
-
|
|
567
|
+
/* size — the ONLY axis that touches box metrics (md = base, no class). Each size
|
|
568
|
+
sets its own line-box so sm stays compact and lg reads larger (the default-size
|
|
569
|
+
control line lives on the base rule + --vms-control-line). */
|
|
570
|
+
.vms-button--sm { padding: var(--vms-space-2xs) var(--vms-space-sm); font-size: var(--vms-text-sm); line-height: 1.125rem; }
|
|
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); }
|
|
561
581
|
|
|
562
582
|
/* ── Pending state (0.8.0 / issue #11) ──
|
|
563
583
|
Applied by BrowserAdapter when a ButtonNode with `pendingLabel` is clicked.
|
|
@@ -578,6 +598,9 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
578
598
|
color: var(--vms-text-muted);
|
|
579
599
|
opacity: 0;
|
|
580
600
|
padding: var(--vms-space-2xs) var(--vms-space-xs);
|
|
601
|
+
/* compact inline icon affordance — opt out of the standardized control line so
|
|
602
|
+
the row's delete-X stays small, not control-height. */
|
|
603
|
+
line-height: 1.25rem;
|
|
581
604
|
border-radius: var(--vms-radius-sm);
|
|
582
605
|
transition: opacity var(--vms-t), color var(--vms-t), background var(--vms-t);
|
|
583
606
|
}
|