@ashley-shrok/viewmodel-shell 6.3.0 → 6.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.js CHANGED
@@ -3025,6 +3025,45 @@ export class BrowserAdapter {
3025
3025
  if (tableHasCheckboxes) {
3026
3026
  const selTh = document.createElement("th");
3027
3027
  selTh.className = "vms-table__th vms-table__th--select";
3028
+ // Header "select all rendered rows" affordance. A pure client-side toggle
3029
+ // over the leading-column checkboxes: it writes the SAME per-row binds the
3030
+ // row checkboxes use, so the server learns the selection through the exact
3031
+ // path it already knows — no new wire field, and agents (which set binds
3032
+ // directly) never need it. Scope is deliberately "all RENDERED rows": under
3033
+ // filter-narrow that equals all matches; under pagination it is the current
3034
+ // page (selection accumulates via the round-tripped binds); over-cap /
3035
+ // zero-match render no rows, so the control below simply is not drawn and
3036
+ // can never claim to select rows that are not on screen. This re-adds ONLY
3037
+ // the DOM toggle, never the per-toggle dispatch that got the old
3038
+ // TableNode.selection seam removed in 0.15.0.
3039
+ const rowCheckboxes = n.rows.flatMap(r => (r.actions ?? []).filter((e) => e.type === "checkbox"));
3040
+ if (rowCheckboxes.length > 0) {
3041
+ const lbl = document.createElement("label");
3042
+ lbl.className = "vms-checkbox vms-table__select-all";
3043
+ const inp = document.createElement("input");
3044
+ inp.type = "checkbox";
3045
+ inp.className = "vms-checkbox__input";
3046
+ inp.setAttribute("aria-label", "Select all rows");
3047
+ const states = rowCheckboxes.map(cb => Boolean(this.sa.read(cb.bind)));
3048
+ const allChecked = states.every(Boolean);
3049
+ inp.checked = allChecked;
3050
+ inp.indeterminate = !allChecked && states.some(Boolean);
3051
+ const mark = document.createElement("span");
3052
+ mark.className = "vms-checkbox__mark";
3053
+ inp.addEventListener("change", () => {
3054
+ const target = inp.checked;
3055
+ for (const cb of rowCheckboxes) {
3056
+ this.sa.write(cb.bind, target);
3057
+ const rowInp = document.getElementById(`vms-checkbox-${cb.name}`);
3058
+ if (rowInp)
3059
+ rowInp.checked = target;
3060
+ }
3061
+ inp.indeterminate = false;
3062
+ });
3063
+ lbl.appendChild(inp);
3064
+ lbl.appendChild(mark);
3065
+ selTh.appendChild(lbl);
3066
+ }
3028
3067
  headerRow.appendChild(selTh);
3029
3068
  }
3030
3069
  const sortIntent = (n.sortBind != null ? this.sa.read(n.sortBind) : null);
@@ -3198,6 +3237,34 @@ export class BrowserAdapter {
3198
3237
  });
3199
3238
  table.appendChild(tbody);
3200
3239
  wrapper.appendChild(table);
3240
+ // Visible-scoped bulk-action toolbar (n.selection). Rendered ABOVE the table.
3241
+ // Each button, on click, harvests the currently-CHECKED, currently-RENDERED
3242
+ // rows (by walking tbody for a checked leading-column checkbox and reading the
3243
+ // row's data-id) and writes that id array to n.selection.harvestBind —
3244
+ // OVERWRITING — before dispatching. So the server only ever sees rows the user
3245
+ // can currently see; a row selected then filtered/paginated out of view is not
3246
+ // harvested. This revives the old selection.buttons[] harvest (removed with the
3247
+ // `context` wire in Phase 6), adapted to write a BIND instead of context. It
3248
+ // adds NONE of the per-toggle dispatch that got the 0.15.0 selection.action
3249
+ // seam removed — selection stays a pure client concern until a bulk click.
3250
+ if (n.selection && n.selection.buttons.length > 0) {
3251
+ const sel = n.selection;
3252
+ const toolbar = document.createElement("div");
3253
+ toolbar.className = "vms-table__bulk-actions";
3254
+ const harvest = (action) => {
3255
+ const ids = [];
3256
+ tbody.querySelectorAll("tr").forEach(tr => {
3257
+ const box = tr.querySelector(".vms-table__td--select input.vms-checkbox__input");
3258
+ if (box?.checked && tr.dataset.id)
3259
+ ids.push(tr.dataset.id);
3260
+ });
3261
+ this.sa.write(sel.harvestBind, ids);
3262
+ on({ name: action.name });
3263
+ };
3264
+ for (const btn of sel.buttons)
3265
+ this.button(btn, toolbar, harvest);
3266
+ wrapper.insertBefore(toolbar, table);
3267
+ }
3201
3268
  if (n.pagination) {
3202
3269
  const pg = n.pagination;
3203
3270
  const footer = document.createElement("div");
package/dist/index.d.ts CHANGED
@@ -868,6 +868,31 @@ export interface TableNode {
868
868
  * filter dispatches reset `page` to 1 on the server side, since
869
869
  * the row window changes underneath them. */
870
870
  pagination?: TablePagination;
871
+ /** Visible-scoped bulk-action toolbar (opt-in). When set, the adapter renders
872
+ * `selection.buttons[]` ABOVE the table; each button, on click, harvests the
873
+ * currently-CHECKED, currently-RENDERED row ids (from the leading-column
874
+ * per-row checkboxes) and writes them — a `string[]` of `TableRow.id` — to
875
+ * `selection.harvestBind`, OVERWRITING, before dispatching its own action.
876
+ * The server reads that path to act, so a bulk action can only ever affect
877
+ * rows the user can currently see: a row selected under one filter and then
878
+ * filtered/paginated out of view is NOT harvested. This is the safe default
879
+ * for the common case; an app that genuinely wants cross-page/persistent
880
+ * selection simply ignores this block and reads its own bound `selectedIds`
881
+ * map instead (the framework never introspects app state, so that escape
882
+ * hatch is always open). Selectable rows must carry `TableRow.id`. */
883
+ selection?: TableSelection;
884
+ }
885
+ /** Visible-scoped bulk-action toolbar for a `TableNode` — see `TableNode.selection`. */
886
+ export interface TableSelection {
887
+ /** Bulk-action buttons rendered above the table. On click, each harvests the
888
+ * visible-checked row ids into `harvestBind` (overwriting) and dispatches its
889
+ * own `action` — name-only, per the Phase-6 wire. Full ButtonNodes, so they
890
+ * carry their own emphasis/tone/confirm/disabled. */
891
+ buttons: ButtonNode[];
892
+ /** State path the harvest writes the visible-checked row-id array (`string[]`)
893
+ * to right before dispatch. The server reads THIS to act — not a per-row
894
+ * `selectedIds` map — which is what makes the action visible-scoped. */
895
+ harvestBind: string;
871
896
  }
872
897
  export interface CopyButtonNode {
873
898
  type: "copy-button";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "6.3.0",
3
+ "version": "6.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",
@@ -884,6 +884,14 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
884
884
  border-left: none;
885
885
  transform: rotate(40deg);
886
886
  }
887
+ .vms-checkbox__input:indeterminate + .vms-checkbox__mark { background: var(--vms-accent); border-color: var(--vms-accent); }
888
+ .vms-checkbox__input:indeterminate + .vms-checkbox__mark::after {
889
+ content: '';
890
+ position: absolute;
891
+ left: 3px; top: 7px;
892
+ width: 10px; height: 0;
893
+ border-top: 2px solid #fff;
894
+ }
887
895
  .vms-checkbox__label { margin-left: var(--vms-space-xs); font-size: var(--vms-text-base); }
888
896
 
889
897
  /* ── Buttons — three orthogonal axes: emphasis (fill) × tone (color) × size