@ashley-shrok/viewmodel-shell 0.11.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
@@ -637,6 +637,43 @@ export class BrowserAdapter {
637
637
  table.className = "vms-table";
638
638
  const thead = document.createElement("thead");
639
639
  const headerRow = document.createElement("tr");
640
+ // Selection: a leading checkbox column. The header box is a select-all over
641
+ // the rows CURRENTLY rendered (the current page) — never unloaded rows.
642
+ const sel = n.selection;
643
+ const selectedSet = sel ? new Set(sel.selectedIds) : null;
644
+ if (sel) {
645
+ const th = document.createElement("th");
646
+ th.className = "vms-table__th vms-table__th--select";
647
+ const allOnPage = n.rows.length > 0 && n.rows.every(r => r.id != null && selectedSet.has(r.id));
648
+ const someOnPage = n.rows.some(r => r.id != null && selectedSet.has(r.id));
649
+ const box = document.createElement("input");
650
+ box.type = "checkbox";
651
+ box.className = "vms-table__select vms-table__select--all";
652
+ box.checked = allOnPage;
653
+ box.indeterminate = someOnPage && !allOnPage;
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
+ });
674
+ th.appendChild(box);
675
+ headerRow.appendChild(th);
676
+ }
640
677
  n.columns.forEach(col => {
641
678
  const th = document.createElement("th");
642
679
  const isSorted = col.key === n.sortColumn;
@@ -665,6 +702,8 @@ export class BrowserAdapter {
665
702
  const filterAction = n.filterAction;
666
703
  const filterRow = document.createElement("tr");
667
704
  filterRow.className = "vms-table__filter-row";
705
+ if (sel)
706
+ filterRow.appendChild(document.createElement("th")); // align under the select column
668
707
  n.columns.forEach(col => {
669
708
  const th = document.createElement("th");
670
709
  if (col.filterable) {
@@ -698,6 +737,9 @@ export class BrowserAdapter {
698
737
  rowClass += ` vms-table__row--${row.variant}`;
699
738
  if (row.action)
700
739
  rowClass += " vms-table__row--clickable";
740
+ const isSelected = sel != null && row.id != null && selectedSet.has(row.id);
741
+ if (isSelected)
742
+ rowClass += " vms-table__row--selected";
701
743
  tr.className = rowClass;
702
744
  if (row.id)
703
745
  tr.dataset.id = row.id;
@@ -705,6 +747,46 @@ export class BrowserAdapter {
705
747
  const rowAction = row.action;
706
748
  tr.addEventListener("click", () => on(rowAction));
707
749
  }
750
+ if (sel) {
751
+ const td = document.createElement("td");
752
+ td.className = "vms-table__td vms-table__td--select";
753
+ // A click in the checkbox cell must not also fire the row's click action.
754
+ td.addEventListener("click", (e) => e.stopPropagation());
755
+ const box = document.createElement("input");
756
+ box.type = "checkbox";
757
+ box.className = "vms-table__select";
758
+ box.checked = isSelected;
759
+ if (row.id != null) {
760
+ const rowId = row.id;
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
+ });
783
+ }
784
+ else {
785
+ box.disabled = true; // selection addresses rows by id; a row without one can't be selected
786
+ }
787
+ td.appendChild(box);
788
+ tr.appendChild(td);
789
+ }
708
790
  n.columns.forEach(col => {
709
791
  const td = document.createElement("td");
710
792
  td.className = "vms-table__td";
@@ -729,6 +811,51 @@ export class BrowserAdapter {
729
811
  });
730
812
  table.appendChild(tbody);
731
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
+ }
833
+ if (n.pagination) {
834
+ const pg = n.pagination;
835
+ const footer = document.createElement("div");
836
+ footer.className = "vms-table__pagination";
837
+ const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
838
+ const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
839
+ const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
840
+ const range = document.createElement("span");
841
+ range.className = "vms-table__pagination-range";
842
+ range.textContent = `${from}–${to} of ${pg.totalRows}`;
843
+ footer.appendChild(range);
844
+ const pgAction = pg.action;
845
+ const mkBtn = (label, targetPage, disabled) => {
846
+ const b = document.createElement("button");
847
+ b.type = "button";
848
+ b.className = "vms-button vms-button--secondary vms-table__pagination-btn";
849
+ b.textContent = label;
850
+ b.disabled = disabled;
851
+ if (!disabled)
852
+ b.addEventListener("click", () => on({ name: pgAction.name, context: { ...(pgAction.context ?? {}), page: targetPage } }));
853
+ return b;
854
+ };
855
+ footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.page <= 1));
856
+ footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.page >= totalPages));
857
+ wrapper.appendChild(footer);
858
+ }
732
859
  parent.appendChild(wrapper);
733
860
  }
734
861
  copyButton(n, parent) {
package/dist/index.d.ts CHANGED
@@ -202,6 +202,46 @@ export interface TableRow {
202
202
  action?: ActionEvent;
203
203
  variant?: string;
204
204
  }
205
+ export interface TableSelection {
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. */
212
+ selectedIds: string[];
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[];
232
+ }
233
+ export interface TablePagination {
234
+ /** 1-based current page. */
235
+ page: number;
236
+ /** Rows per page. Drives the "X–Y of N" range label and the last-page calc. */
237
+ pageSize: number;
238
+ /** Total rows across all pages — server-truth. The adapter renders the range
239
+ * label and enables/disables prev/next from this; it does NOT slice. */
240
+ totalRows: number;
241
+ /** Dispatched on a page-control click. The adapter merges `{ page }` — the
242
+ * target 1-based page. */
243
+ action: ActionEvent;
244
+ }
205
245
  export interface TableNode {
206
246
  type: "table";
207
247
  columns: TableColumn[];
@@ -212,6 +252,18 @@ export interface TableNode {
212
252
  sortAction?: ActionEvent;
213
253
  /** Base action. Adapter merges { column, value, filters } into context on Enter. */
214
254
  filterAction?: ActionEvent;
255
+ /** Per-row multi-select. When set, the adapter renders a leading checkbox
256
+ * column + a header select-all checkbox and tints selected rows. `TableRow.id`
257
+ * is REQUIRED on every row when selection is set — it's the address the
258
+ * toggle action reports back. */
259
+ selection?: TableSelection;
260
+ /** Server-driven pagination. When set, the adapter renders an "X–Y of N"
261
+ * range + prev/next controls below the table. **The server slices `rows` to
262
+ * the current page** — the adapter never paginates client-side (that would
263
+ * break for DB-backed tables, which are most of them). By convention
264
+ * `sortAction` / `filterAction` reset `page` to 1 on the server side, since
265
+ * the row window changes underneath them. */
266
+ pagination?: TablePagination;
215
267
  }
216
268
  export interface CopyButtonNode {
217
269
  type: "copy-button";
package/dist/tui.js CHANGED
@@ -914,38 +914,93 @@ function TableView({ node, ctx }) {
914
914
  context: { ...(node.sortAction.context ?? {}), column: columnKey, direction },
915
915
  });
916
916
  };
917
- return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [_jsx("box", { flexDirection: "row", gap: 2, children: node.columns.map((c) => {
918
- const isSorted = node.sortColumn === c.key;
919
- const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
920
- // B5 only sortable headers respond to clicks (matches BrowserAdapter).
921
- const clickable = c.sortable && node.sortAction != null;
922
- const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
923
- return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
924
- }) }), node.columns.some((c) => c.filterable) ? (_jsx("box", { flexDirection: "row", gap: 2, children: node.columns.map((c) => (_jsx("text", { fg: "#888888", children: c.filterable ? (c.filterValue ? `[${c.filterValue}]` : "[filter]") : "" }, c.key))) })) : null, node.rows.map((row, ri) => {
917
+ // Selection leading [x]/[ ] column. Dispatch payloads match BrowserAdapter:
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.
925
+ const sel = node.selection;
926
+ const effectiveSet = sel ? new Set(sel.selectedIds) : null;
927
+ const allOnPage = sel != null && node.rows.length > 0 &&
928
+ node.rows.every((r) => r.id != null && effectiveSet.has(r.id));
929
+ const onToggleAll = sel?.action
930
+ ? () => ctx.onAction({
931
+ name: sel.action.name,
932
+ context: { ...(sel.action.context ?? {}), all: true, checked: !allOnPage },
933
+ })
934
+ : undefined;
935
+ const onToggleRow = sel?.action
936
+ ? (rowId, checked) => ctx.onAction({
937
+ name: sel.action.name,
938
+ context: { ...(sel.action.context ?? {}), id: rowId, checked },
939
+ })
940
+ : undefined;
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) => {
954
+ const isSorted = node.sortColumn === c.key;
955
+ const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
956
+ // B5 — only sortable headers respond to clicks (matches BrowserAdapter).
957
+ const clickable = c.sortable && node.sortAction != null;
958
+ const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
959
+ return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
960
+ })] }), node.columns.some((c) => c.filterable) ? (_jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? _jsx("text", { fg: "#888888", children: " " }) : null, node.columns.map((c) => (_jsx("text", { fg: "#888888", children: c.filterable ? (c.filterValue ? `[${c.filterValue}]` : "[filter]") : "" }, c.key)))] })) : null, node.rows.map((row, ri) => {
925
961
  // B5 — row click dispatches row.action when present.
926
962
  const onRowClick = row.action
927
963
  ? () => ctx.onAction(row.action)
928
964
  : undefined;
929
- return (_jsx("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: node.columns.map((c) => {
930
- const cell = row.cells[c.key] ?? "";
931
- if (c.linkLabel != null && cell.length > 0) {
932
- // Cell is a link emit OSC-8 for external links, plain
933
- // underlined text otherwise. External links are clickable
934
- // natively via the terminal's OSC-8 support; internal cell
935
- // links route through navigate (B5).
936
- const ESC = String.fromCharCode(27);
937
- const BEL = String.fromCharCode(7);
938
- const inner = c.linkExternal
939
- ? `${ESC}]8;;${cell}${BEL}${c.linkLabel}${ESC}]8;;${BEL}`
940
- : c.linkLabel;
941
- const cellClick = !c.linkExternal && cell.length > 0
942
- ? () => ctx.navigate(cell)
965
+ return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
966
+ const isSel = row.id != null && effectiveSet.has(row.id);
967
+ const rowId = row.id;
968
+ const onBox = rowId != null && onToggleRow
969
+ ? () => onToggleRow(rowId, !isSel)
943
970
  : undefined;
944
- return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(cellClick ? { onMouseDown: cellClick } : {}), children: inner }, c.key));
945
- }
946
- return _jsx("text", { children: cell }, c.key);
947
- }) }, row.id ?? ri));
948
- })] }) }));
971
+ return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", ...(onBox ? { onMouseDown: onBox } : {}), children: isSel ? "[x]" : "[ ]" }));
972
+ })() : null, node.columns.map((c) => {
973
+ const cell = row.cells[c.key] ?? "";
974
+ if (c.linkLabel != null && cell.length > 0) {
975
+ // Cell is a link — emit OSC-8 for external links, plain
976
+ // underlined text otherwise. External links are clickable
977
+ // natively via the terminal's OSC-8 support; internal cell
978
+ // links route through navigate (B5).
979
+ const ESC = String.fromCharCode(27);
980
+ const BEL = String.fromCharCode(7);
981
+ const inner = c.linkExternal
982
+ ? `${ESC}]8;;${cell}${BEL}${c.linkLabel}${ESC}]8;;${BEL}`
983
+ : c.linkLabel;
984
+ const cellClick = !c.linkExternal && cell.length > 0
985
+ ? () => ctx.navigate(cell)
986
+ : undefined;
987
+ return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(cellClick ? { onMouseDown: cellClick } : {}), children: inner }, c.key));
988
+ }
989
+ return _jsx("text", { children: cell }, c.key);
990
+ })] }, row.id ?? ri));
991
+ }), node.pagination ? (() => {
992
+ const pg = node.pagination;
993
+ const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
994
+ const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
995
+ const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
996
+ const go = (p) => ctx.onAction({
997
+ name: pg.action.name,
998
+ context: { ...(pg.action.context ?? {}), page: p },
999
+ });
1000
+ const canPrev = pg.page > 1;
1001
+ const canNext = pg.page < totalPages;
1002
+ return (_jsxs("box", { flexDirection: "row", gap: 2, children: [_jsx("text", { fg: "#888888", children: `${from}–${to} of ${pg.totalRows}` }), _jsx("text", { fg: canPrev ? "#88aaff" : "#555555", ...(canPrev ? { onMouseDown: () => go(pg.page - 1) } : {}), children: "‹ Prev" }), _jsx("text", { fg: canNext ? "#88aaff" : "#555555", ...(canNext ? { onMouseDown: () => go(pg.page + 1) } : {}), children: "Next ›" })] }));
1003
+ })() : null] }) }));
949
1004
  }
950
1005
  // ── minimum-viable text surface for the rest of the node set ───────────────
951
1006
  // Same as B1 — text only, no interactivity. Full widgets land in B3/B4.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.11.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",
@@ -614,6 +614,41 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
614
614
  .vms-table__link { color: var(--vms-accent); text-decoration: none; }
615
615
  .vms-table__link:hover { text-decoration: underline; }
616
616
 
617
+ /* Selection — leading checkbox column + selected-row tint. The tint derives
618
+ from --vms-accent via color-mix, so a custom :root theme recolors it
619
+ automatically (same pattern as the status variants above). Selected wins
620
+ over the clickable-hover background so a selected row stays legible. */
621
+ .vms-table__th--select,
622
+ .vms-table__td--select { width: 1%; white-space: nowrap; text-align: center; padding-right: 0; }
623
+ .vms-table__select { accent-color: var(--vms-accent); cursor: pointer; }
624
+ .vms-table__row--selected { background: color-mix(in srgb, var(--vms-accent) 12%, transparent); }
625
+ .vms-table__row--selected.vms-table__row--clickable:hover { background: color-mix(in srgb, var(--vms-accent) 18%, transparent); }
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
+
638
+ /* Pagination footer — range label + prev/next, separated from the table by a
639
+ hairline. Buttons reuse .vms-button styling; only sizing/disabled is added. */
640
+ .vms-table__pagination {
641
+ display: flex;
642
+ align-items: center;
643
+ gap: var(--vms-space-sm);
644
+ padding-top: var(--vms-space-sm);
645
+ margin-top: var(--vms-space-xs);
646
+ border-top: 1px solid var(--vms-border);
647
+ }
648
+ .vms-table__pagination-range { color: var(--vms-text-muted); font-size: var(--vms-text-sm); margin-right: auto; }
649
+ .vms-table__pagination-btn { padding: var(--vms-space-xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
650
+ .vms-table__pagination-btn:disabled { opacity: 0.45; cursor: default; }
651
+
617
652
  /* ── Recommended error-banner pattern ──
618
653
  Apps typically render this from the `onError` callback. Not emitted by
619
654
  the framework; included here so apps can use the convention without