@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 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
- const selAction = sel.action;
655
- box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } }));
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
- const selAction = sel.action;
744
- box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), id: rowId, checked: box.checked } }));
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 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`. */
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
- /** 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;
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 selectedSet = sel ? new Set(sel.selectedIds) : null;
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 && selectedSet.has(r.id));
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: [_jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, ...(onToggleAll ? { onMouseDown: onToggleAll } : {}), children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
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 && selectedSet.has(row.id);
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.12.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",
@@ -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 {