@ashley-shrok/viewmodel-shell 0.12.0 → 0.13.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
@@ -651,8 +651,26 @@ export class BrowserAdapter {
651
651
  box.className = "vms-table__select vms-table__select--all";
652
652
  box.checked = allOnPage;
653
653
  box.indeterminate = someOnPage && !allOnPage;
654
- const selAction = sel.action;
655
- box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } }));
654
+ box.addEventListener("change", () => {
655
+ const selAction = sel.action;
656
+ if (selAction) {
657
+ // Server-truth mode: dispatch; server re-renders with new selectedIds.
658
+ on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } });
659
+ }
660
+ else {
661
+ // Local mode (0.13.0): toggle every row checkbox + class in this table
662
+ // to match the header. No dispatch — the dispatch guard can't drop anything.
663
+ const want = box.checked;
664
+ table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
665
+ if (rowBox.disabled)
666
+ return;
667
+ rowBox.checked = want;
668
+ const tr = rowBox.closest(".vms-table__row");
669
+ if (tr)
670
+ tr.classList.toggle("vms-table__row--selected", want);
671
+ });
672
+ }
673
+ });
656
674
  th.appendChild(box);
657
675
  headerRow.appendChild(th);
658
676
  }
@@ -740,8 +758,28 @@ export class BrowserAdapter {
740
758
  box.checked = isSelected;
741
759
  if (row.id != null) {
742
760
  const rowId = row.id;
743
- const selAction = sel.action;
744
- box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), id: rowId, checked: box.checked } }));
761
+ // data-id is what selection.buttons[] harvest reads on click.
762
+ box.dataset.id = rowId;
763
+ box.addEventListener("change", () => {
764
+ const selAction = sel.action;
765
+ if (selAction) {
766
+ on({ name: selAction.name, context: { ...(selAction.context ?? {}), id: rowId, checked: box.checked } });
767
+ }
768
+ else {
769
+ // Local mode (0.13.0): flip the row class to mirror the box, then
770
+ // reconcile the header select-all (could now be all / some / none).
771
+ tr.classList.toggle("vms-table__row--selected", box.checked);
772
+ const headerBox = table.querySelector("thead input.vms-table__select--all");
773
+ if (headerBox) {
774
+ const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
775
+ let checked = 0;
776
+ all.forEach(b => { if (b.checked)
777
+ checked++; });
778
+ headerBox.checked = all.length > 0 && checked === all.length;
779
+ headerBox.indeterminate = checked > 0 && checked < all.length;
780
+ }
781
+ }
782
+ });
745
783
  }
746
784
  else {
747
785
  box.disabled = true; // selection addresses rows by id; a row without one can't be selected
@@ -773,6 +811,25 @@ export class BrowserAdapter {
773
811
  });
774
812
  table.appendChild(tbody);
775
813
  wrapper.appendChild(table);
814
+ // 0.13.0 — bulk-action toolbar rendered ABOVE the table. Each button's
815
+ // click harvests the table's currently-checked row ids and merges them as
816
+ // `selectedIds` into the action's context. Works with both server-truth
817
+ // mode (the DOM mirrors selectedIds so the harvest matches state) and
818
+ // local mode (the DOM is the only source of selection truth).
819
+ if (sel?.buttons && sel.buttons.length > 0) {
820
+ const toolbar = document.createElement("div");
821
+ toolbar.className = "vms-table__bulk-actions";
822
+ const harvest = (action) => {
823
+ const ids = [];
824
+ table.querySelectorAll("tbody input.vms-table__select:checked").forEach(b => {
825
+ if (b.dataset.id)
826
+ ids.push(b.dataset.id);
827
+ });
828
+ on({ name: action.name, context: { ...(action.context ?? {}), selectedIds: ids } });
829
+ };
830
+ sel.buttons.forEach(btn => this.button(btn, toolbar, harvest));
831
+ wrapper.insertBefore(toolbar, table);
832
+ }
776
833
  if (n.pagination) {
777
834
  const pg = n.pagination;
778
835
  const footer = document.createElement("div");
package/dist/index.d.ts CHANGED
@@ -203,18 +203,32 @@ export interface TableRow {
203
203
  variant?: string;
204
204
  }
205
205
  export interface TableSelection {
206
- /** Row ids currently selected server-truth. The adapter checks each row
207
- * whose `id` is in this list and emits `.vms-table__row--selected` on it.
208
- * Selection survives sort/filter/pagination because it round-trips in state,
209
- * independent of which rows are currently in `rows`. */
206
+ /** Row ids that should render PRE-SELECTED on this render. In server-truth
207
+ * mode (`action` set) this is the live selection, round-tripped in state and
208
+ * authoritative every render. In local mode (`action` omitted) this is the
209
+ * server's initial pre-selection only subsequent toggles are purely
210
+ * client-side DOM state and the server doesn't see them until a `buttons[]`
211
+ * click harvests them. */
210
212
  selectedIds: string[];
211
- /** Dispatched on a selection toggle. The adapter merges `{ id, checked }` for
212
- * a per-row checkbox, or `{ all: true, checked }` for the header select-all
213
- * checkbox (where "all" means the rows currently rendered i.e. the current
214
- * page, never unloaded rows). The "select all N matching" affordance, when an
215
- * app wants it, is the app's own node composed above the table — the
216
- * framework gives the primitive, not the policy. */
217
- action: ActionEvent;
213
+ /** OPTIONAL (0.13.0). When present: server-truth mode every checkbox toggle
214
+ * dispatches this action with merged `{ id, checked }` per row or
215
+ * `{ all: true, checked }` for the header select-all (where "all" = the
216
+ * rendered page). Selection survives sort/filter/pagination via the state
217
+ * round-trip. When OMITTED: local mode the adapter toggles the DOM
218
+ * checkbox + `.vms-table__row--selected` class purely client-side with no
219
+ * dispatch. Local mode is the recommended pattern for rapid-selection
220
+ * workflows (no dropped clicks under the dispatch guard); see `buttons` for
221
+ * how bulk actions read the resulting selection. */
222
+ action?: ActionEvent;
223
+ /** OPTIONAL (0.13.0). When present, the adapter renders these as a bulk-action
224
+ * toolbar ABOVE the table. On click, each button harvests the currently
225
+ * checked rows from the DOM and dispatches its `action` with
226
+ * `{ selectedIds: [...] }` merged into its `context`. Designed primarily to
227
+ * pair with local mode (`action` absent) — it's how the server learns the
228
+ * selection without a per-toggle round-trip — but works in server-truth mode
229
+ * too (the harvest matches `selectedIds` since the DOM reflects server-truth
230
+ * after each render). */
231
+ buttons?: ButtonNode[];
218
232
  }
219
233
  export interface TablePagination {
220
234
  /** 1-based current page. */
package/dist/tui.js CHANGED
@@ -915,24 +915,42 @@ function TableView({ node, ctx }) {
915
915
  });
916
916
  };
917
917
  // Selection — leading [x]/[ ] column. Dispatch payloads match BrowserAdapter:
918
- // { id, checked } per row, { all: true, checked } for select-all.
918
+ // { id, checked } per row, { all: true, checked } for select-all (server-truth
919
+ // mode). 0.13.0: if `action` is omitted, this is LOCAL mode. The TUI renders
920
+ // checkboxes from `selectedIds` (the server's pre-selection); clicks are
921
+ // inert because the TUI doesn't track DOM-equivalent state across the
922
+ // hook-less conformance walker. The browser carries the real local-mode
923
+ // workflow; TUI is experimental — use the browser for interactive bulk
924
+ // selection. The selection.buttons[] toolbar still renders below.
919
925
  const sel = node.selection;
920
- const selectedSet = sel ? new Set(sel.selectedIds) : null;
926
+ const effectiveSet = sel ? new Set(sel.selectedIds) : null;
921
927
  const allOnPage = sel != null && node.rows.length > 0 &&
922
- node.rows.every((r) => r.id != null && selectedSet.has(r.id));
923
- const onToggleAll = sel
928
+ node.rows.every((r) => r.id != null && effectiveSet.has(r.id));
929
+ const onToggleAll = sel?.action
924
930
  ? () => ctx.onAction({
925
931
  name: sel.action.name,
926
932
  context: { ...(sel.action.context ?? {}), all: true, checked: !allOnPage },
927
933
  })
928
934
  : undefined;
929
- const onToggleRow = sel
935
+ const onToggleRow = sel?.action
930
936
  ? (rowId, checked) => ctx.onAction({
931
937
  name: sel.action.name,
932
938
  context: { ...(sel.action.context ?? {}), id: rowId, checked },
933
939
  })
934
940
  : undefined;
935
- return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [_jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, ...(onToggleAll ? { onMouseDown: onToggleAll } : {}), children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
941
+ return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [sel?.buttons && sel.buttons.length > 0 ? (_jsx("box", { flexDirection: "row", gap: 1, children: sel.buttons.map((btn, i) => {
942
+ const harvestCtx = {
943
+ ...ctx,
944
+ onAction: (action) => {
945
+ const ids = [...effectiveSet];
946
+ ctx.onAction({
947
+ name: action.name,
948
+ context: { ...(action.context ?? {}), selectedIds: ids },
949
+ });
950
+ },
951
+ };
952
+ return _jsx(ButtonView, { node: btn, ctx: harvestCtx }, i);
953
+ }) })) : null, _jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, ...(onToggleAll ? { onMouseDown: onToggleAll } : {}), children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
936
954
  const isSorted = node.sortColumn === c.key;
937
955
  const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
938
956
  // B5 — only sortable headers respond to clicks (matches BrowserAdapter).
@@ -945,7 +963,7 @@ function TableView({ node, ctx }) {
945
963
  ? () => ctx.onAction(row.action)
946
964
  : undefined;
947
965
  return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
948
- const isSel = row.id != null && selectedSet.has(row.id);
966
+ const isSel = row.id != null && effectiveSet.has(row.id);
949
967
  const rowId = row.id;
950
968
  const onBox = rowId != null && onToggleRow
951
969
  ? () => onToggleRow(rowId, !isSel)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -624,6 +624,17 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
624
624
  .vms-table__row--selected { background: color-mix(in srgb, var(--vms-accent) 12%, transparent); }
625
625
  .vms-table__row--selected.vms-table__row--clickable:hover { background: color-mix(in srgb, var(--vms-accent) 18%, transparent); }
626
626
 
627
+ /* 0.13.0 — bulk-action toolbar rendered ABOVE the table (when
628
+ TableNode.selection.buttons[] is set). Reuses .vms-button styling; only
629
+ layout is added — flex row, wrap on narrow, gap from the spacing rhythm. */
630
+ .vms-table__bulk-actions {
631
+ display: flex;
632
+ flex-direction: row;
633
+ flex-wrap: wrap;
634
+ gap: var(--vms-space-sm);
635
+ padding-bottom: var(--vms-space-sm);
636
+ }
637
+
627
638
  /* Pagination footer — range label + prev/next, separated from the table by a
628
639
  hairline. Buttons reuse .vms-button styling; only sizing/disabled is added. */
629
640
  .vms-table__pagination {