nexus-shared 3.2.0 → 4.0.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.
Files changed (187) hide show
  1. package/CHANGELOG.md +1564 -0
  2. package/README.md +1705 -1123
  3. package/dist/Client.Index.d.ts +14 -0
  4. package/dist/Client.Index.js +14 -0
  5. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  6. package/dist/Components/Documents/DocumentFrame.js +83 -0
  7. package/dist/Components/Documents/Menu.d.ts +4 -0
  8. package/dist/Components/Documents/Menu.js +40 -24
  9. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  10. package/dist/Components/Documents/PrintDocument.js +62 -0
  11. package/dist/Components/Documents/Tabs.d.ts +59 -0
  12. package/dist/Components/Documents/Tabs.js +70 -0
  13. package/dist/Components/Forms/ApiForm.js +19 -2
  14. package/dist/Components/Forms/Crud.d.ts +2 -2
  15. package/dist/Components/Forms/Crud.js +576 -243
  16. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  17. package/dist/Components/Forms/CrudActions.js +184 -0
  18. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  19. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  21. package/dist/Components/Forms/CrudParts.js +55 -5
  22. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  23. package/dist/Components/Forms/CrudRowForms.js +114 -0
  24. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  25. package/dist/Components/Forms/CrudScopes.js +76 -0
  26. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  27. package/dist/Components/Forms/CrudTree.js +363 -0
  28. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  29. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  30. package/dist/Components/Forms/ExcelImport.js +3 -3
  31. package/dist/Components/Forms/Form.d.ts +4 -4
  32. package/dist/Components/Forms/Form.js +123 -17
  33. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  34. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  35. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  36. package/dist/Components/Forms/RecordFlows.js +282 -0
  37. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  38. package/dist/Components/Forms/SubmitForm.js +195 -10
  39. package/dist/Components/Inputs/CheckBox.js +1 -1
  40. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  41. package/dist/Components/Inputs/ChoiceField.js +28 -2
  42. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  43. package/dist/Components/Inputs/Dropdown.js +5 -5
  44. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  45. package/dist/Components/Inputs/DropdownParts.js +16 -4
  46. package/dist/Components/Inputs/Field.d.ts +7 -1
  47. package/dist/Components/Inputs/Field.js +2 -2
  48. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  49. package/dist/Components/Inputs/GroupForm.js +10 -11
  50. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  51. package/dist/Components/Inputs/InputRenderer.js +37 -32
  52. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  53. package/dist/Components/Inputs/PictureInput.js +70 -0
  54. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  55. package/dist/Components/Inputs/RadioGroup.js +8 -3
  56. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  57. package/dist/Components/Inputs/RowsInput.js +18 -3
  58. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  59. package/dist/Components/Inputs/TabularForm.js +29 -29
  60. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  61. package/dist/Components/Inputs/TextArea.js +11 -2
  62. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  63. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  64. package/dist/Components/Inputs/TreeView.d.ts +53 -2
  65. package/dist/Components/Inputs/TreeView.js +147 -13
  66. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  67. package/dist/Components/Layouts/MessageBox.js +56 -7
  68. package/dist/Components/Layouts/Popup.d.ts +13 -6
  69. package/dist/Components/Layouts/Popup.js +37 -9
  70. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  71. package/dist/Components/Layouts/PopupDock.js +2 -1
  72. package/dist/Components/Layouts/PopupHost.js +5 -4
  73. package/dist/Components/Viewers/DataTable.js +150 -48
  74. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  75. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  76. package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
  77. package/dist/Components/Viewers/DataTableParts.js +26 -14
  78. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  79. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  80. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  81. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  82. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  83. package/dist/Components/Viewers/RecordCards.js +509 -0
  84. package/dist/Helpers/BusyHooks.d.ts +6 -0
  85. package/dist/Helpers/BusyHooks.js +39 -0
  86. package/dist/Helpers/CrudBackend.d.ts +20 -2
  87. package/dist/Helpers/CrudBackend.js +46 -2
  88. package/dist/Helpers/CrudColumns.d.ts +53 -6
  89. package/dist/Helpers/CrudColumns.js +98 -7
  90. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  91. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  92. package/dist/Helpers/CrudHelpers.d.ts +81 -2
  93. package/dist/Helpers/CrudHelpers.js +195 -0
  94. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  95. package/dist/Helpers/CrudNarrowPick.js +157 -0
  96. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  97. package/dist/Helpers/CrudRowForms.js +43 -0
  98. package/dist/Helpers/CrudScopes.d.ts +32 -0
  99. package/dist/Helpers/CrudScopes.js +79 -0
  100. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  101. package/dist/Helpers/DropdownHelpers.js +6 -2
  102. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  103. package/dist/Helpers/ExcelHelpers.js +12 -11
  104. package/dist/Helpers/FileAdapter.d.ts +78 -0
  105. package/dist/Helpers/FileAdapter.js +102 -0
  106. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  107. package/dist/Helpers/FilePrimaries.js +138 -0
  108. package/dist/Helpers/FormConfirm.d.ts +32 -0
  109. package/dist/Helpers/FormConfirm.js +40 -0
  110. package/dist/Helpers/FormStore.d.ts +90 -5
  111. package/dist/Helpers/FormStore.js +318 -22
  112. package/dist/Helpers/InputConditions.d.ts +50 -0
  113. package/dist/Helpers/InputConditions.js +168 -0
  114. package/dist/Helpers/InputMemory.d.ts +106 -0
  115. package/dist/Helpers/InputMemory.js +385 -0
  116. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  117. package/dist/Helpers/InputNarrowing.js +67 -0
  118. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  119. package/dist/Helpers/InputParamsHelpers.js +157 -74
  120. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  121. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  122. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  123. package/dist/Helpers/NumberHelpers.js +126 -0
  124. package/dist/Helpers/Permissions.js +2 -1
  125. package/dist/Helpers/QrCode.d.ts +26 -0
  126. package/dist/Helpers/QrCode.js +463 -0
  127. package/dist/Helpers/RecordCards.d.ts +205 -0
  128. package/dist/Helpers/RecordCards.js +378 -0
  129. package/dist/Helpers/RequestCache.d.ts +14 -2
  130. package/dist/Helpers/RequestCache.js +22 -3
  131. package/dist/Helpers/RowsStore.d.ts +37 -2
  132. package/dist/Helpers/RowsStore.js +128 -10
  133. package/dist/Helpers/ServerPreview.d.ts +69 -0
  134. package/dist/Helpers/ServerPreview.js +99 -0
  135. package/dist/Helpers/TableColumns.js +4 -3
  136. package/dist/Helpers/TableExport.d.ts +7 -1
  137. package/dist/Helpers/TableExport.js +9 -3
  138. package/dist/Helpers/TableFilters.d.ts +1 -1
  139. package/dist/Helpers/TableFilters.js +24 -23
  140. package/dist/Helpers/TableHelpers.d.ts +23 -2
  141. package/dist/Helpers/TableHelpers.js +51 -10
  142. package/dist/Helpers/TableJoins.d.ts +13 -0
  143. package/dist/Helpers/TableJoins.js +34 -0
  144. package/dist/Helpers/TableStore.d.ts +21 -1
  145. package/dist/Helpers/TableStore.js +64 -7
  146. package/dist/Helpers/TreeStore.d.ts +81 -0
  147. package/dist/Helpers/TreeStore.js +365 -0
  148. package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
  149. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  150. package/dist/Interfaces/FileInterfaces.js +4 -0
  151. package/dist/Interfaces/FormInterfaces.d.ts +391 -30
  152. package/dist/Interfaces/FormInterfaces.js +79 -1
  153. package/dist/Interfaces/InputInterfaces.d.ts +15 -3
  154. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  155. package/dist/Interfaces/RecordInterfaces.js +1 -0
  156. package/dist/Interfaces/TableInterfaces.d.ts +74 -1
  157. package/dist/Services/ExcelApi.js +5 -0
  158. package/dist/Services/FileApi.d.ts +13 -0
  159. package/dist/Services/FileApi.js +29 -0
  160. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  161. package/dist/Services/ImageEditorLoader.js +51 -0
  162. package/dist/Services/JoinSources.d.ts +37 -5
  163. package/dist/Services/JoinSources.js +90 -14
  164. package/dist/Services/ModuleImport.d.ts +7 -0
  165. package/dist/Services/ModuleImport.js +22 -0
  166. package/dist/Shared.Index.d.ts +16 -0
  167. package/dist/Shared.Index.js +16 -0
  168. package/package.json +7 -2
  169. package/src/Styles/Nexus.Avatar.css +6 -5
  170. package/src/Styles/Nexus.Button.css +10 -4
  171. package/src/Styles/Nexus.Choice.css +16 -0
  172. package/src/Styles/Nexus.Crud.css +383 -9
  173. package/src/Styles/Nexus.Dropdown.css +8 -0
  174. package/src/Styles/Nexus.Files.css +410 -0
  175. package/src/Styles/Nexus.Form.css +5 -2
  176. package/src/Styles/Nexus.Index.css +4 -0
  177. package/src/Styles/Nexus.Input.css +42 -0
  178. package/src/Styles/Nexus.Menu.css +10 -1
  179. package/src/Styles/Nexus.Popup.css +77 -1
  180. package/src/Styles/Nexus.Print.css +288 -0
  181. package/src/Styles/Nexus.QrCode.css +12 -0
  182. package/src/Styles/Nexus.Record.css +430 -0
  183. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  184. package/src/Styles/Nexus.Table.css +67 -5
  185. package/src/Styles/Nexus.Themes.css +12 -6
  186. package/src/Styles/Nexus.Tokens.css +8 -3
  187. package/src/Styles/Nexus.Tree.css +31 -1
@@ -0,0 +1,282 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconLoader2, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
+ import { apiRequestOptions, toApiResult } from "../../Helpers/ApiFormHelpers.js";
6
+ import { readApiResponse } from "../../Helpers/ApiResponses.js";
7
+ import { isApiEndpoint } from "../../Helpers/ApiRoutes.js";
8
+ import { FormStore } from "../../Helpers/FormStore.js";
9
+ import { recordOpenedValues, recordStartStep, recordStatusLine, recordSteps, secondsUntil } from "../../Helpers/RecordCards.js";
10
+ import { api as browserApi, getApiReader } from "../../Services/BrowserApi.js";
11
+ import { Button } from "../Documents/Button.js";
12
+ import { messageBox } from "../Layouts/MessageBox.js";
13
+ import { openPopup } from "../Layouts/PopupHost.js";
14
+ import { toast } from "../Layouts/Toaster.js";
15
+ import { ApiForm } from "./ApiForm.js";
16
+ import { confirmDiscard, FormButtons } from "./SubmitForm.js";
17
+ /*
18
+ * The popups of a record's page of cards (`RecordCards`): a card's Edit, a list's Add and an item's actions open a popup
19
+ * of one step or several (a code sent, then typed), each step a form whose answer is written into the card's data at
20
+ * once; a Remove and the like ask first, then call. The page holds the data; the popup asks it for the data as held now
21
+ * every time it draws, so a write made meanwhile (another popup, a reload) is what the next step sees and sends.
22
+ */
23
+ /* eslint-disable @typescript-eslint/no-explicit-any */
24
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+ /** Where a change goes, sent through the app's `api` without its toasts: an endpoint, a URL (POST), a request, or a function. */
26
+ export async function sendRecordCall(submit, body) {
27
+ try {
28
+ if (typeof submit === "function")
29
+ return await toApiResult(await submit(body), getApiReader() ?? readApiResponse);
30
+ if (isApiEndpoint(submit))
31
+ return await browserApi.send(submit, { body, toast: false });
32
+ if (typeof submit === "string")
33
+ return await browserApi.send(submit, { method: "POST", body: body ?? {}, toast: false });
34
+ return await browserApi.send(submit.url, { ...apiRequestOptions(submit, body), toast: false });
35
+ }
36
+ catch (error) {
37
+ return { ok: false, message: error instanceof Error ? error.message : "The server did not answer." };
38
+ }
39
+ }
40
+ /** A step with nowhere to send its values says so, rather than saving nothing. */
41
+ const NOWHERE = async () => ({ ok: false, message: "This step has nowhere to send its values." });
42
+ /** Which step a popup shows, and the answer that led to it. */
43
+ function flowState(initial) {
44
+ let state = initial;
45
+ const listeners = new Set();
46
+ return {
47
+ get: () => state,
48
+ set(next) {
49
+ state = next;
50
+ for (const listener of listeners)
51
+ listener();
52
+ },
53
+ subscribe(listener) {
54
+ listeners.add(listener);
55
+ return () => {
56
+ listeners.delete(listener);
57
+ };
58
+ },
59
+ };
60
+ }
61
+ /**
62
+ * Opens a record page's popup: one per key, brought back as it was when it is open. Each step is a form (none of its
63
+ * inputs is required: a step can be its sentence and its button); its answer is written into the card's data, then the
64
+ * step's `next` shows, or the popup closes and `done` runs. Closing with changes asks first, unless the step says there
65
+ * is nothing worth keeping.
66
+ */
67
+ export function openRecordFlow(request) {
68
+ const { flow, host, key, kind } = request;
69
+ const steps = recordSteps(flow);
70
+ const state = flowState({ step: recordStartStep(flow, host.context()), result: undefined, round: 0 });
71
+ // A form per step, kept while the popup is open, so a step shown again has what was typed in it - unless it is shown
72
+ // afresh (`fresh`), which drops its form: a new one opens on the step's values again.
73
+ const forms = new Map();
74
+ const formOf = (id) => {
75
+ let form = forms.get(id);
76
+ if (!form)
77
+ forms.set(id, (form = new FormStore()));
78
+ return form;
79
+ };
80
+ const shown = new Set();
81
+ let rounds = 0;
82
+ const stepOf = (id) => steps.find(step => step.id === id) ?? steps[0];
83
+ let popup = null;
84
+ const contextOf = () => ({
85
+ ...host.context(),
86
+ step: state.get().step,
87
+ result: state.get().result,
88
+ form: formOf(state.get().step),
89
+ goTo: (id, options) => go(id, state.get().result, options),
90
+ close: () => popup?.close(),
91
+ });
92
+ const resolve = (value) => (typeof value === "function" ? value(contextOf()) : value);
93
+ const inputsOf = (step) => (step.inputs === undefined ? (kind === "edit" ? (request.inputs ?? []) : []) : typeof step.inputs === "function" ? step.inputs(contextOf()) : step.inputs);
94
+ const heading = () => {
95
+ const step = stepOf(state.get().step);
96
+ return { title: resolve(step.title ?? flow.title) ?? request.title, description: resolve(step.description ?? flow.description) ?? request.description };
97
+ };
98
+ function go(id, result, options) {
99
+ const step = steps.find(one => one.id === id);
100
+ if (!step)
101
+ return;
102
+ const fresh = options?.fresh ?? (step.fresh === true && shown.has(id));
103
+ if (fresh)
104
+ forms.delete(id);
105
+ shown.add(id);
106
+ state.set({ step: id, result, round: fresh ? ++rounds : state.get().round });
107
+ popup?.update(heading());
108
+ }
109
+ shown.add(state.get().step);
110
+ async function saved(step, result, values) {
111
+ await host.write({ writes: step.writes, result, values, fallback: kind === "edit" ? "values" : "reload" });
112
+ const answer = { ...contextOf(), result, values };
113
+ if (typeof step.successMessage === "function") {
114
+ const message = step.successMessage(answer);
115
+ if (message !== false && message !== null && message !== undefined && message !== "")
116
+ toast.success(message);
117
+ }
118
+ const next = typeof step.next === "function" ? step.next(answer) : step.next;
119
+ if (next && steps.some(one => one.id === next))
120
+ return go(next, result);
121
+ const closing = popup;
122
+ closing?.close();
123
+ if (step.done && closing)
124
+ void closing.closed.then(() => step.done?.({ ...contextOf(), result, values }));
125
+ }
126
+ const size = flow.size ?? (kind === "edit" && inputsOf(stepOf(state.get().step)).length > 2 ? "md" : "sm");
127
+ popup = openPopup({
128
+ key,
129
+ reopen: "restore",
130
+ ...heading(),
131
+ icon: flow.icon,
132
+ size,
133
+ closeOnBackdrop: false,
134
+ beforeClose: () => {
135
+ const step = stepOf(state.get().step);
136
+ return step.confirmCancel === false ? true : confirmDiscard(formOf(step.id));
137
+ },
138
+ content: () => _jsx(RecordFlowBody, { state: state, stepOf: stepOf, formOf: formOf, inputsOf: inputsOf, contextOf: contextOf, resolve: resolve, host: host, kind: kind, onSaved: saved }),
139
+ footer: handle => _jsx(RecordFlowFooter, { state: state, stepOf: stepOf, formOf: formOf, inputsOf: inputsOf, onCancel: () => handle.close() }),
140
+ });
141
+ host.track(popup);
142
+ return popup;
143
+ }
144
+ /** What a step works on: its own values, else the card's data for an Edit, the item for an item's step, nothing for an Add. */
145
+ function workedOn(step, context, kind) {
146
+ const given = step.values ? step.values(context) : kind === "edit" ? context.data : kind === "item" ? context.item : undefined;
147
+ return isRecord(given) ? given : {};
148
+ }
149
+ /**
150
+ * The values a step's form opens on: its own `values` as given; else, of the record or the item, only what its inputs
151
+ * name and an input can hold (a code step's `code` box never opens on the item's `code` record).
152
+ */
153
+ function openedOn(step, context, kind, inputs) {
154
+ const given = workedOn(step, context, kind);
155
+ return step.values ? given : recordOpenedValues(given, inputs);
156
+ }
157
+ /** The step shown: its sentence, its form, the button that stays on it, and the page's own part. */
158
+ function RecordFlowBody({ state, stepOf, formOf, inputsOf, contextOf, resolve, host, kind, onSaved, }) {
159
+ // Drawn again on every write of the page's data, so the sentence and the item are as held now.
160
+ const version = useSyncExternalStore(host.subscribe, host.snapshot, host.snapshot);
161
+ const { step: id, round } = useSyncExternalStore(state.subscribe, state.get, state.get);
162
+ const step = stepOf(id);
163
+ const context = contextOf();
164
+ // Inputs of a function follow the data (and the form, which a step shown afresh has new).
165
+ const inputs = useMemo(() => inputsOf(step), [step, round, typeof step.inputs === "function" ? version : null]); // eslint-disable-line react-hooks/exhaustive-deps
166
+ const opened = useMemo(() => openedOn(step, context, kind, inputs), [id, round]); // eslint-disable-line react-hooks/exhaustive-deps
167
+ const rootRef = useRef(null);
168
+ const shownBefore = useRef(false);
169
+ // A step shown after another takes the cursor, where its own input did not: its first box, else its first button.
170
+ useLayoutEffect(() => {
171
+ if (!shownBefore.current) {
172
+ shownBefore.current = true;
173
+ return;
174
+ }
175
+ const root = rootRef.current;
176
+ const frame = window.requestAnimationFrame(() => {
177
+ if (!root?.isConnected || root.contains(document.activeElement))
178
+ return;
179
+ const dialog = root.closest("dialog");
180
+ dialog?.querySelector(".nx-popup__body :is(input, textarea, select, [contenteditable='true']):not([disabled]), .nx-popup__foot .nx-btn--primary, .nx-popup__foot .nx-btn--danger")?.focus();
181
+ });
182
+ return () => window.cancelAnimationFrame(frame);
183
+ }, [id, round]);
184
+ const lead = resolve(step.lead);
185
+ const title = resolve(step.title) ?? null;
186
+ const sent = (values) => {
187
+ if (step.transform)
188
+ return step.transform(values, contextOf());
189
+ if (kind === "add" && !step.values)
190
+ return values;
191
+ const now = contextOf();
192
+ return { ...workedOn(step, now, kind), ...values };
193
+ };
194
+ return (_jsxs("div", { ref: rootRef, className: "nx-record-step", children: [lead !== undefined && lead !== null && lead !== false && lead !== "" && _jsx("div", { className: "nx-record-step__lead", children: typeof lead === "string" ? _jsx("p", { children: lead }) : lead }), _jsx(ApiForm, { form: formOf(id), inputs: inputs, defaultValue: opened, validate: step.validate ? values => step.validate(values, contextOf()) : undefined,
195
+ // A step with nothing worth keeping (a code step) never asks before the page is left, as its Cancel does not.
196
+ confirmLeave: step.confirmCancel !== false, api: typeof step.submit === "string" ? { url: step.submit } : (step.submit ?? NOWHERE), transform: values => sent(values), buttons: false, successMessage: typeof step.successMessage === "function" ? false : step.successMessage, "aria-label": typeof title === "string" ? title : undefined, onSuccess: (result, values) => void onSaved(step, result, values), onError: answer => {
197
+ if (!host.ended(answer))
198
+ step.onError?.(answer, contextOf());
199
+ } }, `${id}:${round}`), step.also && _jsx(RecordCall, { call: step.also, context: context, host: host, form: formOf(id) }, `${id}:${round}:also`), step.render?.(context)] }));
200
+ }
201
+ /** The step's bar: Cancel, and its Save under its own name. */
202
+ function RecordFlowFooter({ state, stepOf, formOf, inputsOf, onCancel }) {
203
+ const { step: id, round } = useSyncExternalStore(state.subscribe, state.get, state.get);
204
+ const step = stepOf(id);
205
+ return (_jsx(FormButtons, { form: formOf(id), submitLabel: step.submitLabel ?? "Save",
206
+ // A floppy disk says Save; a step of its own name ("Send a code") has none unless it gives one.
207
+ submitIcon: step.submitIcon !== undefined ? step.submitIcon : step.submitLabel === undefined ? undefined : null, submitTone: step.submitTone, statusLine: recordStatusLine(inputsOf(step).length), confirmCancel: step.confirmCancel !== false, onCancel: onCancel }, `${id}:${round}`));
208
+ }
209
+ /**
210
+ * A button in a step that calls an endpoint and stays: "Send a new code". While the server says to wait it counts the
211
+ * seconds down in its label, held (`aria-disabled`, so the focus stays on it); while it calls, a spinner.
212
+ */
213
+ function RecordCall({ call, context, host, form }) {
214
+ const until = call.waitUntil?.(context) ?? null;
215
+ const [now, setNow] = useState(() => Date.now());
216
+ const [busy, setBusy] = useState(false);
217
+ const live = useRef(true);
218
+ useEffect(() => {
219
+ live.current = true;
220
+ return () => {
221
+ live.current = false;
222
+ };
223
+ }, []);
224
+ useEffect(() => {
225
+ setNow(Date.now());
226
+ if (secondsUntil(until) <= 0)
227
+ return;
228
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
229
+ return () => window.clearInterval(timer);
230
+ }, [until]);
231
+ const wait = secondsUntil(until, now);
232
+ const held = busy || wait > 0;
233
+ async function press() {
234
+ if (held)
235
+ return;
236
+ setBusy(true);
237
+ const answer = await sendRecordCall(call.submit, call.transform?.(context));
238
+ if (answer.ok) {
239
+ await host.write({ writes: call.writes, result: answer.result, values: undefined, fallback: "reload" });
240
+ if (call.successMessage !== false)
241
+ toast.success(call.successMessage ?? answer.message ?? "Done");
242
+ if (call.focus)
243
+ form.focus(call.focus);
244
+ }
245
+ else if (!host.ended(answer))
246
+ toast.error(answer.message ?? "It did not work just now. Try again in a moment.");
247
+ if (live.current)
248
+ setBusy(false);
249
+ }
250
+ const label = typeof call.label === "function" ? call.label(context) : call.label;
251
+ return (_jsx("p", { className: "nx-record-step__also", children: _jsx(Button, { size: "sm", variant: "ghost", icon: busy ? _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) : call.icon, "aria-disabled": held || undefined, "aria-busy": busy || undefined, onClick: () => void press(), children: wait > 0 ? (call.waitLabel?.(wait) ?? `Again in ${wait}s`) : label }) }));
252
+ }
253
+ /**
254
+ * A change that needs no form: the question first ("Remove ram@example.com?"), then the call, its answer written into
255
+ * the card's data. `busy` marks the item while it runs. A refusal is said in a toast, in the server's words.
256
+ */
257
+ export async function runRecordConfirm(confirm, host, busy) {
258
+ const asked = host.context();
259
+ const resolve = (value) => (typeof value === "function" ? value(asked) : value);
260
+ const tone = confirm.tone ?? "danger";
261
+ const sure = await messageBox.confirm({ title: resolve(confirm.title), message: resolve(confirm.message), tone, confirmLabel: confirm.confirmLabel ?? (tone === "danger" ? "Remove" : "OK") });
262
+ if (!sure)
263
+ return;
264
+ busy(true);
265
+ try {
266
+ const now = host.context();
267
+ const answer = await sendRecordCall(confirm.submit, confirm.transform ? confirm.transform(now) : (now.item ?? now.data));
268
+ if (answer.ok) {
269
+ await host.write({ writes: confirm.writes, result: answer.result, values: undefined, fallback: "reload" });
270
+ const after = host.context();
271
+ const message = typeof confirm.successMessage === "function" ? confirm.successMessage(after) : confirm.successMessage;
272
+ if (message !== false)
273
+ toast.success(message ?? answer.message ?? (tone === "danger" ? "Removed" : "Done"));
274
+ confirm.done?.({ ...after, result: answer.result });
275
+ }
276
+ else if (!host.ended(answer))
277
+ toast.error(answer.message ?? "It could not be done just now. Try again in a moment.");
278
+ }
279
+ finally {
280
+ busy(false);
281
+ }
282
+ }
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { AnyFormStore, FormStore } from "../../Helpers/FormStore.ts";
3
- import type { FormStatusLineMode, FormValues, SubmitFormProps } from "../../Interfaces/FormInterfaces.ts";
3
+ import type { FormStatusLineMode, FormSubmitTone, FormValues, SubmitFormProps } from "../../Interfaces/FormInterfaces.ts";
4
4
  import type { Size } from "../../Interfaces/TypeInterfaces.ts";
5
5
  export interface FormButtonsProps<TValues extends FormValues = FormValues> {
6
6
  /** The form's store: the one given to the form as `form`. */
@@ -9,6 +9,11 @@ export interface FormButtonsProps<TValues extends FormValues = FormValues> {
9
9
  submitLabel?: ReactNode;
10
10
  /** Default: a floppy disk. `null` shows none. */
11
11
  submitIcon?: ReactNode | null;
12
+ /**
13
+ * The submit button's fill: `primary` (default), or `danger` for a form whose save removes something - "Remove",
14
+ * "Turn two-step off" - filled with the danger colour and its light text, as every solid danger fill is.
15
+ */
16
+ submitTone?: FormSubmitTone;
12
17
  /** Shows a Cancel button. */
13
18
  onCancel?: () => void;
14
19
  /** Cancel asks before it drops changed values. Default true. */
@@ -40,6 +45,36 @@ export interface FormButtonsProps<TValues extends FormValues = FormValues> {
40
45
  * Asks "Discard your changes?" when the form has changes. Resolves `true` to go on (nothing changed, or the user chose
41
46
  * Discard), `false` to keep editing. Cancel buttons and popups that close a form ask it.
42
47
  */
48
+ /**
49
+ * Asks before a navigation the app makes itself takes forms away: "Discard your changes?" while one of them has
50
+ * changes, the question a link inside the app asks. Answers whether to go on.
51
+ *
52
+ * - Default, a move inside the app (`router.push` from a menu pick): the page's own forms are asked about; a form drawn in
53
+ * a `PopupHost` popup stays open through such a move and is not.
54
+ * - `unload: true`, a move that loads a whole page (a sign-out, `location.assign`): every form is asked about, the
55
+ * popups' too, since the load takes them all; once it is confirmed nothing asks again - neither this nor the browser's
56
+ * close question - until the page goes, however long the call before the move takes. A move that then does not happen
57
+ * (the call failed) gives the questions back with `endPageLeave()`.
58
+ *
59
+ * @example if (await confirmPageLeave()) router.push(href);
60
+ * @example
61
+ * if (!(await confirmPageLeave({ unload: true }))) return;
62
+ * const answer = await api.post(SIGN_OUT);
63
+ * if (answer.ok) window.location.assign("/");
64
+ * else endPageLeave();
65
+ */
66
+ export declare function confirmPageLeave(options?: {
67
+ unload?: boolean;
68
+ }): Promise<boolean>;
69
+ /** Gives the leave questions back after `confirmPageLeave({ unload: true })` when the page did not go after all. */
70
+ export declare function endPageLeave(): void;
71
+ /**
72
+ * Whether a move to `href` leaves nothing of this page behind in this tab, so no form is lost and nothing is asked: one
73
+ * for another tab or a download, one to another site (the browser's close question asks there), one to this page
74
+ * itself or a place on it. The same test for a link and for a menu row the app moves by `router.push`.
75
+ */
76
+ export declare function keepsPage(href: string, target?: string | null, download?: boolean): boolean;
77
+ /** Asks "Discard your changes?" when the form holds changes worth keeping (`isWorthKeeping`): true to go on. */
43
78
  export declare function confirmDiscard(form: AnyFormStore): Promise<boolean>;
44
79
  /**
45
80
  * A form's bar: its status line (loading, saving, what needs attention, unsaved changes, saved), Reset, Cancel, and Save.
@@ -52,7 +87,7 @@ export declare function confirmDiscard(form: AnyFormStore): Promise<boolean>;
52
87
  * <ApiForm form={form} buttons={false} inputs={inputs} api={api} />
53
88
  * </Popup>
54
89
  */
55
- export declare function FormButtons<TValues extends FormValues = FormValues>({ form, submitLabel, submitIcon, onCancel, confirmCancel, cancelLabel, resettable, resetLabel, align, size, readOnly, statusLine, block, loadingLabel, className, }: FormButtonsProps<TValues>): import("react").JSX.Element;
90
+ export declare function FormButtons<TValues extends FormValues = FormValues>({ form, submitLabel, submitIcon, submitTone, onCancel, confirmCancel, cancelLabel, resettable, resetLabel, align, size, readOnly, statusLine, block, loadingLabel, className, }: FormButtonsProps<TValues>): import("react").JSX.Element;
56
91
  /**
57
92
  * A `Form` that submits: every value is checked on Save (the inputs' own rules, the form's `validate`, rows not on the
58
93
  * page), each message shows, and the first problem takes focus; then `onSubmit` gets the values, and while it runs Save
@@ -1,13 +1,17 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { iconAlertCircle, iconCircleCheck, iconDeviceFloppy, iconLoader2, iconRefresh, InlineIcon } from "nexus-icons";
4
- import { useEffect, useId, useLayoutEffect, useRef } from "react";
4
+ import { useContext, useEffect, useId, useLayoutEffect, useRef } from "react";
5
5
  import { errorMessage, isAbortError } from "../../Helpers/ApiResponses.js";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  import { useDatePreferences } from "../../Helpers/DatePreferences.js";
8
+ import { formConfirmAsks, formConfirmQuestion } from "../../Helpers/FormConfirm.js";
9
+ import { FORM_SAVED_EVENT } from "../../Helpers/InputMemory.js";
8
10
  import { Button } from "../Documents/Button.js";
9
11
  import { messageBox } from "../Layouts/MessageBox.js";
12
+ import { CrudDetails } from "./CrudParts.js";
10
13
  import { Form, formatFormTime, formInputLabel, namesToShow, useFormStatus, useFormStore } from "./Form.js";
14
+ import { FormLeaveGuardContext, pageLeaveGuard, PageLeaveGuardContext, unloadLeaveGuard } from "./FormLeaveGuard.js";
11
15
  /** The problems after a submit, each a button that goes to its input: "2 fields need attention: Email, Phone." */
12
16
  function ProblemList({ form, names }) {
13
17
  const { shown, more } = namesToShow(names);
@@ -50,8 +54,129 @@ function FormStatusLine({ form, status, mode }) {
50
54
  * Asks "Discard your changes?" when the form has changes. Resolves `true` to go on (nothing changed, or the user chose
51
55
  * Discard), `false` to keep editing. Cancel buttons and popups that close a form ask it.
52
56
  */
57
+ /**
58
+ * Asks before a navigation the app makes itself takes forms away: "Discard your changes?" while one of them has
59
+ * changes, the question a link inside the app asks. Answers whether to go on.
60
+ *
61
+ * - Default, a move inside the app (`router.push` from a menu pick): the page's own forms are asked about; a form drawn in
62
+ * a `PopupHost` popup stays open through such a move and is not.
63
+ * - `unload: true`, a move that loads a whole page (a sign-out, `location.assign`): every form is asked about, the
64
+ * popups' too, since the load takes them all; once it is confirmed nothing asks again - neither this nor the browser's
65
+ * close question - until the page goes, however long the call before the move takes. A move that then does not happen
66
+ * (the call failed) gives the questions back with `endPageLeave()`.
67
+ *
68
+ * @example if (await confirmPageLeave()) router.push(href);
69
+ * @example
70
+ * if (!(await confirmPageLeave({ unload: true }))) return;
71
+ * const answer = await api.post(SIGN_OUT);
72
+ * if (answer.ok) window.location.assign("/");
73
+ * else endPageLeave();
74
+ */
75
+ export async function confirmPageLeave(options = {}) {
76
+ if (options.unload) {
77
+ if (pageLeaveGuard.unloadAllowed)
78
+ return true;
79
+ const form = unloadLeaveGuard.changed();
80
+ if (form && !(await confirmDiscard(form)))
81
+ return false;
82
+ pageLeaveGuard.allowUnload();
83
+ return true;
84
+ }
85
+ if (pageLeaveGuard.allowed)
86
+ return true;
87
+ const form = pageLeaveGuard.changed();
88
+ if (!form)
89
+ return true;
90
+ const go = await confirmDiscard(form);
91
+ if (go)
92
+ pageLeaveGuard.allow();
93
+ return go;
94
+ }
95
+ /** Gives the leave questions back after `confirmPageLeave({ unload: true })` when the page did not go after all. */
96
+ export function endPageLeave() {
97
+ pageLeaveGuard.end();
98
+ }
99
+ /**
100
+ * Whether a move to `href` leaves nothing of this page behind in this tab, so no form is lost and nothing is asked: one
101
+ * for another tab or a download, one to another site (the browser's close question asks there), one to this page
102
+ * itself or a place on it. The same test for a link and for a menu row the app moves by `router.push`.
103
+ */
104
+ export function keepsPage(href, target, download = false) {
105
+ if (typeof window === "undefined")
106
+ return true;
107
+ if ((target && target !== "_self") || download)
108
+ return true;
109
+ const to = new URL(href, window.location.href);
110
+ if (to.origin !== window.location.origin)
111
+ return true;
112
+ return to.pathname === window.location.pathname && to.search === window.location.search;
113
+ }
114
+ let linksGuarded = false;
115
+ /**
116
+ * Every link inside the app asks before it takes the page's own forms away: a press on one - next/link or a plain `<a>`,
117
+ * a menu's, the brand's, the side menu's - is held first (in the capture phase, before any other handler) while a
118
+ * form has changes, and followed once "Discard" is chosen. Set up once, by the first form that asks before leaving.
119
+ */
120
+ function guardLinks() {
121
+ if (linksGuarded || typeof window === "undefined")
122
+ return;
123
+ linksGuarded = true;
124
+ window.addEventListener("click", event => {
125
+ if (pageLeaveGuard.allowed || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
126
+ return;
127
+ const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
128
+ if (!(link instanceof HTMLAnchorElement) || keepsPage(link.href, link.target, link.hasAttribute("download")) || !pageLeaveGuard.changed())
129
+ return;
130
+ // A link inside text being edited (a rich-text editor's) is never followed by the browser: a click there places
131
+ // the caret, and nothing would be left.
132
+ if (link.isContentEditable || link.closest('[contenteditable]:not([contenteditable="false"])'))
133
+ return;
134
+ event.preventDefault();
135
+ event.stopPropagation();
136
+ void confirmPageLeave().then(go => {
137
+ if (!go)
138
+ return;
139
+ // Followed as it was pressed (the app's router for its own links), or by its address if it has gone meanwhile.
140
+ if (link.isConnected)
141
+ link.click();
142
+ else
143
+ window.location.assign(link.href);
144
+ });
145
+ }, true);
146
+ }
147
+ /** Ctrl+S (or ⌘S) while a form's question is open does nothing: neither a second save nor the browser's Save page. */
148
+ function holdSaveKey(event) {
149
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "s")
150
+ event.preventDefault();
151
+ }
152
+ /**
153
+ * A form's question before it saves (`SubmitFormProps.confirm`): asked when `when` holds, with what will be sent worked
154
+ * out first (`makeBody`, an ApiForm's transform) and handed to every text. Resolves whether to save.
155
+ */
156
+ async function askBeforeSaving(confirm, values, context, makeBody) {
157
+ if (!formConfirmAsks(confirm, values))
158
+ return true;
159
+ const body = makeBody ? await makeBody(values, context) : values;
160
+ if (context.signal.aborted)
161
+ return false;
162
+ const question = formConfirmQuestion(confirm, values, body);
163
+ const details = question.details;
164
+ return messageBox.confirm({
165
+ tone: "question",
166
+ closable: true,
167
+ closeOnBackdrop: false,
168
+ signal: context.signal,
169
+ size: question.size,
170
+ title: question.title,
171
+ message: question.message,
172
+ details: details ? _jsx(CrudDetails, { row: details.row, columns: details.columns, currency: details.currency }) : null,
173
+ confirmLabel: question.confirmLabel,
174
+ cancelLabel: question.cancelLabel,
175
+ });
176
+ }
177
+ /** Asks "Discard your changes?" when the form holds changes worth keeping (`isWorthKeeping`): true to go on. */
53
178
  export async function confirmDiscard(form) {
54
- if (!form.isChanged())
179
+ if (!form.isWorthKeeping())
55
180
  return true;
56
181
  return messageBox.confirm({
57
182
  title: "Discard your changes?",
@@ -72,7 +197,7 @@ export async function confirmDiscard(form) {
72
197
  * <ApiForm form={form} buttons={false} inputs={inputs} api={api} />
73
198
  * </Popup>
74
199
  */
75
- export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel, confirmCancel = true, cancelLabel = "Cancel", resettable = false, resetLabel = "Reset", align = "end", size = "md", readOnly = false, statusLine = "full", block = false, loadingLabel = "Loading…", className, }) {
200
+ export function FormButtons({ form, submitLabel = "Save", submitIcon, submitTone = "primary", onCancel, confirmCancel = true, cancelLabel = "Cancel", resettable = false, resetLabel = "Reset", align = "end", size = "md", readOnly = false, statusLine = "full", block = false, loadingLabel = "Loading…", className, }) {
76
201
  const store = form;
77
202
  const status = useFormStatus(store);
78
203
  useEffect(() => store.addButtons(), [store]);
@@ -88,7 +213,7 @@ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel,
88
213
  const actions = [
89
214
  resettable && !readOnly && (_jsx(Button, { size: buttonSize, variant: "ghost", icon: _jsx(InlineIcon, { icon: iconRefresh }), disabled: !status.changed || status.submitting || status.loading, onClick: () => store.reset(), children: resetLabel }, "reset")),
90
215
  onCancel && (_jsx(Button, { size: buttonSize, onClick: () => void cancel(), children: cancelLabel }, "cancel")),
91
- !readOnly && (_jsx(Button, { type: "submit", form: status.formId ?? undefined, size: buttonSize, variant: "primary", loading: status.submitting || loadingInButton, disabled: status.loading, icon: submitIcon === undefined ? _jsx(InlineIcon, { icon: iconDeviceFloppy }) : (submitIcon ?? undefined), children: loadingInButton ? loadingLabel : submitLabel }, "submit")),
216
+ !readOnly && (_jsx(Button, { type: "submit", form: status.formId ?? undefined, size: buttonSize, variant: submitTone === "danger" ? "danger" : "primary", loading: status.submitting || loadingInButton, disabled: status.loading, icon: submitIcon === undefined ? _jsx(InlineIcon, { icon: iconDeviceFloppy }) : (submitIcon ?? undefined), children: loadingInButton ? loadingLabel : submitLabel }, "submit")),
92
217
  ].filter(Boolean);
93
218
  if (align === "start")
94
219
  actions.reverse();
@@ -115,12 +240,14 @@ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel,
115
240
  * />
116
241
  */
117
242
  export function SubmitForm(props) {
118
- const { onSubmit: _onSubmit, onSaved: _onSaved, onCancel, confirmCancel, submitLabel, submitIcon, cancelLabel, resettable, resetLabel, resetOnSuccess: _resetOnSuccess, buttons = "end", statusLine, confirmLeave, id, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...formProps } = props;
243
+ const { onSubmit: _onSubmit, onSaved: _onSaved, onCancel, confirmCancel, submitLabel, submitIcon, submitTone, cancelLabel, resettable, resetLabel, resetOnSuccess: _resetOnSuccess, buttons = "end", statusLine, confirmLeave, confirm: _confirm, makeBody: _makeBody, id, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...formProps } = props;
119
244
  const store = useFormStore(props.form);
120
245
  const formRef = useRef(null);
121
246
  const generatedId = useId();
122
247
  const formId = id ?? `${generatedId}form`;
123
248
  const controllerRef = useRef(null);
249
+ // True while the question before a save is open (or what it shows is being worked out): nothing else submits.
250
+ const confirmingRef = useRef(false);
124
251
  const latest = useRef(props);
125
252
  latest.current = props;
126
253
  const locked = Boolean(props.readOnly || props.disabled);
@@ -138,11 +265,17 @@ export function SubmitForm(props) {
138
265
  useEffect(() => () => controllerRef.current?.abort(), []);
139
266
  // Closing or reloading the page with unsaved changes asks first, unless a draft keeps them.
140
267
  const guardLeave = confirmLeave ?? !props.draftKey;
268
+ // Whether this form is the page's own (in the page guard) or a popup's: the close question follows it.
269
+ const pageGuardRef = useRef(useContext(PageLeaveGuardContext));
141
270
  useEffect(() => {
142
271
  if (!guardLeave)
143
272
  return;
144
273
  const onBeforeUnload = (event) => {
145
- if (!store.isChanged() || locked)
274
+ // A navigation the person already confirmed is not asked about twice: for a page form, a link inside the app or
275
+ // `confirmPageLeave`; for a form in a popup, which those never asked about, only a confirmed full-page move.
276
+ if (!store.isWorthKeeping() || locked)
277
+ return;
278
+ if (pageGuardRef.current ? pageLeaveGuard.allowed : pageLeaveGuard.unloadAllowed)
146
279
  return;
147
280
  event.preventDefault();
148
281
  // Older browsers show the prompt only when this is set.
@@ -151,9 +284,33 @@ export function SubmitForm(props) {
151
284
  window.addEventListener("beforeunload", onBeforeUnload);
152
285
  return () => window.removeEventListener("beforeunload", onBeforeUnload);
153
286
  }, [store, guardLeave, locked]);
287
+ // Inside a place that can go away with the form (a tab of a record's workspace), that place asks the same question.
288
+ const leaveGuard = useContext(FormLeaveGuardContext);
289
+ const lockedRef = useRef(locked);
290
+ lockedRef.current = locked;
291
+ useEffect(() => {
292
+ if (!guardLeave || !leaveGuard)
293
+ return;
294
+ return leaveGuard.watch({ form: store, changed: () => !lockedRef.current && store.isWorthKeeping() });
295
+ }, [store, guardLeave, leaveGuard]);
296
+ // On the page itself, a navigation inside the app asks the same question before it takes the form away (a link,
297
+ // `confirmPageLeave`); a form in a `PopupHost` popup is left out, as the popup stays open through a navigation. Every
298
+ // such form, popups' too, is one a full-page load takes away (`confirmPageLeave({ unload: true })`).
299
+ const pageGuard = useContext(PageLeaveGuardContext);
300
+ useEffect(() => {
301
+ if (!guardLeave)
302
+ return;
303
+ return unloadLeaveGuard.watch({ form: store, changed: () => !lockedRef.current && store.isWorthKeeping() });
304
+ }, [store, guardLeave]);
305
+ useEffect(() => {
306
+ if (!guardLeave || !pageGuard)
307
+ return;
308
+ guardLinks();
309
+ return pageGuard.watch({ form: store, changed: () => !lockedRef.current && store.isWorthKeeping() });
310
+ }, [store, guardLeave, pageGuard]);
154
311
  async function submit() {
155
312
  const status = store.getStatus();
156
- if (status.submitting || status.loading || locked)
313
+ if (confirmingRef.current || status.submitting || status.loading || locked)
157
314
  return;
158
315
  if (!store.validate())
159
316
  return;
@@ -161,6 +318,32 @@ export function SubmitForm(props) {
161
318
  controllerRef.current = controller;
162
319
  const values = store.getValue();
163
320
  const context = { changes: store.getChanges(), initial: store.getInitial(), form: store, signal: controller.signal };
321
+ const { confirm, makeBody } = latest.current;
322
+ if (confirm) {
323
+ // The question comes after the checks and before "Saving…": while it is open nothing else submits, and Back, Esc
324
+ // or the close button end here with nothing sent and the form as it was.
325
+ confirmingRef.current = true;
326
+ window.addEventListener("keydown", holdSaveKey, true);
327
+ let go;
328
+ try {
329
+ go = await askBeforeSaving(confirm, values, context, makeBody);
330
+ }
331
+ catch (error) {
332
+ // A transform or a rule of the question that throws shows under the form, as a failed save does, and asks nothing.
333
+ go = false;
334
+ if (!controller.signal.aborted && !isAbortError(error))
335
+ store.setErrors({}, errorMessage(error));
336
+ }
337
+ finally {
338
+ confirmingRef.current = false;
339
+ window.removeEventListener("keydown", holdSaveKey, true);
340
+ }
341
+ if (!go || controller.signal.aborted) {
342
+ if (controllerRef.current === controller)
343
+ controllerRef.current = null;
344
+ return;
345
+ }
346
+ }
164
347
  store.beginSubmit();
165
348
  let result;
166
349
  try {
@@ -183,7 +366,9 @@ export function SubmitForm(props) {
183
366
  store.focus(first);
184
367
  return;
185
368
  }
186
- // Saved.
369
+ // Saved. Remembered inputs keep what was saved, before a reset opens them on it; so do rows' (the last row added).
370
+ store.rememberSaved(values);
371
+ formRef.current?.dispatchEvent(new Event(FORM_SAVED_EVENT));
187
372
  if (latest.current.resetOnSuccess) {
188
373
  store.reset();
189
374
  store.markSaved();
@@ -202,8 +387,8 @@ export function SubmitForm(props) {
202
387
  formRef.current?.requestSubmit();
203
388
  }
204
389
  }
205
- return (_jsxs("form", { ref: formRef, id: formId, className: cx("nx-submit-form", className), style: style, noValidate: true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, onSubmit: event => {
390
+ return (_jsxs("form", { ref: formRef, id: formId, className: cx("nx-submit-form", className), style: style, noValidate: true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, onSubmit: event => {
206
391
  event.preventDefault();
207
392
  void submit();
208
- }, onKeyDown: onKeyDown, children: [_jsx(Form, { ...formProps, form: store }), buttons !== false && (_jsx(FormButtons, { form: store, align: buttons, submitLabel: submitLabel, submitIcon: submitIcon, onCancel: onCancel, confirmCancel: confirmCancel, cancelLabel: cancelLabel, resettable: resettable, resetLabel: resetLabel, size: formProps.size, readOnly: locked, statusLine: statusLine }))] }));
393
+ }, onKeyDown: onKeyDown, children: [_jsx(Form, { ...formProps, form: store }), buttons !== false && (_jsx(FormButtons, { form: store, align: buttons, submitLabel: submitLabel, submitIcon: submitIcon, submitTone: submitTone, onCancel: onCancel, confirmCancel: confirmCancel, cancelLabel: cancelLabel, resettable: resettable, resetLabel: resetLabel, size: formProps.size, readOnly: locked, statusLine: statusLine }))] }));
209
394
  }
@@ -83,5 +83,5 @@ function Toggle(props) {
83
83
  // With a description, the name comes from the label text alone; the description is read as a description.
84
84
  const labelId = descriptionId && hasContent(label) && !inputProps["aria-label"] ? `${field.fieldId}-label` : undefined;
85
85
  const describedBy = [inputProps["aria-describedby"], descriptionId, field.error && field.messageId].filter(Boolean).join(" ") || undefined;
86
- return (_jsxs("div", { ref: field.containerRef, className: cx("nx-choice-field", className), style: style, "data-invalid": field.error ? true : undefined, "data-readonly": readOnly || undefined, onBlur: handleBlur, children: [_jsxs("label", { className: cx("nx-choice", `nx-choice--${size}`, labelPosition === "start" && "nx-choice--start"), children: [_jsx("input", { ...inputProps, [OPTION_ATTRIBUTE]: 0, ref: setRef, id: field.fieldId, type: kind === "radio" ? "radio" : "checkbox", role: kind === "switch" ? "switch" : undefined, className: cx("nx-choice__control", `nx-choice__control--${kind}`, inputClassName), checked: checked, disabled: disabled, "aria-invalid": field.error ? true : undefined, "aria-required": required || undefined, "aria-readonly": readOnly || undefined, "aria-labelledby": inputProps["aria-labelledby"] ?? labelId, "aria-describedby": describedBy, onClick: handleClick, onKeyDown: handleKeyDown, onChange: handleChange, onInvalid: field.onInvalid }), (hasContent(label) || descriptionId) && (_jsxs("span", { className: "nx-choice__text", children: [hasContent(label) && (_jsxs("span", { id: labelId, className: "nx-choice__label", children: [label, readOnlyNotice.lock] })), descriptionId && (_jsx("span", { id: descriptionId, className: "nx-choice__description", children: description }))] }))] }), _jsx(GroupFooter, { id: field.messageId, error: field.error }), readOnlyNotice.notice] }));
86
+ return (_jsxs("div", { ref: field.containerRef, className: cx("nx-choice-field", className), style: style, "data-invalid": field.error ? true : undefined, "data-readonly": readOnly || undefined, onBlur: handleBlur, children: [_jsxs("label", { className: cx("nx-choice", `nx-choice--${size}`, labelPosition === "start" && "nx-choice--start"), title: inputProps.title, children: [_jsx("input", { ...inputProps, [OPTION_ATTRIBUTE]: 0, ref: setRef, id: field.fieldId, type: kind === "radio" ? "radio" : "checkbox", role: kind === "switch" ? "switch" : undefined, className: cx("nx-choice__control", `nx-choice__control--${kind}`, inputClassName), checked: checked, disabled: disabled, "aria-invalid": field.error ? true : undefined, "aria-required": required || undefined, "aria-readonly": readOnly || undefined, "aria-labelledby": inputProps["aria-labelledby"] ?? labelId, "aria-describedby": describedBy, onClick: handleClick, onKeyDown: handleKeyDown, onChange: handleChange, onInvalid: field.onInvalid }), (hasContent(label) || descriptionId) && (_jsxs("span", { className: "nx-choice__text", children: [hasContent(label) && (_jsxs("span", { id: labelId, className: "nx-choice__label", children: [label, readOnlyNotice.lock] })), descriptionId && (_jsx("span", { id: descriptionId, className: "nx-choice__description", children: description }))] }))] }), _jsx(GroupFooter, { id: field.messageId, error: field.error }), readOnlyNotice.notice] }));
87
87
  }