nexus-shared 3.1.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 (234) hide show
  1. package/CHANGELOG.md +2268 -446
  2. package/LICENSE +21 -21
  3. package/README.md +1705 -1084
  4. package/dist/Client.Index.d.ts +17 -0
  5. package/dist/Client.Index.js +17 -0
  6. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  7. package/dist/Components/Documents/DocumentFrame.js +83 -0
  8. package/dist/Components/Documents/Menu.d.ts +4 -0
  9. package/dist/Components/Documents/Menu.js +40 -24
  10. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  11. package/dist/Components/Documents/PrintDocument.js +62 -0
  12. package/dist/Components/Documents/Tabs.d.ts +59 -0
  13. package/dist/Components/Documents/Tabs.js +70 -0
  14. package/dist/Components/Forms/ApiForm.js +19 -2
  15. package/dist/Components/Forms/Crud.d.ts +2 -2
  16. package/dist/Components/Forms/Crud.js +871 -377
  17. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  18. package/dist/Components/Forms/CrudActions.js +184 -0
  19. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  20. package/dist/Components/Forms/CrudHistory.js +140 -0
  21. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  22. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  23. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  24. package/dist/Components/Forms/CrudParts.js +56 -5
  25. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  26. package/dist/Components/Forms/CrudRowForms.js +114 -0
  27. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  28. package/dist/Components/Forms/CrudScopes.js +76 -0
  29. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  30. package/dist/Components/Forms/CrudTree.js +363 -0
  31. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  32. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  33. package/dist/Components/Forms/ExcelImport.js +3 -3
  34. package/dist/Components/Forms/Form.d.ts +4 -4
  35. package/dist/Components/Forms/Form.js +125 -16
  36. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  37. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  38. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  39. package/dist/Components/Forms/RecordFlows.js +282 -0
  40. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  41. package/dist/Components/Forms/SubmitForm.js +195 -10
  42. package/dist/Components/Inputs/CheckBox.js +1 -1
  43. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  44. package/dist/Components/Inputs/ChoiceField.js +28 -2
  45. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  46. package/dist/Components/Inputs/CodeBox.js +316 -0
  47. package/dist/Components/Inputs/DatePicker.js +4 -1
  48. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  49. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  50. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  51. package/dist/Components/Inputs/Dropdown.js +8 -5
  52. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  53. package/dist/Components/Inputs/DropdownParts.js +16 -4
  54. package/dist/Components/Inputs/Field.d.ts +7 -1
  55. package/dist/Components/Inputs/Field.js +2 -2
  56. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  57. package/dist/Components/Inputs/GroupForm.js +10 -11
  58. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  59. package/dist/Components/Inputs/InputRenderer.js +39 -31
  60. package/dist/Components/Inputs/NumberBox.js +4 -2
  61. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  62. package/dist/Components/Inputs/PictureInput.js +70 -0
  63. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  64. package/dist/Components/Inputs/RadioGroup.js +8 -3
  65. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  66. package/dist/Components/Inputs/RowsInput.js +18 -3
  67. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  68. package/dist/Components/Inputs/TabularForm.js +29 -29
  69. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  70. package/dist/Components/Inputs/TextArea.js +14 -2
  71. package/dist/Components/Inputs/TextBox.js +4 -1
  72. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  73. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  74. package/dist/Components/Inputs/TimePicker.js +4 -1
  75. package/dist/Components/Inputs/TreeView.d.ts +107 -6
  76. package/dist/Components/Inputs/TreeView.js +513 -30
  77. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  78. package/dist/Components/Layouts/MessageBox.js +56 -7
  79. package/dist/Components/Layouts/Popup.d.ts +13 -6
  80. package/dist/Components/Layouts/Popup.js +37 -9
  81. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  82. package/dist/Components/Layouts/PopupDock.js +2 -1
  83. package/dist/Components/Layouts/PopupHost.js +5 -4
  84. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  85. package/dist/Components/Layouts/ThemePicker.js +16 -4
  86. package/dist/Components/Viewers/DataTable.js +205 -61
  87. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  88. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  89. package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
  90. package/dist/Components/Viewers/DataTableParts.js +62 -21
  91. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  92. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  93. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  94. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  95. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  96. package/dist/Components/Viewers/RecordCards.js +509 -0
  97. package/dist/Helpers/ApiModules.d.ts +3 -34
  98. package/dist/Helpers/ApiModules.js +3 -3
  99. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  100. package/dist/Helpers/ApiRoutes.js +31 -27
  101. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  102. package/dist/Helpers/ArrayHelpers.js +2 -0
  103. package/dist/Helpers/BusyHooks.d.ts +6 -0
  104. package/dist/Helpers/BusyHooks.js +39 -0
  105. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  106. package/dist/Helpers/CodeHelpers.js +332 -0
  107. package/dist/Helpers/CrudBackend.d.ts +114 -6
  108. package/dist/Helpers/CrudBackend.js +214 -11
  109. package/dist/Helpers/CrudColumns.d.ts +98 -0
  110. package/dist/Helpers/CrudColumns.js +160 -0
  111. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  112. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  113. package/dist/Helpers/CrudHelpers.d.ts +130 -6
  114. package/dist/Helpers/CrudHelpers.js +304 -12
  115. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  116. package/dist/Helpers/CrudNarrowPick.js +157 -0
  117. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  118. package/dist/Helpers/CrudRowForms.js +43 -0
  119. package/dist/Helpers/CrudScopes.d.ts +32 -0
  120. package/dist/Helpers/CrudScopes.js +79 -0
  121. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  122. package/dist/Helpers/DropdownHelpers.js +6 -2
  123. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  124. package/dist/Helpers/ExcelHelpers.js +12 -11
  125. package/dist/Helpers/FileAdapter.d.ts +78 -0
  126. package/dist/Helpers/FileAdapter.js +102 -0
  127. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  128. package/dist/Helpers/FilePrimaries.js +138 -0
  129. package/dist/Helpers/FormConfirm.d.ts +32 -0
  130. package/dist/Helpers/FormConfirm.js +40 -0
  131. package/dist/Helpers/FormStore.d.ts +90 -5
  132. package/dist/Helpers/FormStore.js +318 -22
  133. package/dist/Helpers/InputConditions.d.ts +50 -0
  134. package/dist/Helpers/InputConditions.js +168 -0
  135. package/dist/Helpers/InputMemory.d.ts +106 -0
  136. package/dist/Helpers/InputMemory.js +385 -0
  137. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  138. package/dist/Helpers/InputNarrowing.js +67 -0
  139. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  140. package/dist/Helpers/InputParamsHelpers.js +162 -71
  141. package/dist/Helpers/MessageBuilder.js +2 -0
  142. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  143. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  144. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  145. package/dist/Helpers/NexusHelpers.js +1 -0
  146. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  147. package/dist/Helpers/NumberHelpers.js +126 -0
  148. package/dist/Helpers/Permissions.js +2 -1
  149. package/dist/Helpers/QrCode.d.ts +26 -0
  150. package/dist/Helpers/QrCode.js +463 -0
  151. package/dist/Helpers/RecordCards.d.ts +205 -0
  152. package/dist/Helpers/RecordCards.js +378 -0
  153. package/dist/Helpers/RequestCache.d.ts +47 -0
  154. package/dist/Helpers/RequestCache.js +132 -0
  155. package/dist/Helpers/RowsStore.d.ts +37 -2
  156. package/dist/Helpers/RowsStore.js +128 -10
  157. package/dist/Helpers/ServerPreview.d.ts +69 -0
  158. package/dist/Helpers/ServerPreview.js +99 -0
  159. package/dist/Helpers/TableColumns.js +4 -3
  160. package/dist/Helpers/TableExport.d.ts +7 -1
  161. package/dist/Helpers/TableExport.js +21 -15
  162. package/dist/Helpers/TableFilters.d.ts +1 -1
  163. package/dist/Helpers/TableFilters.js +24 -23
  164. package/dist/Helpers/TableHelpers.d.ts +24 -3
  165. package/dist/Helpers/TableHelpers.js +58 -12
  166. package/dist/Helpers/TableJoins.d.ts +83 -0
  167. package/dist/Helpers/TableJoins.js +284 -0
  168. package/dist/Helpers/TableStore.d.ts +26 -1
  169. package/dist/Helpers/TableStore.js +73 -7
  170. package/dist/Helpers/TreeStore.d.ts +306 -11
  171. package/dist/Helpers/TreeStore.js +1048 -90
  172. package/dist/Helpers/UserPreferences.d.ts +8 -0
  173. package/dist/Helpers/UserPreferences.js +17 -0
  174. package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
  175. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  176. package/dist/Interfaces/FileInterfaces.js +4 -0
  177. package/dist/Interfaces/FormInterfaces.d.ts +393 -29
  178. package/dist/Interfaces/FormInterfaces.js +79 -1
  179. package/dist/Interfaces/InputInterfaces.d.ts +39 -3
  180. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  181. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  182. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  183. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  184. package/dist/Interfaces/RecordInterfaces.js +1 -0
  185. package/dist/Interfaces/TableInterfaces.d.ts +160 -5
  186. package/dist/Services/ApiProxy.d.ts +36 -1
  187. package/dist/Services/ApiProxy.js +182 -52
  188. package/dist/Services/ApiProxyRules.d.ts +203 -0
  189. package/dist/Services/ApiProxyRules.js +426 -0
  190. package/dist/Services/ExcelApi.js +5 -0
  191. package/dist/Services/FileApi.d.ts +13 -0
  192. package/dist/Services/FileApi.js +29 -0
  193. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  194. package/dist/Services/ImageEditorLoader.js +51 -0
  195. package/dist/Services/JoinSources.d.ts +80 -0
  196. package/dist/Services/JoinSources.js +150 -0
  197. package/dist/Services/ModuleImport.d.ts +7 -0
  198. package/dist/Services/ModuleImport.js +22 -0
  199. package/dist/Services/ServerApi.d.ts +13 -2
  200. package/dist/Services/ServerApi.js +8 -3
  201. package/dist/Shared.Index.d.ts +20 -0
  202. package/dist/Shared.Index.js +20 -0
  203. package/package.json +92 -85
  204. package/src/Styles/Nexus.Avatar.css +80 -79
  205. package/src/Styles/Nexus.Base.css +117 -117
  206. package/src/Styles/Nexus.Button.css +186 -180
  207. package/src/Styles/Nexus.Calendar.css +273 -273
  208. package/src/Styles/Nexus.Chat.css +1443 -1443
  209. package/src/Styles/Nexus.Choice.css +359 -343
  210. package/src/Styles/Nexus.Crud.css +542 -168
  211. package/src/Styles/Nexus.DateTime.css +81 -81
  212. package/src/Styles/Nexus.Dropdown.css +733 -725
  213. package/src/Styles/Nexus.Editor.css +295 -295
  214. package/src/Styles/Nexus.Excel.css +389 -389
  215. package/src/Styles/Nexus.Files.css +410 -0
  216. package/src/Styles/Nexus.Form.css +282 -279
  217. package/src/Styles/Nexus.Index.css +41 -37
  218. package/src/Styles/Nexus.Input.css +238 -4
  219. package/src/Styles/Nexus.Markdown.css +237 -237
  220. package/src/Styles/Nexus.Menu.css +246 -237
  221. package/src/Styles/Nexus.Popup.css +800 -724
  222. package/src/Styles/Nexus.Print.css +288 -0
  223. package/src/Styles/Nexus.QrCode.css +12 -0
  224. package/src/Styles/Nexus.Record.css +430 -0
  225. package/src/Styles/Nexus.Rows.css +637 -637
  226. package/src/Styles/Nexus.Slider.css +276 -276
  227. package/src/Styles/Nexus.Tab.Buttons.css +497 -432
  228. package/src/Styles/Nexus.Table.css +98 -35
  229. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  230. package/src/Styles/Nexus.Themes.css +200 -194
  231. package/src/Styles/Nexus.Time.css +144 -144
  232. package/src/Styles/Nexus.Toast.css +263 -263
  233. package/src/Styles/Nexus.Tokens.css +94 -89
  234. package/src/Styles/Nexus.Tree.css +530 -431
@@ -4,12 +4,17 @@ import { Fragment } from "react";
4
4
  import { iconAlertCircle, iconHistory, iconX, InlineIcon } from "nexus-icons";
5
5
  import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
+ import { useInputDesign, useNumberLocale } from "../../Helpers/UserPreferences.js";
7
8
  import { formatDate, toIsoDate, usesDevanagari } from "../../Helpers/DateHelpers.js";
8
9
  import { useDatePreferences } from "../../Helpers/DatePreferences.js";
9
10
  import { FormStore } from "../../Helpers/FormStore.js";
10
- import { inputFieldName } from "../../Helpers/InputParamsHelpers.js";
11
+ import { computedText, computedValue, withInputConditions } from "../../Helpers/InputConditions.js";
12
+ import { narrowInputParams } from "../../Helpers/InputNarrowing.js";
13
+ import { inputFieldName, isBooleanInput } from "../../Helpers/InputParamsHelpers.js";
11
14
  import { formatTime } from "../../Helpers/TimeHelpers.js";
15
+ import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
12
16
  import { Button } from "../Documents/Button.js";
17
+ import { Field } from "../Inputs/Field.js";
13
18
  import { isFirstInvalid } from "../Inputs/InputField.js";
14
19
  import { renderInput } from "../Inputs/InputRenderer.js";
15
20
  import { firstFocusable } from "../Inputs/RowsInput.js";
@@ -19,11 +24,18 @@ import { firstFocusable } from "../Inputs/RowsInput.js";
19
24
  * its own: SubmitForm checks on submit, and a plain <form> around a Form blocks its submit while any input, or the form's
20
25
  * own rule, has a problem.
21
26
  */
22
- /** Inputs without a box: they line up with the boxes beside them. */
23
- const CHOICE_TYPES = new Set(["checkbox", "switch", "radio"]);
24
27
  /** What takes focus in an input's cell: a control to type in or pick, before any button of its toolbar. */
25
28
  const TYPING_TARGETS = 'input:not([type="hidden"]):not(.nx-visually-hidden):not(:disabled), textarea:not(.nx-visually-hidden):not(:disabled), select:not(:disabled), [contenteditable="true"]';
26
29
  const INVALID_CONTROLS = ":is(input, textarea, select):invalid";
30
+ /** This browser's local storage, or nothing where it cannot be reached (storage switched off). */
31
+ function browserStorage() {
32
+ try {
33
+ return window.localStorage;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
27
39
  /* Hooks */
28
40
  /**
29
41
  * A form store for the page to hold: pass it to a form as `form`, then read, watch, set, and submit through it.
@@ -89,13 +101,48 @@ export function formatFormTime(ms, preferences) {
89
101
  return time;
90
102
  return `${formatDate(date, preferences)}, ${time}`;
91
103
  }
104
+ /* Cells */
105
+ /**
106
+ * What the page passed as the form's `computeWith`, for the inputs worked out from it (`compute`): one object for the
107
+ * form's life, so the cells' context stays the same and only the worked-out inputs draw again when it changes.
108
+ */
109
+ class ComputeSource {
110
+ listeners = new Set();
111
+ version = 0;
112
+ value;
113
+ constructor(value) {
114
+ this.value = value;
115
+ }
116
+ set(value) {
117
+ if (Object.is(value, this.value))
118
+ return;
119
+ this.value = value;
120
+ this.version++;
121
+ for (const listener of [...this.listeners])
122
+ listener();
123
+ }
124
+ subscribe = (listener) => {
125
+ this.listeners.add(listener);
126
+ return () => {
127
+ this.listeners.delete(listener);
128
+ };
129
+ };
130
+ getVersion = () => this.version;
131
+ }
92
132
  const spanOf = (params) => Math.max(1, Math.min(12, Math.round(params.span ?? 12)));
93
133
  /** A checkbox, switch, or radio that shares a row with labeled boxes: it lines up with their boxes, below their labels. */
94
- const besideBoxes = (params, span, design) => CHOICE_TYPES.has(params.type) && span < 12 && (design ?? "standard") === "standard";
134
+ const besideBoxes = (params, span, design) => isBooleanInput(params) && span < 12 && (design ?? "standard") === "standard";
95
135
  /** The input. It starts from the store's value and reports each change; a new stamp mounts it again with a new value. */
96
- function FieldInput({ store, params, stamp, error, context }) {
136
+ function FieldInput({ store, params, stamp, error, context, cell, heldFocus }) {
97
137
  const [initial] = useState(() => store.get(params.name));
98
- const onValueChange = useCallback((value) => store.input(params.name, value, stamp), [store, params.name, stamp]);
138
+ // Runs before the input leaves the page (a new stamp: a value from code, a load, a remembered value a picker gave
139
+ // late): a focus inside it is noted, or it would fall to the page with nothing said.
140
+ useLayoutEffect(() => () => {
141
+ const active = typeof document === "undefined" ? null : document.activeElement;
142
+ if (active && cell.current?.contains(active))
143
+ heldFocus.current = true;
144
+ }, [cell, heldFocus]);
145
+ const onValueChange = useCallback((value, item) => store.input(params.name, value, stamp, item), [store, params.name, stamp]);
99
146
  const own = params;
100
147
  const overrides = {
101
148
  id: `${context.idPrefix}-${params.name}`,
@@ -107,13 +154,43 @@ function FieldInput({ store, params, stamp, error, context }) {
107
154
  };
108
155
  if (context.design && !own.design)
109
156
  overrides.design = context.design;
110
- if (context.scopeNames && params.type === "radio-group")
157
+ if (context.scopeNames && params.type === EInputTypes.RadioGroup)
111
158
  overrides.name = `${context.idPrefix}:${params.name}`;
112
159
  // Only a message from the form replaces the input's own.
113
160
  if (error !== undefined)
114
161
  overrides.error = error;
115
- return renderInput(params, overrides);
162
+ // A picker asked whether it still offers the remembered value (`remember`): its spinner shows until it answers.
163
+ if (store.isRemembering(params.name))
164
+ overrides.loading = true;
165
+ // Options another input narrows (`narrowedBy`): read with its value as it is now; the cell draws again when it changes.
166
+ // Copied afresh on every draw, never kept: the copy reads the params' getters, and one that `dependsOn` follows
167
+ // (readOnly, hint, label, options; span is read by the cell) must answer for the values of this draw. The box keeps its loaded pages by `loadKey`.
168
+ const narrowing = params.narrowedBy;
169
+ const other = narrowing?.name;
170
+ const shown = narrowing ? narrowInputParams(params, other === undefined ? undefined : store.get(other), other === undefined ? undefined : store.inputParams(other)?.label) : params;
171
+ return renderInput(shown, overrides);
172
+ }
173
+ /**
174
+ * An input worked out from others (`compute`): the field's frame (label, hint, prefix, suffix, size, design) around an
175
+ * `<output>` that says the value as it is now, in the person's number format, on the read-only tint - never typed in, so
176
+ * no lock, no note, no steppers and no Tab stop. Blank while the values load, and for a value that is no figure.
177
+ */
178
+ function ComputedField({ store, params, context }) {
179
+ const source = context.computeWith;
180
+ useSyncExternalStore(source.subscribe, source.getVersion, source.getVersion);
181
+ const locale = useNumberLocale();
182
+ const own = params;
183
+ const design = useInputDesign(own.design ?? context.design);
184
+ const controlId = `${context.idPrefix}-${params.name}`;
185
+ const messageId = `${controlId}-message`;
186
+ const text = context.loading ? "" : computedText(params, computedValue(params, store.getValue(), source.value), locale);
187
+ const disabled = context.disabled || Boolean(own.disabled);
188
+ // The inputs it is worked out from, as an output names them.
189
+ const from = params.compute?.from.map(name => `${context.idPrefix}-${name}`).join(" ");
190
+ return (_jsx(Field, { controlId: controlId, messageId: messageId, label: params.label, hint: own.hint, design: design, size: own.size ?? context.size, icon: own.icon, iconEnd: own.iconEnd, prefix: own.prefix, suffix: own.suffix, readOnly: true, disabled: disabled, raiseLabel: true, className: own.className, children: _jsx("output", { id: controlId, htmlFor: from || undefined, className: cx("nx-field__control", "nx-field__control--output", params.type === EInputTypes.Number && "nx-field__control--number"), "aria-live": "polite", "aria-describedby": own.hint ? messageId : undefined, "aria-disabled": disabled || undefined, children: _jsx("span", { className: "nx-field__output-text", children: text }) }) }));
116
191
  }
192
+ /** The control an input mounted again takes the focus back in: its box, a list's combobox, a choice, an editor. */
193
+ const REFOCUSABLE = '[role="combobox"]:not([disabled]), input:not([type="hidden"]):not([disabled]):not([tabindex="-1"]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"], button:not([disabled]):not([tabindex="-1"])';
117
194
  /** One input's place on the grid. It renders again only when its input must: a message from the form, or a value from code. */
118
195
  const FormCell = memo(function FormCell({ store, params, context }) {
119
196
  const { name } = params;
@@ -121,7 +198,26 @@ const FormCell = memo(function FormCell({ store, params, context }) {
121
198
  const version = () => store.fieldVersion(name);
122
199
  useSyncExternalStore(subscribe, version, version);
123
200
  const stamp = store.stamp(name);
201
+ const cellRef = useRef(null);
202
+ const heldFocus = useRef(false);
203
+ // An input mounted again while it had the focus gets it back in its new control (the checked choice of a group
204
+ // first): a person in the box stays there, and a screen reader says the box again with the value it now holds.
205
+ useLayoutEffect(() => {
206
+ if (!heldFocus.current)
207
+ return;
208
+ heldFocus.current = false;
209
+ const cell = cellRef.current;
210
+ const active = document.activeElement;
211
+ if (!cell || (active && active !== document.body && cell.contains(active)))
212
+ return;
213
+ const target = cell.querySelector("input:checked:not([disabled])") ?? cell.querySelector(REFOCUSABLE);
214
+ target?.focus({ preventScroll: true });
215
+ }, [stamp]);
216
+ // Read on every draw: a `span` getter, or `spanWhen`, answers for the values as they are now. The input below keeps
217
+ // its key, so a new width never mounts it again: what is typed, the caret and the focus stay.
124
218
  const span = spanOf(params);
219
+ // The design the inputs draw in, the person's choice included: choice boxes sit beside others only in `standard`.
220
+ const design = useInputDesign(context.design);
125
221
  // An input that follows others is asked whether it shows each time it draws: it comes and goes with their values.
126
222
  if (params.dependsOn?.length && params.hidden)
127
223
  return null;
@@ -131,7 +227,7 @@ const FormCell = memo(function FormCell({ store, params, context }) {
131
227
  return;
132
228
  store.touch(name);
133
229
  }
134
- return (_jsx("div", { className: cx("nx-form__cell", CHOICE_TYPES.has(params.type) && "nx-form__cell--choice"), style: { "--nx-span": span }, "data-span": span, "data-beside": besideBoxes(params, span, context.design) || undefined, "data-nx-field": name, onBlur: onBlur, children: _jsx(FieldInput, { store: store, params: params, stamp: stamp, error: store.shownError(name), context: context }, stamp) }));
230
+ return (_jsx("div", { className: cx("nx-form__cell", isBooleanInput(params) && "nx-form__cell--choice"), style: { "--nx-span": span }, "data-span": span, "data-beside": besideBoxes(params, span, design) || undefined, "data-nx-field": name, onBlur: onBlur, ref: cellRef, children: params.compute ? (_jsx(ComputedField, { store: store, params: params, context: context })) : (_jsx(FieldInput, { store: store, params: params, stamp: stamp, error: store.shownError(name), context: context, cell: cellRef, heldFocus: heldFocus }, stamp)) }));
135
231
  });
136
232
  /** The note over a form whose unsaved changes came back from a draft. */
137
233
  function DraftNotice({ store, restored, inputs, preferences }) {
@@ -152,18 +248,24 @@ function DraftNotice({ store, restored, inputs, preferences }) {
152
248
  * @example
153
249
  * <Form
154
250
  * inputs={[
155
- * { type: "text", name: "fullName", label: "Full name", required: true, span: 6 },
156
- * { type: "email", name: "email", label: "Email", span: 6 },
157
- * { type: "dropdown", name: "role", label: "Role", options: roles, span: 4 },
251
+ * { type: EInputTypes.Text, name: "fullName", label: "Full name", required: true, span: 6 },
252
+ * { type: EInputTypes.Email, name: "email", label: "Email", span: 6 },
253
+ * { type: EInputTypes.Dropdown, name: "role", label: "Role", options: roles, span: 4 },
158
254
  * ]}
159
255
  * defaultValue={employee}
160
256
  * onValueChange={values => saveFilters(values)}
161
257
  * />
162
258
  */
163
259
  export function Form(props) {
164
- const { inputs, design, size = "md", readOnly = false, disabled = false, loading = false, draftKey, id, className, style, ref } = props;
260
+ const { design, size = "md", readOnly = false, disabled = false, loading = false, draftKey, id, className, style, ref } = props;
165
261
  const store = useFormStore(props.form);
166
262
  const anyStore = store;
263
+ // What the page passes for the worked-out inputs (`compute`): they alone draw again when it changes.
264
+ const [computeWith] = useState(() => new ComputeSource(props.computeWith));
265
+ useLayoutEffect(() => computeWith.set(props.computeWith), [computeWith, props.computeWith]);
266
+ // An input with `showWhen` gets a live `hidden`, one with `readOnlyWhen` a live `readOnly`, that reads this store; it
267
+ // follows the inputs they name (`dependsOn`).
268
+ const inputs = useMemo(() => withInputConditions(props.inputs, name => anyStore.get(name)), [props.inputs, anyStore]);
167
269
  const preferences = useDatePreferences();
168
270
  const idPrefix = useId();
169
271
  const rootRef = useRef(null);
@@ -187,6 +289,13 @@ export function Form(props) {
187
289
  rulesRef.current = rulesKey;
188
290
  store.configure(options, !previous || rulesKey.some((item, index) => !Object.is(item, previous[index])));
189
291
  });
292
+ // Remembered inputs (`remember`) read and keep their values in this browser: given once drawn - after the store has
293
+ // these inputs (a store a CRUD loaded first takes them in the effect above) - so the first draw is the same on a
294
+ // server and in the browser, and they open on what is kept before the first paint.
295
+ useLayoutEffect(() => {
296
+ if (inputs.some(params => params.remember))
297
+ store.attachMemory(browserStorage());
298
+ }, [store, inputs]);
190
299
  useLayoutEffect(() => store.setLoading(loading), [store, loading]);
191
300
  useImperativeHandle(ref, () => store, [store]);
192
301
  // Radio groups need names of their own when no <form> keeps them apart.
@@ -205,7 +314,7 @@ export function Form(props) {
205
314
  }
206
315
  }
207
316
  const fromValues = new Set(store.problems());
208
- const names = latest.current.inputs.filter(params => !params.hidden && (invalid.has(params.name) || fromValues.has(params.name))).map(params => params.name);
317
+ const names = anyStore.inputs.filter(params => !params.hidden && (invalid.has(params.name) || fromValues.has(params.name))).map(params => params.name);
209
318
  return { names, invalid, fromValues };
210
319
  }
211
320
  function focusInput(name) {
@@ -250,7 +359,7 @@ export function Form(props) {
250
359
  const proxy = proxyRef.current;
251
360
  if (!proxy)
252
361
  return;
253
- const first = latest.current.inputs.find(params => !params.hidden && store.formRuleError(params.name));
362
+ const first = anyStore.inputs.find(params => !params.hidden && store.formRuleError(params.name));
254
363
  proxy.setCustomValidity((first ? store.formRuleError(first.name) : store.formRuleMessage) ?? "");
255
364
  };
256
365
  const recount = () => {
@@ -308,7 +417,7 @@ export function Form(props) {
308
417
  }
309
418
  // While the values load, the real inputs show, disabled: the form has its final shape from the first frame, so a
310
419
  // popup around it never jumps when the record lands. The store refuses anything typed until then.
311
- const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled: disabled || loading, scopeNames: !inForm }), [idPrefix, size, design, readOnly, disabled, loading, inForm]);
420
+ const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled: disabled || loading, loading, scopeNames: !inForm, computeWith }), [idPrefix, size, design, readOnly, disabled, loading, inForm, computeWith]);
312
421
  // With FormButtons on the page, the form's message is in their status line.
313
422
  const ownMessage = status.buttons === 0 ? status.message : null;
314
423
  return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-form", `nx-form--${size}`, className), style: style, "data-nx-form": "", "aria-busy": loading || undefined, children: [status.restored && !loading && _jsx(DraftNotice, { store: anyStore, restored: status.restored, inputs: inputs, preferences: preferences }), _jsx("div", { className: "nx-form__grid", children: inputs.filter(params => params.dependsOn?.length || !params.hidden).map((params, index, visible) => (_jsxs(Fragment, { children: [params.section && (index === 0 || visible[index - 1]?.section !== params.section) && _jsx("h3", { className: "nx-form__section", children: params.section }), _jsx(FormCell, { store: anyStore, params: params, context: context })] }, params.name))) }), loading && (_jsx("p", { className: "nx-visually-hidden", role: "status", children: "Loading\u2026" })), _jsx("input", { ref: proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", disabled: locked, onInvalid: onProxyInvalid }), ownMessage && (_jsxs("p", { className: "nx-form__message nx-field__message nx-field__message--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: ownMessage })] }))] }));
@@ -0,0 +1,44 @@
1
+ import type { AnyFormStore } from "../../Helpers/FormStore.ts";
2
+ /** One form a guard watches: its store, and whether it has changes that leaving would lose now. */
3
+ export interface GuardedForm {
4
+ form: AnyFormStore;
5
+ changed: () => boolean;
6
+ }
7
+ /** The forms in a place, as they come and go. */
8
+ export declare class FormLeaveGuard {
9
+ private readonly forms;
10
+ /** Watches a form until the function it answers is called (the form leaves). */
11
+ watch(entry: GuardedForm): () => void;
12
+ /** The first form with changes that leaving would lose, or `undefined`. */
13
+ changed(): AnyFormStore | undefined;
14
+ }
15
+ /** The guard of the place a form is drawn in; `null` outside one. */
16
+ export declare const FormLeaveGuardContext: import("react").Context<FormLeaveGuard | null>;
17
+ /**
18
+ * The page's own forms: the ones a navigation inside the app takes away with the page. A `SubmitForm` (and so an
19
+ * `ApiForm`) that asks before the page closes joins it too, unless it is drawn in a `PopupHost` popup, which stays open
20
+ * through a navigation. A link inside the app and `confirmPageLeave()` ask "Discard your changes?" while one has changes.
21
+ */
22
+ export declare class PageLeaveGuard extends FormLeaveGuard {
23
+ private allowedUntil;
24
+ private unloading;
25
+ /** Lets the page's forms go without asking for a moment: a navigation the person confirmed, until it has started. */
26
+ allow(ms?: number): void;
27
+ /**
28
+ * Lets every form go, the page's and the popups', until the page itself goes (`pagehide`): a full-page move the person
29
+ * confirmed (a sign-out, `location.assign`), however long its own call takes first. `end()` takes it back.
30
+ */
31
+ allowUnload(): void;
32
+ /** Asks again: a confirmed move that did not happen (its call failed, the page stays). */
33
+ end(): void;
34
+ /** Whether a confirmed navigation is under way, so neither a link nor a page form's close question asks again. */
35
+ get allowed(): boolean;
36
+ /** Whether a confirmed full-page move is under way: only then a popup form's close question is not asked either. */
37
+ get unloadAllowed(): boolean;
38
+ }
39
+ /** The one page guard of this browser tab. */
40
+ export declare const pageLeaveGuard: PageLeaveGuard;
41
+ /** Every form a full-page load takes away: the page's and the ones in popups (`confirmPageLeave({ unload: true })`). */
42
+ export declare const unloadLeaveGuard: FormLeaveGuard;
43
+ /** The page guard a form joins: the tab's, or `null` inside a place that outlives a navigation (a `PopupHost` popup). */
44
+ export declare const PageLeaveGuardContext: import("react").Context<PageLeaveGuard | null>;
@@ -0,0 +1,65 @@
1
+ "use client";
2
+ import { createContext } from "react";
3
+ /** The forms in a place, as they come and go. */
4
+ export class FormLeaveGuard {
5
+ forms = new Set();
6
+ /** Watches a form until the function it answers is called (the form leaves). */
7
+ watch(entry) {
8
+ this.forms.add(entry);
9
+ return () => {
10
+ this.forms.delete(entry);
11
+ };
12
+ }
13
+ /** The first form with changes that leaving would lose, or `undefined`. */
14
+ changed() {
15
+ for (const entry of this.forms)
16
+ if (entry.changed())
17
+ return entry.form;
18
+ return undefined;
19
+ }
20
+ }
21
+ /** The guard of the place a form is drawn in; `null` outside one. */
22
+ export const FormLeaveGuardContext = createContext(null);
23
+ /**
24
+ * The page's own forms: the ones a navigation inside the app takes away with the page. A `SubmitForm` (and so an
25
+ * `ApiForm`) that asks before the page closes joins it too, unless it is drawn in a `PopupHost` popup, which stays open
26
+ * through a navigation. A link inside the app and `confirmPageLeave()` ask "Discard your changes?" while one has changes.
27
+ */
28
+ export class PageLeaveGuard extends FormLeaveGuard {
29
+ allowedUntil = 0;
30
+ unloading = false;
31
+ /** Lets the page's forms go without asking for a moment: a navigation the person confirmed, until it has started. */
32
+ allow(ms = 1500) {
33
+ this.allowedUntil = Date.now() + ms;
34
+ }
35
+ /**
36
+ * Lets every form go, the page's and the popups', until the page itself goes (`pagehide`): a full-page move the person
37
+ * confirmed (a sign-out, `location.assign`), however long its own call takes first. `end()` takes it back.
38
+ */
39
+ allowUnload() {
40
+ if (this.unloading)
41
+ return;
42
+ this.unloading = true;
43
+ if (typeof window !== "undefined")
44
+ window.addEventListener("pagehide", () => this.end(), { once: true });
45
+ }
46
+ /** Asks again: a confirmed move that did not happen (its call failed, the page stays). */
47
+ end() {
48
+ this.unloading = false;
49
+ this.allowedUntil = 0;
50
+ }
51
+ /** Whether a confirmed navigation is under way, so neither a link nor a page form's close question asks again. */
52
+ get allowed() {
53
+ return this.unloading || Date.now() < this.allowedUntil;
54
+ }
55
+ /** Whether a confirmed full-page move is under way: only then a popup form's close question is not asked either. */
56
+ get unloadAllowed() {
57
+ return this.unloading;
58
+ }
59
+ }
60
+ /** The one page guard of this browser tab. */
61
+ export const pageLeaveGuard = new PageLeaveGuard();
62
+ /** Every form a full-page load takes away: the page's and the ones in popups (`confirmPageLeave({ unload: true })`). */
63
+ export const unloadLeaveGuard = new FormLeaveGuard();
64
+ /** The page guard a form joins: the tab's, or `null` inside a place that outlives a navigation (a `PopupHost` popup). */
65
+ export const PageLeaveGuardContext = createContext(pageLeaveGuard);
@@ -0,0 +1,50 @@
1
+ import { type ReactNode } from "react";
2
+ import { type RecordWriteRequest } from "../../Helpers/RecordCards.ts";
3
+ import type { ApiResult, ApiTarget } from "../../Interfaces/ApiInterfaces.ts";
4
+ import type { ApiRequest, InputParams } from "../../Interfaces/FormInterfaces.ts";
5
+ import type { RecordConfirmParams, RecordContext, RecordFlowParams } from "../../Interfaces/RecordInterfaces.ts";
6
+ import { type PopupHandle } from "../Layouts/PopupHost.tsx";
7
+ /** What a popup works with: the card's data as held now, and the page's ways to write it and to end the session. */
8
+ export interface RecordFlowHost<TData = any, TItem = any> {
9
+ /** The card's data and the item, as held now. */
10
+ context: () => RecordContext<TData, TItem>;
11
+ /** Draws the popup again when the page's data changes. */
12
+ subscribe: (listener: () => void) => () => void;
13
+ snapshot: () => unknown;
14
+ /** Writes an answer into the card's data, as `writes` says. */
15
+ write: (request: Pick<RecordWriteRequest, "writes" | "result" | "values" | "fallback">) => Promise<void>;
16
+ /** A refusal that ended the session: the page shows its words and closes its popups. Answers whether it was one. */
17
+ ended: (answer: ApiResult) => boolean;
18
+ /** Keeps the popup, so an ended session closes it, and the focus comes home after it. */
19
+ track: (popup: PopupHandle) => void;
20
+ }
21
+ export interface RecordFlowRequest<TData = any, TItem = any> {
22
+ flow: RecordFlowParams<TData, TItem>;
23
+ host: RecordFlowHost<TData, TItem>;
24
+ /** One popup per key: opened again, it comes back as it was. */
25
+ key: string;
26
+ /** The popup's title and line when the flow names neither. */
27
+ title: ReactNode;
28
+ description?: ReactNode;
29
+ /**
30
+ * `edit`: a card's Edit - its inputs when the flow names none, opened on the data, its values merged over the data as
31
+ * held now when sent and written back when the answer is no record. `add`: the values alone. `item`: opened on the item,
32
+ * its values merged over it.
33
+ */
34
+ kind: "edit" | "add" | "item";
35
+ inputs?: readonly InputParams[];
36
+ }
37
+ /** Where a change goes, sent through the app's `api` without its toasts: an endpoint, a URL (POST), a request, or a function. */
38
+ export declare function sendRecordCall(submit: ApiTarget | ApiRequest | ((body: unknown) => Promise<unknown>), body: unknown): Promise<ApiResult>;
39
+ /**
40
+ * 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
41
+ * inputs is required: a step can be its sentence and its button); its answer is written into the card's data, then the
42
+ * step's `next` shows, or the popup closes and `done` runs. Closing with changes asks first, unless the step says there
43
+ * is nothing worth keeping.
44
+ */
45
+ export declare function openRecordFlow<TData, TItem>(request: RecordFlowRequest<TData, TItem>): PopupHandle;
46
+ /**
47
+ * A change that needs no form: the question first ("Remove ram@example.com?"), then the call, its answer written into
48
+ * the card's data. `busy` marks the item while it runs. A refusal is said in a toast, in the server's words.
49
+ */
50
+ export declare function runRecordConfirm<TData, TItem>(confirm: RecordConfirmParams<TData, TItem>, host: RecordFlowHost<TData, TItem>, busy: (on: boolean) => void): Promise<void>;