@ashley-shrok/viewmodel-shell 0.11.0 → 0.12.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,25 @@ 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
+ const selAction = sel.action;
655
+ box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } }));
656
+ th.appendChild(box);
657
+ headerRow.appendChild(th);
658
+ }
640
659
  n.columns.forEach(col => {
641
660
  const th = document.createElement("th");
642
661
  const isSorted = col.key === n.sortColumn;
@@ -665,6 +684,8 @@ export class BrowserAdapter {
665
684
  const filterAction = n.filterAction;
666
685
  const filterRow = document.createElement("tr");
667
686
  filterRow.className = "vms-table__filter-row";
687
+ if (sel)
688
+ filterRow.appendChild(document.createElement("th")); // align under the select column
668
689
  n.columns.forEach(col => {
669
690
  const th = document.createElement("th");
670
691
  if (col.filterable) {
@@ -698,6 +719,9 @@ export class BrowserAdapter {
698
719
  rowClass += ` vms-table__row--${row.variant}`;
699
720
  if (row.action)
700
721
  rowClass += " vms-table__row--clickable";
722
+ const isSelected = sel != null && row.id != null && selectedSet.has(row.id);
723
+ if (isSelected)
724
+ rowClass += " vms-table__row--selected";
701
725
  tr.className = rowClass;
702
726
  if (row.id)
703
727
  tr.dataset.id = row.id;
@@ -705,6 +729,26 @@ export class BrowserAdapter {
705
729
  const rowAction = row.action;
706
730
  tr.addEventListener("click", () => on(rowAction));
707
731
  }
732
+ if (sel) {
733
+ const td = document.createElement("td");
734
+ td.className = "vms-table__td vms-table__td--select";
735
+ // A click in the checkbox cell must not also fire the row's click action.
736
+ td.addEventListener("click", (e) => e.stopPropagation());
737
+ const box = document.createElement("input");
738
+ box.type = "checkbox";
739
+ box.className = "vms-table__select";
740
+ box.checked = isSelected;
741
+ if (row.id != null) {
742
+ 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 } }));
745
+ }
746
+ else {
747
+ box.disabled = true; // selection addresses rows by id; a row without one can't be selected
748
+ }
749
+ td.appendChild(box);
750
+ tr.appendChild(td);
751
+ }
708
752
  n.columns.forEach(col => {
709
753
  const td = document.createElement("td");
710
754
  td.className = "vms-table__td";
@@ -729,6 +773,32 @@ export class BrowserAdapter {
729
773
  });
730
774
  table.appendChild(tbody);
731
775
  wrapper.appendChild(table);
776
+ if (n.pagination) {
777
+ const pg = n.pagination;
778
+ const footer = document.createElement("div");
779
+ footer.className = "vms-table__pagination";
780
+ const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
781
+ const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
782
+ const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
783
+ const range = document.createElement("span");
784
+ range.className = "vms-table__pagination-range";
785
+ range.textContent = `${from}–${to} of ${pg.totalRows}`;
786
+ footer.appendChild(range);
787
+ const pgAction = pg.action;
788
+ const mkBtn = (label, targetPage, disabled) => {
789
+ const b = document.createElement("button");
790
+ b.type = "button";
791
+ b.className = "vms-button vms-button--secondary vms-table__pagination-btn";
792
+ b.textContent = label;
793
+ b.disabled = disabled;
794
+ if (!disabled)
795
+ b.addEventListener("click", () => on({ name: pgAction.name, context: { ...(pgAction.context ?? {}), page: targetPage } }));
796
+ return b;
797
+ };
798
+ footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.page <= 1));
799
+ footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.page >= totalPages));
800
+ wrapper.appendChild(footer);
801
+ }
732
802
  parent.appendChild(wrapper);
733
803
  }
734
804
  copyButton(n, parent) {
package/dist/index.d.ts CHANGED
@@ -202,6 +202,32 @@ export interface TableRow {
202
202
  action?: ActionEvent;
203
203
  variant?: string;
204
204
  }
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`. */
210
+ 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;
218
+ }
219
+ export interface TablePagination {
220
+ /** 1-based current page. */
221
+ page: number;
222
+ /** Rows per page. Drives the "X–Y of N" range label and the last-page calc. */
223
+ pageSize: number;
224
+ /** Total rows across all pages — server-truth. The adapter renders the range
225
+ * label and enables/disables prev/next from this; it does NOT slice. */
226
+ totalRows: number;
227
+ /** Dispatched on a page-control click. The adapter merges `{ page }` — the
228
+ * target 1-based page. */
229
+ action: ActionEvent;
230
+ }
205
231
  export interface TableNode {
206
232
  type: "table";
207
233
  columns: TableColumn[];
@@ -212,6 +238,18 @@ export interface TableNode {
212
238
  sortAction?: ActionEvent;
213
239
  /** Base action. Adapter merges { column, value, filters } into context on Enter. */
214
240
  filterAction?: ActionEvent;
241
+ /** Per-row multi-select. When set, the adapter renders a leading checkbox
242
+ * column + a header select-all checkbox and tints selected rows. `TableRow.id`
243
+ * is REQUIRED on every row when selection is set — it's the address the
244
+ * toggle action reports back. */
245
+ selection?: TableSelection;
246
+ /** Server-driven pagination. When set, the adapter renders an "X–Y of N"
247
+ * range + prev/next controls below the table. **The server slices `rows` to
248
+ * the current page** — the adapter never paginates client-side (that would
249
+ * break for DB-backed tables, which are most of them). By convention
250
+ * `sortAction` / `filterAction` reset `page` to 1 on the server side, since
251
+ * the row window changes underneath them. */
252
+ pagination?: TablePagination;
215
253
  }
216
254
  export interface CopyButtonNode {
217
255
  type: "copy-button";
package/dist/tui.js CHANGED
@@ -914,38 +914,75 @@ 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.
919
+ const sel = node.selection;
920
+ const selectedSet = sel ? new Set(sel.selectedIds) : null;
921
+ const allOnPage = sel != null && node.rows.length > 0 &&
922
+ node.rows.every((r) => r.id != null && selectedSet.has(r.id));
923
+ const onToggleAll = sel
924
+ ? () => ctx.onAction({
925
+ name: sel.action.name,
926
+ context: { ...(sel.action.context ?? {}), all: true, checked: !allOnPage },
927
+ })
928
+ : undefined;
929
+ const onToggleRow = sel
930
+ ? (rowId, checked) => ctx.onAction({
931
+ name: sel.action.name,
932
+ context: { ...(sel.action.context ?? {}), id: rowId, checked },
933
+ })
934
+ : 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) => {
936
+ const isSorted = node.sortColumn === c.key;
937
+ const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
938
+ // B5 — only sortable headers respond to clicks (matches BrowserAdapter).
939
+ const clickable = c.sortable && node.sortAction != null;
940
+ const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
941
+ return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
942
+ })] }), 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
943
  // B5 — row click dispatches row.action when present.
926
944
  const onRowClick = row.action
927
945
  ? () => ctx.onAction(row.action)
928
946
  : 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)
947
+ return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
948
+ const isSel = row.id != null && selectedSet.has(row.id);
949
+ const rowId = row.id;
950
+ const onBox = rowId != null && onToggleRow
951
+ ? () => onToggleRow(rowId, !isSel)
943
952
  : 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
- })] }) }));
953
+ return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", ...(onBox ? { onMouseDown: onBox } : {}), children: isSel ? "[x]" : "[ ]" }));
954
+ })() : null, node.columns.map((c) => {
955
+ const cell = row.cells[c.key] ?? "";
956
+ if (c.linkLabel != null && cell.length > 0) {
957
+ // Cell is a link — emit OSC-8 for external links, plain
958
+ // underlined text otherwise. External links are clickable
959
+ // natively via the terminal's OSC-8 support; internal cell
960
+ // links route through navigate (B5).
961
+ const ESC = String.fromCharCode(27);
962
+ const BEL = String.fromCharCode(7);
963
+ const inner = c.linkExternal
964
+ ? `${ESC}]8;;${cell}${BEL}${c.linkLabel}${ESC}]8;;${BEL}`
965
+ : c.linkLabel;
966
+ const cellClick = !c.linkExternal && cell.length > 0
967
+ ? () => ctx.navigate(cell)
968
+ : undefined;
969
+ return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(cellClick ? { onMouseDown: cellClick } : {}), children: inner }, c.key));
970
+ }
971
+ return _jsx("text", { children: cell }, c.key);
972
+ })] }, row.id ?? ri));
973
+ }), node.pagination ? (() => {
974
+ const pg = node.pagination;
975
+ const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
976
+ const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
977
+ const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
978
+ const go = (p) => ctx.onAction({
979
+ name: pg.action.name,
980
+ context: { ...(pg.action.context ?? {}), page: p },
981
+ });
982
+ const canPrev = pg.page > 1;
983
+ const canNext = pg.page < totalPages;
984
+ 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 ›" })] }));
985
+ })() : null] }) }));
949
986
  }
950
987
  // ── minimum-viable text surface for the rest of the node set ───────────────
951
988
  // 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.12.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,30 @@ 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
+ /* Pagination footer — range label + prev/next, separated from the table by a
628
+ hairline. Buttons reuse .vms-button styling; only sizing/disabled is added. */
629
+ .vms-table__pagination {
630
+ display: flex;
631
+ align-items: center;
632
+ gap: var(--vms-space-sm);
633
+ padding-top: var(--vms-space-sm);
634
+ margin-top: var(--vms-space-xs);
635
+ border-top: 1px solid var(--vms-border);
636
+ }
637
+ .vms-table__pagination-range { color: var(--vms-text-muted); font-size: var(--vms-text-sm); margin-right: auto; }
638
+ .vms-table__pagination-btn { padding: var(--vms-space-xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
639
+ .vms-table__pagination-btn:disabled { opacity: 0.45; cursor: default; }
640
+
617
641
  /* ── Recommended error-banner pattern ──
618
642
  Apps typically render this from the `onError` callback. Not emitted by
619
643
  the framework; included here so apps can use the convention without