@ashley-shrok/viewmodel-shell 0.12.0 → 0.14.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.d.ts +6 -0
- package/dist/browser.js +83 -4
- package/dist/index.d.ts +41 -11
- package/dist/index.js +9 -0
- package/dist/server.d.ts +4 -0
- package/dist/tui.js +25 -7
- package/package.json +1 -1
- package/styles/default.css +11 -0
package/dist/browser.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
10
10
|
* contentType is informational — the Blob's own .type takes precedence in
|
|
11
11
|
* browsers. We accept the arg for adapter symmetry (other adapters use it). */
|
|
12
12
|
saveFile(data: Blob, filename: string, _contentType: string): void;
|
|
13
|
+
/** 0.14.0 — install / clear the browser's `beforeunload` guard. The shell
|
|
14
|
+
* calls this on every response with `response.preventUnload ?? false`, so
|
|
15
|
+
* the lock-and-clear cycle is just "server sets preventUnload:true while
|
|
16
|
+
* work is pending, omits it (or sets false) when done." Idempotent. */
|
|
17
|
+
private unloadHandler;
|
|
18
|
+
setPreventUnload(active: boolean): void;
|
|
13
19
|
transport(input: string, init: {
|
|
14
20
|
method?: string;
|
|
15
21
|
headers?: Record<string, string>;
|
package/dist/browser.js
CHANGED
|
@@ -105,6 +105,28 @@ export class BrowserAdapter {
|
|
|
105
105
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
/** 0.14.0 — install / clear the browser's `beforeunload` guard. The shell
|
|
109
|
+
* calls this on every response with `response.preventUnload ?? false`, so
|
|
110
|
+
* the lock-and-clear cycle is just "server sets preventUnload:true while
|
|
111
|
+
* work is pending, omits it (or sets false) when done." Idempotent. */
|
|
112
|
+
unloadHandler = null;
|
|
113
|
+
setPreventUnload(active) {
|
|
114
|
+
if (active && this.unloadHandler == null) {
|
|
115
|
+
this.unloadHandler = (e) => {
|
|
116
|
+
// Two signals because browsers historically disagreed on which they
|
|
117
|
+
// honor; modern Chromium/Firefox honor preventDefault, older Safari
|
|
118
|
+
// and ancient browsers look at returnValue. The dialog text itself is
|
|
119
|
+
// browser-controlled ("Leave site?…") and cannot be customized.
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
e.returnValue = "";
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener("beforeunload", this.unloadHandler);
|
|
124
|
+
}
|
|
125
|
+
else if (!active && this.unloadHandler != null) {
|
|
126
|
+
window.removeEventListener("beforeunload", this.unloadHandler);
|
|
127
|
+
this.unloadHandler = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
108
130
|
async transport(input, init, hooks) {
|
|
109
131
|
const onUploadProgress = hooks?.onUploadProgress;
|
|
110
132
|
if (!onUploadProgress) {
|
|
@@ -651,8 +673,26 @@ export class BrowserAdapter {
|
|
|
651
673
|
box.className = "vms-table__select vms-table__select--all";
|
|
652
674
|
box.checked = allOnPage;
|
|
653
675
|
box.indeterminate = someOnPage && !allOnPage;
|
|
654
|
-
|
|
655
|
-
|
|
676
|
+
box.addEventListener("change", () => {
|
|
677
|
+
const selAction = sel.action;
|
|
678
|
+
if (selAction) {
|
|
679
|
+
// Server-truth mode: dispatch; server re-renders with new selectedIds.
|
|
680
|
+
on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } });
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
// Local mode (0.13.0): toggle every row checkbox + class in this table
|
|
684
|
+
// to match the header. No dispatch — the dispatch guard can't drop anything.
|
|
685
|
+
const want = box.checked;
|
|
686
|
+
table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
|
|
687
|
+
if (rowBox.disabled)
|
|
688
|
+
return;
|
|
689
|
+
rowBox.checked = want;
|
|
690
|
+
const tr = rowBox.closest(".vms-table__row");
|
|
691
|
+
if (tr)
|
|
692
|
+
tr.classList.toggle("vms-table__row--selected", want);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
});
|
|
656
696
|
th.appendChild(box);
|
|
657
697
|
headerRow.appendChild(th);
|
|
658
698
|
}
|
|
@@ -740,8 +780,28 @@ export class BrowserAdapter {
|
|
|
740
780
|
box.checked = isSelected;
|
|
741
781
|
if (row.id != null) {
|
|
742
782
|
const rowId = row.id;
|
|
743
|
-
|
|
744
|
-
box.
|
|
783
|
+
// data-id is what selection.buttons[] harvest reads on click.
|
|
784
|
+
box.dataset.id = rowId;
|
|
785
|
+
box.addEventListener("change", () => {
|
|
786
|
+
const selAction = sel.action;
|
|
787
|
+
if (selAction) {
|
|
788
|
+
on({ name: selAction.name, context: { ...(selAction.context ?? {}), id: rowId, checked: box.checked } });
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
// Local mode (0.13.0): flip the row class to mirror the box, then
|
|
792
|
+
// reconcile the header select-all (could now be all / some / none).
|
|
793
|
+
tr.classList.toggle("vms-table__row--selected", box.checked);
|
|
794
|
+
const headerBox = table.querySelector("thead input.vms-table__select--all");
|
|
795
|
+
if (headerBox) {
|
|
796
|
+
const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
|
|
797
|
+
let checked = 0;
|
|
798
|
+
all.forEach(b => { if (b.checked)
|
|
799
|
+
checked++; });
|
|
800
|
+
headerBox.checked = all.length > 0 && checked === all.length;
|
|
801
|
+
headerBox.indeterminate = checked > 0 && checked < all.length;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
});
|
|
745
805
|
}
|
|
746
806
|
else {
|
|
747
807
|
box.disabled = true; // selection addresses rows by id; a row without one can't be selected
|
|
@@ -773,6 +833,25 @@ export class BrowserAdapter {
|
|
|
773
833
|
});
|
|
774
834
|
table.appendChild(tbody);
|
|
775
835
|
wrapper.appendChild(table);
|
|
836
|
+
// 0.13.0 — bulk-action toolbar rendered ABOVE the table. Each button's
|
|
837
|
+
// click harvests the table's currently-checked row ids and merges them as
|
|
838
|
+
// `selectedIds` into the action's context. Works with both server-truth
|
|
839
|
+
// mode (the DOM mirrors selectedIds so the harvest matches state) and
|
|
840
|
+
// local mode (the DOM is the only source of selection truth).
|
|
841
|
+
if (sel?.buttons && sel.buttons.length > 0) {
|
|
842
|
+
const toolbar = document.createElement("div");
|
|
843
|
+
toolbar.className = "vms-table__bulk-actions";
|
|
844
|
+
const harvest = (action) => {
|
|
845
|
+
const ids = [];
|
|
846
|
+
table.querySelectorAll("tbody input.vms-table__select:checked").forEach(b => {
|
|
847
|
+
if (b.dataset.id)
|
|
848
|
+
ids.push(b.dataset.id);
|
|
849
|
+
});
|
|
850
|
+
on({ name: action.name, context: { ...(action.context ?? {}), selectedIds: ids } });
|
|
851
|
+
};
|
|
852
|
+
sel.buttons.forEach(btn => this.button(btn, toolbar, harvest));
|
|
853
|
+
wrapper.insertBefore(toolbar, table);
|
|
854
|
+
}
|
|
776
855
|
if (n.pagination) {
|
|
777
856
|
const pg = n.pagination;
|
|
778
857
|
const footer = document.createElement("div");
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,16 @@ export interface Adapter {
|
|
|
32
32
|
* May return void or Promise<void>; the shell awaits the return value so
|
|
33
33
|
* async I/O errors surface via onError. */
|
|
34
34
|
saveFile?(data: Blob, filename: string, contentType: string): void | Promise<void>;
|
|
35
|
+
/** 0.14.0 — install / clear a "warn before navigating away" guard, driven
|
|
36
|
+
* by `ShellResponse.preventUnload` on every response (load, dispatch,
|
|
37
|
+
* push). Idempotent: the shell calls with the boolean from each response,
|
|
38
|
+
* the adapter installs when true / clears when false. Designed for
|
|
39
|
+
* long-running server actions where an accidental tab close would lose
|
|
40
|
+
* in-flight work. Fail-quiet by absence (unlike navigate/storage/saveFile)
|
|
41
|
+
* — this is a UX safety net, not a security guarantee, and non-browser
|
|
42
|
+
* targets (TUI) have no terminal equivalent. Modern browsers show a
|
|
43
|
+
* generic "Leave site?" dialog; the message is not customizable. */
|
|
44
|
+
setPreventUnload?(active: boolean): void;
|
|
35
45
|
}
|
|
36
46
|
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
|
|
37
47
|
export interface PageNode {
|
|
@@ -203,18 +213,32 @@ export interface TableRow {
|
|
|
203
213
|
variant?: string;
|
|
204
214
|
}
|
|
205
215
|
export interface TableSelection {
|
|
206
|
-
/** Row ids
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
216
|
+
/** Row ids that should render PRE-SELECTED on this render. In server-truth
|
|
217
|
+
* mode (`action` set) this is the live selection, round-tripped in state and
|
|
218
|
+
* authoritative every render. In local mode (`action` omitted) this is the
|
|
219
|
+
* server's initial pre-selection only — subsequent toggles are purely
|
|
220
|
+
* client-side DOM state and the server doesn't see them until a `buttons[]`
|
|
221
|
+
* click harvests them. */
|
|
210
222
|
selectedIds: string[];
|
|
211
|
-
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* page
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
|
|
223
|
+
/** OPTIONAL (0.13.0). When present: server-truth mode — every checkbox toggle
|
|
224
|
+
* dispatches this action with merged `{ id, checked }` per row or
|
|
225
|
+
* `{ all: true, checked }` for the header select-all (where "all" = the
|
|
226
|
+
* rendered page). Selection survives sort/filter/pagination via the state
|
|
227
|
+
* round-trip. When OMITTED: local mode — the adapter toggles the DOM
|
|
228
|
+
* checkbox + `.vms-table__row--selected` class purely client-side with no
|
|
229
|
+
* dispatch. Local mode is the recommended pattern for rapid-selection
|
|
230
|
+
* workflows (no dropped clicks under the dispatch guard); see `buttons` for
|
|
231
|
+
* how bulk actions read the resulting selection. */
|
|
232
|
+
action?: ActionEvent;
|
|
233
|
+
/** OPTIONAL (0.13.0). When present, the adapter renders these as a bulk-action
|
|
234
|
+
* toolbar ABOVE the table. On click, each button harvests the currently
|
|
235
|
+
* checked rows from the DOM and dispatches its `action` with
|
|
236
|
+
* `{ selectedIds: [...] }` merged into its `context`. Designed primarily to
|
|
237
|
+
* pair with local mode (`action` absent) — it's how the server learns the
|
|
238
|
+
* selection without a per-toggle round-trip — but works in server-truth mode
|
|
239
|
+
* too (the harvest matches `selectedIds` since the DOM reflects server-truth
|
|
240
|
+
* after each render). */
|
|
241
|
+
buttons?: ButtonNode[];
|
|
218
242
|
}
|
|
219
243
|
export interface TablePagination {
|
|
220
244
|
/** 1-based current page. */
|
|
@@ -306,6 +330,12 @@ export interface ShellResponse {
|
|
|
306
330
|
sideEffects?: ShellSideEffect[];
|
|
307
331
|
/** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
|
|
308
332
|
nextPollIn?: number;
|
|
333
|
+
/** 0.14.0 — when true, the shell asks the adapter to install a "warn before
|
|
334
|
+
* unload" guard; when false / absent, the guard is cleared. Drives long-
|
|
335
|
+
* running-work workflows: while server-side work is in flight, return
|
|
336
|
+
* `preventUnload: true` from each response; clear it when the work
|
|
337
|
+
* completes (typically via polling). See `Adapter.setPreventUnload`. */
|
|
338
|
+
preventUnload?: boolean;
|
|
309
339
|
}
|
|
310
340
|
export declare class ViewModelShell {
|
|
311
341
|
private options;
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,10 @@ export class ViewModelShell {
|
|
|
21
21
|
const body = (await res.json());
|
|
22
22
|
this.currentVm = body.vm;
|
|
23
23
|
this.currentState = body.state;
|
|
24
|
+
// 0.14.0 — apply the unload guard from the initial-load response too. The
|
|
25
|
+
// server may legitimately want it on at first paint (e.g. the page was
|
|
26
|
+
// refreshed mid-work and the long action is still pending server-side).
|
|
27
|
+
adapter.setPreventUnload?.(body.preventUnload ?? false);
|
|
24
28
|
adapter.render(body.vm, (action) => this.dispatch(action));
|
|
25
29
|
this.schedulePoll(body.nextPollIn);
|
|
26
30
|
}
|
|
@@ -138,6 +142,11 @@ export class ViewModelShell {
|
|
|
138
142
|
void this.download(effect.url, effect.filename);
|
|
139
143
|
}
|
|
140
144
|
}
|
|
145
|
+
// 0.14.0 — apply the unload guard before the redirect/render branch so it's
|
|
146
|
+
// in place (or cleared) consistently across both branches. A server that
|
|
147
|
+
// wants a redirect to NOT be blocked by its own guard simply omits
|
|
148
|
+
// preventUnload (or sets it false) on that response — standard pattern.
|
|
149
|
+
adapter.setPreventUnload?.(body.preventUnload ?? false);
|
|
141
150
|
if (body.redirect) {
|
|
142
151
|
if (this.options.onRedirect) {
|
|
143
152
|
this.options.onRedirect(body.redirect);
|
package/dist/server.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export interface ShellResponseBody<TState> {
|
|
|
18
18
|
redirect?: string;
|
|
19
19
|
sideEffects?: ShellSideEffect[];
|
|
20
20
|
nextPollIn?: number;
|
|
21
|
+
/** 0.14.0 — install / clear the browser's "warn before unload" guard. Omit
|
|
22
|
+
* (or set false) to clear; set true while a long-running server action is
|
|
23
|
+
* in flight so an accidental tab-close doesn't lose work. */
|
|
24
|
+
preventUnload?: boolean;
|
|
21
25
|
}
|
|
22
26
|
/** Build a redirect response (Vm and State omitted; shell navigates the browser). */
|
|
23
27
|
export declare function shellRedirect<TState = unknown>(url: string): ShellResponseBody<TState>;
|
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
|
|
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 &&
|
|
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: [
|
|
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 &&
|
|
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.
|
|
3
|
+
"version": "0.14.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
|
@@ -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 {
|