@ashley-shrok/viewmodel-shell 0.13.0 → 0.15.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 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) {
@@ -652,24 +674,18 @@ export class BrowserAdapter {
652
674
  box.checked = allOnPage;
653
675
  box.indeterminate = someOnPage && !allOnPage;
654
676
  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
- }
677
+ // Toggle every row checkbox + class in this table to match the header.
678
+ // No dispatch — selection is purely DOM-local; bulk actions harvest the
679
+ // checked rows when a `selection.buttons[]` entry is clicked.
680
+ const want = box.checked;
681
+ table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
682
+ if (rowBox.disabled)
683
+ return;
684
+ rowBox.checked = want;
685
+ const tr = rowBox.closest(".vms-table__row");
686
+ if (tr)
687
+ tr.classList.toggle("vms-table__row--selected", want);
688
+ });
673
689
  });
674
690
  th.appendChild(box);
675
691
  headerRow.appendChild(th);
@@ -761,23 +777,18 @@ export class BrowserAdapter {
761
777
  // data-id is what selection.buttons[] harvest reads on click.
762
778
  box.dataset.id = rowId;
763
779
  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
- }
780
+ // Flip the row class to mirror the box, then reconcile the header
781
+ // select-all (could now be all / some / none). No dispatch — bulk
782
+ // actions read the DOM via the selection.buttons[] harvest.
783
+ tr.classList.toggle("vms-table__row--selected", box.checked);
784
+ const headerBox = table.querySelector("thead input.vms-table__select--all");
785
+ if (headerBox) {
786
+ const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
787
+ let checked = 0;
788
+ all.forEach(b => { if (b.checked)
789
+ checked++; });
790
+ headerBox.checked = all.length > 0 && checked === all.length;
791
+ headerBox.indeterminate = checked > 0 && checked < all.length;
781
792
  }
782
793
  });
783
794
  }
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,31 +213,19 @@ export interface TableRow {
203
213
  variant?: string;
204
214
  }
205
215
  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. */
216
+ /** Row ids that should render PRE-SELECTED on initial render the server's
217
+ * initial pre-selection. Subsequent toggles are purely client-side DOM state;
218
+ * the server doesn't see them until a `buttons[]` click harvests them. */
212
219
  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). */
220
+ /** When present, the adapter renders these as a bulk-action toolbar ABOVE
221
+ * the table. On click, each button harvests the currently-checked rows from
222
+ * the DOM and dispatches its `action` with `{ selectedIds: [...] }` merged
223
+ * into its `context`. This is the only way to act on the selection — there
224
+ * is no per-toggle dispatch (0.15.0 removed the `action` mode that did,
225
+ * because rapid clicks were dropped by the dispatch guard and the in-flight
226
+ * response wiped the DOM). If a future release needs cross-page persistence
227
+ * or live "N selected" indicators, the way back is a redesigned wire shape
228
+ * (dispatch queueing + optimistic preservation), not the old `action` mode. */
231
229
  buttons?: ButtonNode[];
232
230
  }
233
231
  export interface TablePagination {
@@ -320,6 +318,12 @@ export interface ShellResponse {
320
318
  sideEffects?: ShellSideEffect[];
321
319
  /** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
322
320
  nextPollIn?: number;
321
+ /** 0.14.0 — when true, the shell asks the adapter to install a "warn before
322
+ * unload" guard; when false / absent, the guard is cleared. Drives long-
323
+ * running-work workflows: while server-side work is in flight, return
324
+ * `preventUnload: true` from each response; clear it when the work
325
+ * completes (typically via polling). See `Adapter.setPreventUnload`. */
326
+ preventUnload?: boolean;
323
327
  }
324
328
  export declare class ViewModelShell {
325
329
  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
@@ -914,30 +914,15 @@ function TableView({ node, ctx }) {
914
914
  context: { ...(node.sortAction.context ?? {}), column: columnKey, direction },
915
915
  });
916
916
  };
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.
917
+ // Selection — leading [x]/[ ] column. TUI is render-only: checkboxes display
918
+ // selectedIds; clicks are inert (the TUI doesn't track DOM-equivalent state
919
+ // across the hook-less conformance walker). Bulk actions live in
920
+ // selection.buttons[]; the harvest reads sel.selectedIds (server's
921
+ // pre-selection). The browser carries the interactive surface.
925
922
  const sel = node.selection;
926
923
  const effectiveSet = sel ? new Set(sel.selectedIds) : null;
927
924
  const allOnPage = sel != null && node.rows.length > 0 &&
928
925
  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
926
  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
927
  const harvestCtx = {
943
928
  ...ctx,
@@ -950,7 +935,7 @@ function TableView({ node, ctx }) {
950
935
  },
951
936
  };
952
937
  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) => {
938
+ }) })) : null, _jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
954
939
  const isSorted = node.sortColumn === c.key;
955
940
  const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
956
941
  // B5 — only sortable headers respond to clicks (matches BrowserAdapter).
@@ -964,11 +949,7 @@ function TableView({ node, ctx }) {
964
949
  : undefined;
965
950
  return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
966
951
  const isSel = row.id != null && effectiveSet.has(row.id);
967
- const rowId = row.id;
968
- const onBox = rowId != null && onToggleRow
969
- ? () => onToggleRow(rowId, !isSel)
970
- : undefined;
971
- return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", ...(onBox ? { onMouseDown: onBox } : {}), children: isSel ? "[x]" : "[ ]" }));
952
+ return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", children: isSel ? "[x]" : "[ ]" }));
972
953
  })() : null, node.columns.map((c) => {
973
954
  const cell = row.cells[c.key] ?? "";
974
955
  if (c.linkLabel != null && cell.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",