@ashley-shrok/viewmodel-shell 3.3.0 → 3.4.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
@@ -60,6 +60,11 @@ export declare class BrowserAdapter implements Adapter {
60
60
  * When `action` is set, it fires on Enter (text-like) or change (select) —
61
61
  * the new value is already in state by that point. */
62
62
  private field;
63
+ /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
64
+ * render help + error text below it, wiring aria-describedby / aria-invalid.
65
+ * Runs on the main field path (the hidden + checkbox-FieldNode variants
66
+ * return before this). */
67
+ private decorateField;
63
68
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
64
69
  * write to state then dispatch the action name (if any). */
65
70
  private checkbox;
package/dist/browser.js CHANGED
@@ -772,8 +772,61 @@ export class BrowserAdapter {
772
772
  }
773
773
  wrapper.appendChild(inp);
774
774
  }
775
+ this.decorateField(wrapper, n);
775
776
  parent.appendChild(wrapper);
776
777
  }
778
+ /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
779
+ * render help + error text below it, wiring aria-describedby / aria-invalid.
780
+ * Runs on the main field path (the hidden + checkbox-FieldNode variants
781
+ * return before this). */
782
+ decorateField(wrapper, n) {
783
+ const control = wrapper.querySelector(".vms-field__input");
784
+ if (n.disabled) {
785
+ if (control)
786
+ control.disabled = true;
787
+ wrapper.classList.add("vms-field--disabled");
788
+ }
789
+ if (n.readonly && control && "readOnly" in control) {
790
+ control.readOnly = true;
791
+ }
792
+ // Native input constraints — min/max/step on <input>, maxLength on
793
+ // <input>/<textarea>. Strings pass straight to the attribute.
794
+ if (control instanceof HTMLInputElement) {
795
+ if (n.min != null)
796
+ control.min = n.min;
797
+ if (n.max != null)
798
+ control.max = n.max;
799
+ if (n.step != null)
800
+ control.step = n.step;
801
+ }
802
+ if (n.maxLength != null &&
803
+ (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) {
804
+ control.maxLength = n.maxLength;
805
+ }
806
+ const describedBy = [];
807
+ if (n.help != null && n.help !== "") {
808
+ const helpEl = document.createElement("div");
809
+ helpEl.className = "vms-field__help";
810
+ helpEl.id = `vms-${n.name}-help`;
811
+ helpEl.textContent = n.help;
812
+ wrapper.appendChild(helpEl);
813
+ describedBy.push(helpEl.id);
814
+ }
815
+ if (n.error != null && n.error !== "") {
816
+ wrapper.classList.add("vms-field--error");
817
+ const errEl = document.createElement("div");
818
+ errEl.className = "vms-field__error";
819
+ errEl.id = `vms-${n.name}-error`;
820
+ errEl.setAttribute("role", "alert");
821
+ errEl.textContent = n.error;
822
+ wrapper.appendChild(errEl);
823
+ describedBy.push(errEl.id);
824
+ control?.setAttribute("aria-invalid", "true");
825
+ }
826
+ if (control && describedBy.length > 0) {
827
+ control.setAttribute("aria-describedby", describedBy.join(" "));
828
+ }
829
+ }
777
830
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
778
831
  * write to state then dispatch the action name (if any). */
779
832
  checkbox(n, parent, on) {
@@ -809,9 +862,16 @@ export class BrowserAdapter {
809
862
  button(n, parent, on) {
810
863
  const btn = document.createElement("button");
811
864
  btn.type = "button";
812
- 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" : ""}`;
865
+ 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" : ""}${n.disabled ? " vms-button--disabled" : ""}`;
813
866
  btn.textContent = n.label;
867
+ if (n.disabled)
868
+ btn.disabled = true;
814
869
  btn.addEventListener("click", () => {
870
+ // Forms-completeness (3.4.0): a disabled button never dispatches. (Native
871
+ // `disabled` already suppresses the click, but guard anyway in case the
872
+ // attribute was cleared out-of-band.)
873
+ if (n.disabled)
874
+ return;
815
875
  // pendingLabel: instant client-side feedback. Swap text + add
816
876
  // .vms-button--pending BEFORE handing off to the dispatcher. On
817
877
  // success the next render replaces the button entirely. On dispatch
package/dist/index.d.ts CHANGED
@@ -241,6 +241,35 @@ export interface FieldNode {
241
241
  label?: string;
242
242
  placeholder?: string;
243
243
  required?: boolean;
244
+ /** Disables the control (greys it out, blocks input, excludes it from form
245
+ * submission). Server-owned. Emits `.vms-field--disabled` on the wrapper +
246
+ * the native `disabled` attribute. Omitted = enabled. */
247
+ disabled?: boolean;
248
+ /** Read-only: the value is shown and submitted but the user can't edit it
249
+ * (distinct from `disabled`, which also greys out + excludes from submit).
250
+ * Text-like inputs + textarea only. Emits the native `readonly` attribute. */
251
+ readonly?: boolean;
252
+ /** Per-field validation error message, rendered inline below the control as
253
+ * `.vms-field__error` (role="alert"); also sets the wrapper's
254
+ * `.vms-field--error`, the control's `aria-invalid="true"`, and wires the
255
+ * message into `aria-describedby`. The view-side complement to the
256
+ * response-level `rejected` channel — set it on the offending field when you
257
+ * build the tree. Omitted = no error shown. */
258
+ error?: string;
259
+ /** Hint/help text rendered below the control as `.vms-field__help` and wired
260
+ * into the control's `aria-describedby`. Omitted = no hint. */
261
+ help?: string;
262
+ /** `min`/`max`/`step` passed through to the native input attribute for
263
+ * `number`/`range`/date-time inputs. STRINGS (HTML-attribute semantics): a
264
+ * numeric bound (`"0"`), a date bound (`"2020-01-01"`), and `step` also
265
+ * accepts `"any"`. Typed as strings (not numbers) so the wire stays
266
+ * byte-identical across backends. Omitted = no constraint. */
267
+ min?: string;
268
+ max?: string;
269
+ step?: string;
270
+ /** Maximum input length (characters) for text-like inputs + textarea →
271
+ * native `maxlength`. Integer. Omitted = no cap. */
272
+ maxLength?: number;
244
273
  options?: Array<{
245
274
  value: string;
246
275
  label: string;
@@ -279,6 +308,10 @@ export interface ButtonNode {
279
308
  * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
280
309
  * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
281
310
  width?: "auto" | "full";
311
+ /** Disables the button — greys it out (`.vms-button--disabled` + native
312
+ * `disabled`) and the renderer will NOT dispatch its action on click.
313
+ * Server-owned (e.g. a submit gated on a precondition). Omitted = enabled. */
314
+ disabled?: boolean;
282
315
  /** Transient label shown from click until the dispatch resolves (response
283
316
  * arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
284
317
  * lifecycle pattern at a different beat: shown DURING the round-trip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.3.0",
3
+ "version": "3.4.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",
@@ -494,6 +494,27 @@ body {
494
494
  textarea.vms-field__input { min-height: 80px; }
495
495
  select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl); }
496
496
 
497
+ /* Forms-completeness (3.4.0) — help/error text + disabled/readonly/error states.
498
+ help is the muted hint; error is the danger message (role="alert"). */
499
+ .vms-field__help { font-size: var(--vms-text-sm); color: var(--vms-text-muted); }
500
+ .vms-field__error { font-size: var(--vms-text-sm); color: var(--vms-error); }
501
+ .vms-field--error .vms-field__input { border-color: var(--vms-error); }
502
+ .vms-field--error .vms-field__input:focus {
503
+ border-color: var(--vms-error);
504
+ box-shadow: 0 0 0 3px var(--vms-error-glow);
505
+ }
506
+ .vms-field__input:disabled,
507
+ .vms-field--disabled .vms-field__input {
508
+ opacity: 0.55;
509
+ cursor: not-allowed;
510
+ }
511
+ .vms-field--disabled .vms-field__label { opacity: 0.7; }
512
+ /* read-only: editable-looking but visibly inert background (not greyed like disabled). */
513
+ .vms-field__input:read-only:not([type="file"]) {
514
+ background: color-mix(in srgb, var(--vms-surface) 70%, var(--vms-border));
515
+ cursor: default;
516
+ }
517
+
497
518
  /* Code editor field — monospaced, tab-aware, no spell-check chrome.
498
519
  Apps add syntax highlighting via .vms-field--code-{language} hooks. */
499
520
  .vms-field__input--code {
@@ -617,6 +638,14 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
617
638
  pointer-events: none;
618
639
  }
619
640
 
641
+ /* Forms-completeness (3.4.0) — server-declared disabled button. */
642
+ .vms-button:disabled,
643
+ .vms-button--disabled {
644
+ opacity: 0.5;
645
+ cursor: not-allowed;
646
+ pointer-events: none;
647
+ }
648
+
620
649
  /* Inside a list item, a danger button is treated as the row's "X" affordance:
621
650
  muted and hidden until the row is hovered, then visible and red on hover. */
622
651
  .vms-list-item .vms-button--danger {