@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 +70 -0
- package/dist/index.d.ts +38 -0
- package/dist/tui.js +64 -27
- package/package.json +1 -1
- package/styles/default.css +24 -0
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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 (
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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", {
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
|
|
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.
|
|
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",
|
package/styles/default.css
CHANGED
|
@@ -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
|