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,16 @@
1
+ import type { RecordCardsProps } from "../../Interfaces/RecordInterfaces.ts";
2
+ /**
3
+ * A record's page of cards, declared by params: the `sources` it reads, a `hero`, and its `cards` (fields, items, or its
4
+ * own). See `RecordCardsProps` and the interfaces it names.
5
+ *
6
+ * @example
7
+ * <RecordCards
8
+ * sources={{ profile: ACCOUNT_API.profile, emails: ACCOUNT_API.emails }}
9
+ * hero={{ title: p => p.fullName, lines: p => [`@${p.username}`] }}
10
+ * cards={[
11
+ * { id: "personal", title: "Personal details", fields: [FULL_NAME, DATE_OF_BIRTH], edit: { submit: { url: ACCOUNT_API.saveProfile } } },
12
+ * { id: "emails", source: "emails", title: "Emails", items: "items", itemKey: "id", item: e => ({ title: e.address }), add: { ... } },
13
+ * ]}
14
+ * />
15
+ */
16
+ export declare function RecordCards(props: RecordCardsProps): import("react").JSX.Element;
@@ -0,0 +1,509 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconAlertCircle, iconPencil, iconPlus, iconRefresh, InlineIcon } from "nexus-icons";
4
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { useDatePreferences } from "../../Helpers/DatePreferences.js";
7
+ import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
8
+ import { recordActionName, recordActionState, recordBusyKeys, recordCardKind, recordCount, recordField, recordFieldHeld, recordHeldLines, recordFieldInputs, recordFieldWide, recordItemKey, recordItems, recordLimit, recordLimitReason, RecordSources, recordSessionEnded, recordValueView, recordWrite, } from "../../Helpers/RecordCards.js";
9
+ import { api as browserApi } from "../../Services/BrowserApi.js";
10
+ import { Button } from "../Documents/Button.js";
11
+ import { ProfilePicture } from "../Forms/CrudWorkspace.js";
12
+ import { openRecordFlow, runRecordConfirm } from "../Forms/RecordFlows.js";
13
+ import { useReadOnlyNotice } from "../Inputs/ReadOnlyNotice.js";
14
+ import { toast } from "../Layouts/Toaster.js";
15
+ import { MarkdownView } from "./MarkdownView.js";
16
+ import { RichTextView } from "./RichTextView.js";
17
+ /*
18
+ * A record's page of cards: a hero across the top, then cards of fields, cards of items and the page's own cards, each
19
+ * drawn from one of the sources the page reads. Nothing on the page saves: a card's Edit, a list's Add and an item's
20
+ * icon actions open popups (RecordFlows.tsx), and their answers are written into the source at once, so every card on
21
+ * it shows the change. Loading draws the cards as they will be, held; a source that fails says so in each of its cards,
22
+ * with Try again; read-only draws no buttons; an ended session leaves the page's own words and its way back in.
23
+ */
24
+ /* eslint-disable @typescript-eslint/no-explicit-any */
25
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
26
+ const shown = (node) => node !== undefined && node !== null && node !== false && node !== "";
27
+ /** Reads a source: its own function, else a GET of its endpoint or URL, without the client's toasts. */
28
+ const readSource = (read, signal) => (typeof read === "function" ? read(signal) : browserApi.get(read, { signal, toast: false }));
29
+ /** One placeholder line while a source loads: the table's bone. */
30
+ /** How long after the page opens a link to a card is still followed again once the cards have their records. */
31
+ const FOLLOW_WITHIN_MS = 5000;
32
+ /** Tells one mounted page from another: a popup belongs to the page that opened it, never to the next one drawn. */
33
+ let pages = 0;
34
+ const Bone = ({ width = "60%" }) => _jsx("span", { className: "nx-table__bone nx-record__bone", style: { width }, "aria-hidden": "true" });
35
+ /**
36
+ * A record's page of cards, declared by params: the `sources` it reads, a `hero`, and its `cards` (fields, items, or its
37
+ * own). See `RecordCardsProps` and the interfaces it names.
38
+ *
39
+ * @example
40
+ * <RecordCards
41
+ * sources={{ profile: ACCOUNT_API.profile, emails: ACCOUNT_API.emails }}
42
+ * hero={{ title: p => p.fullName, lines: p => [`@${p.username}`] }}
43
+ * cards={[
44
+ * { id: "personal", title: "Personal details", fields: [FULL_NAME, DATE_OF_BIRTH], edit: { submit: { url: ACCOUNT_API.saveProfile } } },
45
+ * { id: "emails", source: "emails", title: "Emails", items: "items", itemKey: "id", item: e => ({ title: e.address }), add: { ... } },
46
+ * ]}
47
+ * />
48
+ */
49
+ export function RecordCards(props) {
50
+ const { hero, cards, readOnly = false, headingLevel = 2, className } = props;
51
+ const latest = useRef(props);
52
+ latest.current = props;
53
+ const popups = useRef(new Set());
54
+ const endRef = useRef(() => false);
55
+ const [store] = useState(() => new RecordSources(props.sources, async (read, signal) => {
56
+ const answer = await readSource(read, signal);
57
+ if (!answer.ok)
58
+ endRef.current(answer);
59
+ return answer;
60
+ }));
61
+ const states = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
62
+ useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
63
+ const permissions = getPermissions();
64
+ const can = permissions.ready ? permissions.can.bind(permissions) : () => false;
65
+ const preferences = useDatePreferences();
66
+ const [busy, setBusy] = useState({});
67
+ // Each mounted page keys its popups apart: a page drawn afresh (another visit, a new `key`) never brings back a popup
68
+ // bound to the page before it, whose data is gone.
69
+ const [page] = useState(() => ++pages);
70
+ useEffect(() => {
71
+ store.start();
72
+ return () => store.stop();
73
+ }, [store]);
74
+ // The page's popups go with it: their answers would be written into data no card shows any more.
75
+ useEffect(() => {
76
+ const open = popups.current;
77
+ return () => {
78
+ const closing = [...open];
79
+ open.clear();
80
+ for (const popup of closing)
81
+ popup.close();
82
+ };
83
+ }, []);
84
+ // A link to a card (`/dashboard/profile#emails`) is followed while the cards are held; the cards above it may grow as
85
+ // their records come (a list longer than its held rows) and push it away. Once every source has answered, the card
86
+ // the address names is brought back into view - once, and only while the person has not moved on: a wheel, a touch,
87
+ // a key or a press since the page opened (or more than a few seconds gone) leaves the page where they are.
88
+ const followed = useRef(false);
89
+ const movedOn = useRef(false);
90
+ useEffect(() => {
91
+ const moved = () => {
92
+ movedOn.current = true;
93
+ };
94
+ const events = ["wheel", "touchstart", "keydown", "pointerdown"];
95
+ for (const name of events)
96
+ window.addEventListener(name, moved, { capture: true, passive: true });
97
+ const late = window.setTimeout(moved, FOLLOW_WITHIN_MS);
98
+ return () => {
99
+ window.clearTimeout(late);
100
+ for (const name of events)
101
+ window.removeEventListener(name, moved, { capture: true });
102
+ };
103
+ }, []);
104
+ const settled = Object.values(states).every(state => state.status !== "loading");
105
+ useEffect(() => {
106
+ if (!settled || followed.current)
107
+ return;
108
+ followed.current = true;
109
+ if (movedOn.current)
110
+ return;
111
+ let id = "";
112
+ try {
113
+ id = decodeURIComponent(window.location.hash.slice(1));
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ if (!id)
119
+ return;
120
+ const target = document.getElementById(id);
121
+ if (!target?.closest(".nx-record"))
122
+ return;
123
+ // Already where the link put it (its top in view): left as it is.
124
+ const top = target.getBoundingClientRect().top;
125
+ if (top >= 0 && top < window.innerHeight / 2)
126
+ return;
127
+ target.scrollIntoView({ block: "start" });
128
+ }, [settled]);
129
+ /** An answer that ended the session, when the page says what to do then: the page's words, its popups closed. */
130
+ const ended = useCallback((answer) => {
131
+ const { signedOut, isSessionEnded = recordSessionEnded } = latest.current;
132
+ if (!signedOut || answer.ok || answer.aborted || !isSessionEnded(answer))
133
+ return false;
134
+ if (store.ended)
135
+ return true;
136
+ store.end(answer);
137
+ const closing = [...popups.current];
138
+ popups.current.clear();
139
+ for (const popup of closing)
140
+ popup.close();
141
+ // A closing popup hands the focus back to what opened it, which went with the cards: Sign in again takes it.
142
+ void Promise.all(closing.map(popup => popup.closed)).then(() => {
143
+ const active = document.activeElement;
144
+ if (!active || active === document.body)
145
+ document.getElementById(`${latest.current.name ?? "record"}-sign-in`)?.focus();
146
+ });
147
+ signedOut.onEnded?.(answer);
148
+ return true;
149
+ }, [store]);
150
+ endRef.current = ended;
151
+ const sourceOf = useCallback((card) => card.source ?? store.names[0] ?? "", [store]);
152
+ /** Reads a source again for a step: its data, or null - said in a toast while the cards still show the data. */
153
+ const reload = useCallback(async (source) => {
154
+ const answer = await store.read(source);
155
+ if (answer.ok)
156
+ return store.get(source).data;
157
+ if (!answer.aborted && !store.ended && store.get(source).status === "ready")
158
+ toast.error(`Not read again: ${answer.message ?? "the server did not answer."}`, "The page shows what was read before.");
159
+ return null;
160
+ }, [store]);
161
+ /** Puts the focus back in a card when what held it went away: the item's first button, the card's, or its title. */
162
+ const focusHome = useCallback((cardId, itemKey) => {
163
+ const root = document.getElementById(cardId);
164
+ if (!root)
165
+ return;
166
+ const row = itemKey !== undefined ? root.querySelector(`[data-key="${CSS.escape(itemKey)}"]`) : null;
167
+ const target = row?.querySelector("button") ?? root.querySelector(".nx-record-card__actions button, .nx-record-card__body button") ?? root.querySelector(".nx-record-card__title");
168
+ target?.focus({ preventScroll: true });
169
+ }, []);
170
+ const contextOf = useCallback((card, item) => {
171
+ const source = sourceOf(card);
172
+ const state = store.get(source);
173
+ const now = item && card.itemKey ? recordItems(state.data, card.items).find(one => recordItemKey(one, card.itemKey) === item.key) : undefined;
174
+ return {
175
+ data: state.data,
176
+ item: item ? (now ?? item.value) : undefined,
177
+ readOnly: latest.current.readOnly === true || card.readOnly === true,
178
+ reload: () => reload(source),
179
+ write: data => store.write(source, data),
180
+ run: (actionId, target) => {
181
+ const action = card.itemActions?.find(one => one.id === actionId);
182
+ if (action && card.itemKey)
183
+ runItemRef.current(card, action, target);
184
+ },
185
+ // A popup of the page's own: its values sent as they are (or merged over the item), its answer written as said.
186
+ open: flow => {
187
+ const named = flow.label ?? (typeof flow.title === "string" ? flow.title : "open");
188
+ const label = item ? (card.itemLabel?.(item.value) ?? item.key) : undefined;
189
+ openFlowRef.current(card, { id: `open-${named}`, label: named }, flow, item ? "item" : "add", item && label !== undefined ? { ...item, label } : undefined);
190
+ },
191
+ };
192
+ }, [store, sourceOf, reload]);
193
+ const hostOf = useCallback((card, item) => ({
194
+ context: () => contextOf(card, item),
195
+ subscribe: store.subscribe,
196
+ snapshot: store.snapshot,
197
+ write: async ({ writes, result, values, fallback }) => {
198
+ const source = sourceOf(card);
199
+ const context = contextOf(card, item);
200
+ const outcome = recordWrite({ writes, result, data: store.get(source).data, values, fallback, items: card.items, itemKey: card.itemKey, item: context.item });
201
+ if (outcome.kind === "write")
202
+ store.write(source, outcome.data);
203
+ else if (outcome.kind === "reload")
204
+ await reload(source);
205
+ },
206
+ ended,
207
+ track: popup => {
208
+ popups.current.add(popup);
209
+ void popup.closed.then(() => {
210
+ popups.current.delete(popup);
211
+ const active = document.activeElement;
212
+ if (!active || active === document.body)
213
+ focusHome(card.id, item?.key);
214
+ });
215
+ },
216
+ }), [store, contextOf, sourceOf, reload, ended, focusHome]);
217
+ const name = props.name ?? "record";
218
+ const openFlow = useCallback((card, action, flow, kind, item) => {
219
+ openRecordFlow({
220
+ flow,
221
+ host: hostOf(card, item),
222
+ key: `${name}:${page}:${card.id}:${action.id}${item ? `:${item.key}` : ""}`,
223
+ title: kind === "item" ? action.label : (card.title ?? action.label),
224
+ description: item?.label ?? (kind === "add" ? action.label : undefined),
225
+ kind,
226
+ inputs: kind === "edit" && card.fields ? recordFieldInputs({ fields: card.fields }) : undefined,
227
+ });
228
+ }, [hostOf, name, page]);
229
+ const openFlowRef = useRef(openFlow);
230
+ openFlowRef.current = openFlow;
231
+ /**
232
+ * A question, then the call: the item (or the card) busy meanwhile. The question hands the focus back to the button,
233
+ * which the answer may take away (a Remove takes its row): the focus then comes home, once the change is drawn.
234
+ */
235
+ const confirmThen = useCallback((card, confirm, busyKey, item) => {
236
+ void runRecordConfirm(confirm, hostOf(card, item), on => setBusy(now => ({ ...now, [card.id]: recordBusyKeys(now[card.id], busyKey, on) }))).then(() => window.requestAnimationFrame(() => {
237
+ const active = document.activeElement;
238
+ if (!active || active === document.body)
239
+ focusHome(card.id, item?.key);
240
+ }));
241
+ }, [hostOf, focusHome]);
242
+ const runCard = useCallback((card, action, kind) => {
243
+ if (action.flow)
244
+ openFlow(card, action, action.flow, kind);
245
+ else if (action.confirm)
246
+ confirmThen(card, action.confirm, "card");
247
+ else
248
+ action.onSelect?.(contextOf(card));
249
+ }, [openFlow, confirmThen, contextOf]);
250
+ const runItem = useCallback((card, action, value) => {
251
+ const key = recordItemKey(value, card.itemKey);
252
+ const label = card.itemLabel?.(value) ?? itemTitle(card, value, store.get(sourceOf(card)).data, key);
253
+ const item = { key, value, label };
254
+ const actionLabel = typeof action.label === "function" ? action.label(value, store.get(sourceOf(card)).data) : action.label;
255
+ if (action.flow)
256
+ openFlow(card, { id: action.id, label: actionLabel }, action.flow, "item", item);
257
+ else if (action.confirm)
258
+ confirmThen(card, action.confirm, key, item);
259
+ else
260
+ action.onSelect?.(contextOf(card, item));
261
+ }, [store, sourceOf, openFlow, confirmThen, contextOf]);
262
+ const runItemRef = useRef(runItem);
263
+ runItemRef.current = runItem;
264
+ const retry = useCallback((source, cardId) => {
265
+ const had = document.getElementById(cardId)?.contains(document.activeElement) ?? false;
266
+ void store.read(source).then(() => {
267
+ if (had && (!document.activeElement || document.activeElement === document.body))
268
+ focusHome(cardId);
269
+ });
270
+ }, [store, focusHome]);
271
+ const endedAnswer = store.ended;
272
+ const { signedOut } = props;
273
+ if (endedAnswer && signedOut) {
274
+ const Heading = headingLevel === 3 ? "h3" : "h2";
275
+ const message = typeof signedOut.message === "function" ? signedOut.message(endedAnswer) : (signedOut.message ?? endedAnswer.message ?? "Your session has ended. Sign in again.");
276
+ return (_jsx("div", { className: cx("nx-record", className), children: _jsxs("section", { className: "nx-record-card nx-record-card--wide", "aria-labelledby": `${name}-ended`, children: [_jsx("header", { className: "nx-record-card__head", children: _jsx("div", { className: "nx-record-card__text", children: _jsx(Heading, { id: `${name}-ended`, className: "nx-record-card__title", children: signedOut.title ?? "Your session has ended" }) }) }), _jsxs("div", { className: "nx-record-card__body", children: [_jsx("p", { role: "alert", className: "nx-record-card__message", children: message }), _jsx(Button, { id: `${name}-sign-in`, variant: "primary", autoFocus: true, onClick: signedOut.onSignIn, children: signedOut.label ?? "Sign in again" })] })] }) }));
277
+ }
278
+ const runtime = {
279
+ store,
280
+ readOnly,
281
+ level: headingLevel,
282
+ can,
283
+ preferences,
284
+ busy,
285
+ sourceOf,
286
+ loudFor: loudParts(hero ? [{ id: `${name}-hero`, source: sourceOf(hero) }, ...cards.map(card => ({ id: card.id, source: sourceOf(card) }))] : cards.map(card => ({ id: card.id, source: sourceOf(card) })), states),
287
+ retry,
288
+ runCard,
289
+ runItem,
290
+ contextOf,
291
+ };
292
+ return (_jsxs("div", { className: cx("nx-record", className), role: props["aria-label"] ? "region" : undefined, "aria-label": props["aria-label"], children: [hero && _jsx(RecordHero, { hero: hero, id: `${name}-hero`, state: states[sourceOf(hero)] ?? store.get(sourceOf(hero)), runtime: runtime }), _jsx("div", { className: "nx-record__cards", children: cards.map(card => (_jsx(RecordCardView, { card: card, state: states[sourceOf(card)] ?? store.get(sourceOf(card)), runtime: runtime }, card.id))) })] }));
293
+ }
294
+ /** The first part (in the page's order) that shows each failed source: it alone says the failure aloud. */
295
+ function loudParts(parts, states) {
296
+ const loud = new Map();
297
+ for (const part of parts)
298
+ if (states[part.source]?.status === "failed" && !loud.has(part.source))
299
+ loud.set(part.source, part.id);
300
+ return loud;
301
+ }
302
+ /** An item's name for a screen reader and a popup's line: its title when that is text, else its key. */
303
+ function itemTitle(card, item, data, key) {
304
+ const title = card.item(item, data).title;
305
+ return typeof title === "string" || typeof title === "number" ? String(title) : key;
306
+ }
307
+ /** The failure of a source, in a card: the server's words and Try again. The first part to show it says it aloud. */
308
+ function Failed({ state, source, cardId, runtime }) {
309
+ const loud = runtime.loudFor.get(source) === cardId;
310
+ return (_jsxs("div", { className: "nx-form-load-failed nx-record__failed", role: loud ? "alert" : undefined, children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsxs("p", { children: [_jsx("strong", { children: "This could not be read." }), " ", state.answer.message ?? "The server did not answer."] }), _jsx("div", { className: "nx-form-load-failed__actions", children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => runtime.retry(source, cardId), children: "Try again" }) })] }));
311
+ }
312
+ /** The marks after a name: the table's tags, each with its tone. */
313
+ function Marks({ marks, className }) {
314
+ if (!marks?.length)
315
+ return null;
316
+ return (_jsx("span", { className: className, children: marks.map((mark, index) => (_jsx("span", { className: "nx-table__tag", "data-tone": mark.tone, children: mark.label }, index))) }));
317
+ }
318
+ /**
319
+ * The buttons of a card's heading, the hero's, or an item's right side, with the note a held one says when pressed (the
320
+ * read-only fields' and the table's held Add's): one note for the whole part, saying the pressed button's reason.
321
+ */
322
+ function useHeldNote(rootRef) {
323
+ const [reason, setReason] = useState("");
324
+ const notice = useReadOnlyNotice(true, rootRef, reason, { anchor: target => target.closest(".nx-btn"), typing: false, lock: "label" });
325
+ return {
326
+ note: notice.notice,
327
+ onKeyDown: (event) => notice.onKeyDown(event),
328
+ say: (target, why) => {
329
+ setReason(why);
330
+ notice.show(target);
331
+ },
332
+ };
333
+ }
334
+ /** The text a card's title says, for a button named after it: its text, or the heading's own words once drawn. */
335
+ const titleText = (title) => (typeof title === "string" || typeof title === "number" ? String(title).trim() || null : null);
336
+ /**
337
+ * A heading's buttons: Edit or Add first, then the card's own; held ones say why when pressed. Each is named with its
338
+ * card to a screen reader ("Edit: Personal details"), as an item's icons are with their item: three Edits on a page are
339
+ * three different buttons.
340
+ */
341
+ function HeaderActions({ actions, card, runtime, say, busy }) {
342
+ const visible = actions.filter(entry => entry.state.kind !== "hidden");
343
+ if (!visible.length)
344
+ return null;
345
+ const of = titleText(card.title);
346
+ return (_jsx("div", { className: "nx-record-card__actions", children: visible.map(({ action, kind, state }) => {
347
+ const held = state.kind === "held" ? state.reason : null;
348
+ return (_jsx(Button, { size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "data-action": action.id, "data-held": held !== null || undefined, "aria-label": of ? recordActionName(action.label, of) : undefined, "aria-disabled": held !== null || busy || undefined, title: held ?? undefined, onClick: event => {
349
+ if (held !== null)
350
+ return say(event.currentTarget, held);
351
+ if (!busy)
352
+ runtime.runCard(card, action, kind);
353
+ }, children: action.label }, action.id));
354
+ }) }));
355
+ }
356
+ /** The card across the top: the picture that opens the pictures window, the name, its lines and marks. */
357
+ function RecordHero({ hero, id, state, runtime }) {
358
+ const rootRef = useRef(null);
359
+ const held = useHeldNote(rootRef);
360
+ const data = state.data ?? {};
361
+ const loading = state.status === "loading";
362
+ const source = runtime.sourceOf(hero);
363
+ const Heading = runtime.level === 3 ? "h3" : "h2";
364
+ const title = hero.title(data);
365
+ const picture = hero.picture?.(data);
366
+ const lines = hero.lines?.(data) ?? [];
367
+ const card = { id, source: hero.source, title };
368
+ const actions = (hero.actions ?? []).map(action => ({
369
+ action,
370
+ kind: "edit",
371
+ // While the record is read its rules are not asked: each is held (or not drawn yet, `loading: "hidden"`).
372
+ state: state.status === "failed" ? { kind: "hidden" } : recordActionState(action, state.status === "ready" ? [data] : null, { readOnly: runtime.readOnly, can: runtime.can }),
373
+ }));
374
+ return (_jsxs("section", { ref: rootRef, id: id, className: "nx-record-hero", "aria-labelledby": `${id}-title`, "aria-busy": loading || undefined, onKeyDown: held.onKeyDown, children: [shown(picture) && (_jsx("div", { className: "nx-record-hero__picture", children: _jsx(ProfilePicture, { picture: picture, title: title, size: "var(--nx-record-hero-picture)", readOnly: runtime.readOnly }) })), _jsxs("div", { className: "nx-record-hero__text", children: [_jsx(Heading, { id: `${id}-title`, className: "nx-record-hero__title", children: shown(title) ? title : _jsx(Bone, { width: "12rem" }) }), lines.map((line, index) => shown(line) ? (_jsx("p", { className: "nx-record-hero__line", children: line }, index)) : loading ? (_jsx("p", { className: "nx-record-hero__line", "aria-hidden": "true", children: "\u00A0" }, index)) : null), state.status === "failed" ? (_jsx(Failed, { state: state, source: source, cardId: id, runtime: runtime })) : loading && hero.marks && !hero.marks(data).length ? (_jsx("span", { className: "nx-record-hero__marks", "aria-hidden": "true", children: _jsx("span", { className: "nx-table__tag nx-table__tag--plain nx-record__bone-tag", children: "\u00A0" }) })) : (_jsx(Marks, { marks: hero.marks?.(data), className: "nx-record-hero__marks" }))] }), _jsx(HeaderActions, { actions: actions, card: card, runtime: runtime, say: held.say, busy: false }), held.note] }));
375
+ }
376
+ /** One card: its heading (icon, title, count, sentence, buttons) and its body (fields, items, or its own). */
377
+ function RecordCardView({ card, state, runtime }) {
378
+ const rootRef = useRef(null);
379
+ const held = useHeldNote(rootRef);
380
+ const kind = recordCardKind(card);
381
+ const source = runtime.sourceOf(card);
382
+ const data = state.data;
383
+ const ready = state.status === "ready";
384
+ if (ready && card.hidden?.(data))
385
+ return null;
386
+ const readOnly = runtime.readOnly || card.readOnly === true;
387
+ const busy = runtime.busy[card.id] ?? [];
388
+ const Heading = runtime.level === 3 ? "h3" : "h2";
389
+ const titleId = `${card.id}-title`;
390
+ const description = typeof card.description === "function" ? (ready ? card.description(data) : null) : card.description;
391
+ // The heading's buttons: Edit or Add first, then the card's own. Held while the source loads; none once it failed.
392
+ const items = kind === "items" ? card : null;
393
+ const list = items && ready ? recordItems(data, items.items) : [];
394
+ const limit = items && ready ? recordLimit(data, items.limit) : null;
395
+ // The rules typed on the data are asked once it is read; until then each button is held, saying so.
396
+ const stateOf = (action, extra) => {
397
+ if (state.status === "failed")
398
+ return { kind: "hidden" };
399
+ const own = recordActionState(action, ready ? [data] : null, { readOnly, can: runtime.can });
400
+ if (own.kind !== "ready")
401
+ return own;
402
+ return extra ? { kind: "held", reason: extra } : own;
403
+ };
404
+ const header = [];
405
+ if (kind === "fields") {
406
+ const edit = card.edit;
407
+ if (edit) {
408
+ const action = { id: "edit", label: edit.label ?? "Edit", icon: edit.icon ?? _jsx(InlineIcon, { icon: iconPencil }), flow: edit };
409
+ header.push({ action, kind: "edit", state: stateOf(action) });
410
+ }
411
+ }
412
+ if (items?.add) {
413
+ const add = items.add;
414
+ const action = { id: "add", label: add.label ?? "Add", icon: add.icon ?? _jsx(InlineIcon, { icon: iconPlus }), flow: add };
415
+ const full = limit !== null && list.length >= limit ? recordLimitReason(limit, items.limitReason) : null;
416
+ header.push({ action, kind: "add", state: stateOf(action, full) });
417
+ }
418
+ for (const action of card.actions ?? [])
419
+ header.push({ action, kind: "edit", state: stateOf(action) });
420
+ let body;
421
+ if (state.status === "failed")
422
+ body = _jsx(Failed, { state: state, source: source, cardId: card.id, runtime: runtime });
423
+ else if (kind === "fields")
424
+ body = _jsx(FieldsBody, { card: card, data: data, loading: !ready, preferences: runtime.preferences });
425
+ else if (kind === "items")
426
+ body = _jsx(ItemsBody, { card: items, data: data, loading: !ready, runtime: runtime, readOnly: readOnly, busy: busy, say: held.say });
427
+ else if (!ready)
428
+ body = _jsx(HeldLines, {});
429
+ else
430
+ body = card.render(runtime.contextOf(card));
431
+ return (_jsxs("section", { ref: rootRef, id: card.id, className: cx("nx-record-card", `nx-record-card--${kind}`, card.wide && "nx-record-card--wide"), "aria-labelledby": titleId, "aria-busy": state.status === "loading" || busy.length > 0 || undefined, "data-state": state.status, onKeyDown: held.onKeyDown, children: [_jsxs("header", { className: "nx-record-card__head", children: [card.icon && (_jsx("span", { className: "nx-record-card__icon", "aria-hidden": "true", children: card.icon })), _jsxs("div", { className: "nx-record-card__main", children: [_jsxs("div", { className: "nx-record-card__text", children: [_jsxs(Heading, { id: titleId, className: "nx-record-card__title", tabIndex: -1, children: [card.title, items && limit !== null && _jsx("span", { className: "nx-record-card__count", children: recordCount(list.length, limit) })] }), shown(description) && _jsx("p", { className: "nx-record-card__lead", children: description })] }), _jsx(HeaderActions, { actions: header, card: card, runtime: runtime, say: held.say, busy: busy.length > 0 })] })] }), _jsx("div", { className: "nx-record-card__body", children: body }), held.note] }));
432
+ }
433
+ /** Two held lines of text, for a card of the page's own while its source loads: each a line box, as a paragraph's are. */
434
+ const HeldLines = () => (_jsxs("div", { className: "nx-record__held", "aria-hidden": "true", children: [_jsx("span", { className: "nx-record__held-line", children: _jsx(Bone, { width: "80%" }) }), _jsx("span", { className: "nx-record__held-line", children: _jsx(Bone, { width: "55%" }) })] }));
435
+ /** A fields card's body: label over value, from the inputs its Edit uses. */
436
+ function FieldsBody({ card, data, loading, preferences }) {
437
+ const fields = card.fields.map(recordField);
438
+ return (_jsx("dl", { className: "nx-record-fields", children: fields.map(field => {
439
+ if (!loading && field.hidden?.(data))
440
+ return null;
441
+ const label = field.label ?? field.input.label ?? field.input.name;
442
+ return (_jsxs("div", { className: cx("nx-record-field", recordFieldWide(field) && "nx-record-field--wide"), children: [_jsx("dt", { className: "nx-record-field__label", children: label }), _jsx("dd", { className: "nx-record-field__value", children: loading ? _jsx(HeldValue, { field: field }) : _jsx(FieldValue, { field: field, data: data, empty: card.empty ?? "Not said", preferences: preferences }) })] }, field.input.name));
443
+ }) }));
444
+ }
445
+ /** A value held while its source loads, as tall as it will be: two lines for long text, a chip's height for chips. */
446
+ function HeldValue({ field }) {
447
+ const held = recordFieldHeld(field);
448
+ // Each bone on a line of its own, as the text will be (`heldLines`; long text 2); a chip as the chips will be.
449
+ if (held === "lines" || field.heldLines !== undefined) {
450
+ const lines = recordHeldLines(field);
451
+ return (_jsx(_Fragment, { children: Array.from({ length: lines }, (_, index) => (_jsx("span", { className: "nx-record__held-line", children: _jsx(Bone, { width: index === lines - 1 && lines > 1 ? "55%" : "85%" }) }, index))) }));
452
+ }
453
+ if (held === "chips")
454
+ return (_jsx("ul", { className: "nx-record-chips", "aria-hidden": "true", children: _jsx("li", { className: "nx-table__tag nx-table__tag--plain nx-record__bone-tag", children: "\u00A0" }) }));
455
+ return _jsx(Bone, { width: "60%" });
456
+ }
457
+ /** One value as its input draws it: a choice by its label, a date in the person's format, chips, lines, Markdown. */
458
+ function FieldValue({ field, data, empty, preferences }) {
459
+ const value = isRecord(data) ? data[field.input.name] : undefined;
460
+ const none = _jsx("span", { className: "nx-record-field__empty", children: empty });
461
+ if (field.show) {
462
+ const own = field.show(value, data);
463
+ return shown(own) ? _jsx(_Fragment, { children: own }) : none;
464
+ }
465
+ const view = recordValueView(field.input, value, { look: field.look, preferences });
466
+ switch (view.kind) {
467
+ case "empty":
468
+ return none;
469
+ case "text":
470
+ return _jsx(_Fragment, { children: view.text });
471
+ case "lines":
472
+ return _jsx("span", { className: "nx-record-field__lines", children: view.text });
473
+ case "chips":
474
+ return (_jsx("ul", { className: "nx-record-chips", children: view.items.map((item, index) => (_jsx("li", { className: "nx-table__tag nx-table__tag--plain", children: item }, `${index}:${item}`))) }));
475
+ case "markdown":
476
+ return _jsx(MarkdownView, { value: view.text });
477
+ case "rich":
478
+ return _jsx(RichTextView, { value: view.html });
479
+ }
480
+ }
481
+ /** An items card's body: the rows, each with its picture or icon, its name and marks, lines, and icon actions. */
482
+ function ItemsBody({ card, data, loading, runtime, readOnly, busy, say, }) {
483
+ if (loading) {
484
+ return (_jsx("ul", { className: "nx-record-items", "aria-hidden": "true", children: Array.from({ length: card.heldRows ?? 2 }, (_, index) => (_jsxs("li", { className: "nx-record-item", children: [_jsx("span", { className: "nx-record-item__media nx-record-item__media--held" }), _jsxs("span", { className: "nx-record-item__text", children: [_jsx("span", { className: "nx-record-item__title nx-record__held-line", children: _jsx(Bone, { width: "45%" }) }), Array.from({ length: recordHeldLines({ heldLines: card.heldRowLines ?? 1 }) }, (_, line) => (_jsx("span", { className: "nx-record-item__line nx-record__held-line", children: _jsx(Bone, { width: line ? "40%" : "30%" }) }, line)))] })] }, index))) }));
485
+ }
486
+ const items = recordItems(data, card.items);
487
+ if (!items.length)
488
+ return _jsx("p", { className: "nx-record-card__empty", children: card.empty ?? "Nothing here yet." });
489
+ return (_jsx("ul", { className: "nx-record-items", children: items.map(item => {
490
+ const key = recordItemKey(item, card.itemKey);
491
+ const view = card.item(item, data);
492
+ const label = card.itemLabel?.(item) ?? itemTitle(card, item, data, key);
493
+ const working = busy.includes(key) || busy.includes("card");
494
+ const actions = (card.itemActions ?? [])
495
+ .map(action => ({ action, state: recordActionState(action, [item, data], { readOnly, can: runtime.can }) }))
496
+ .filter(entry => entry.state.kind !== "hidden");
497
+ const media = view.picture ?? view.icon;
498
+ return (_jsxs("li", { className: "nx-record-item", "data-key": key, "aria-busy": working || undefined, children: [shown(media) && _jsx("span", { className: cx("nx-record-item__media", !view.picture && "nx-record-item__media--icon"), children: media }), _jsxs("div", { className: "nx-record-item__text", children: [_jsxs("p", { className: "nx-record-item__title", children: [_jsx("span", { className: "nx-record-item__name", children: view.title }), _jsx(Marks, { marks: view.marks, className: "nx-record-item__marks" })] }), (view.lines ?? []).filter(shown).map((line, index) => (_jsx("p", { className: "nx-record-item__line", children: line }, index)))] }), actions.length > 0 && (_jsx("div", { className: "nx-record-item__actions", children: actions.map(({ action, state }) => {
499
+ const name = typeof action.label === "function" ? action.label(item, data) : action.label;
500
+ const held = state.kind === "held" ? state.reason : null;
501
+ return (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": recordActionName(name, label), title: held ? `${name}. ${held}` : name, "data-action": action.id, "data-held": held !== null || undefined, "aria-disabled": held !== null || working || undefined, onClick: event => {
502
+ if (held !== null)
503
+ return say(event.currentTarget, held);
504
+ if (!working)
505
+ runtime.runItem(card, action, item);
506
+ } }, action.id));
507
+ }) }))] }, key));
508
+ }) }));
509
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Whether to say that something is still under way: `true` only once `busy` has lasted 400ms, and then for at least
3
+ * 300ms, however soon `busy` ends. A status line that reads it says "Loading…" for a slow read and nothing for a quick
4
+ * one. A Crud's details footer and `DocumentFrame` use it.
5
+ */
6
+ export declare function useSlowBusy(busy: boolean): boolean;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ /**
4
+ * A read that ends sooner than this says nothing: most backends answer sooner, and a word shown for a frame or two is
5
+ * the flicker this avoids. Longer than a popup's 160ms fade, so the word never lands on a half-faded popup.
6
+ */
7
+ const SLOW_DELAY = 400;
8
+ /** Once the word shows, it stays at least this long, so a read that ends just after the delay does not blink it. */
9
+ const SLOW_HOLD = 300;
10
+ /**
11
+ * Whether to say that something is still under way: `true` only once `busy` has lasted 400ms, and then for at least
12
+ * 300ms, however soon `busy` ends. A status line that reads it says "Loading…" for a slow read and nothing for a quick
13
+ * one. A Crud's details footer and `DocumentFrame` use it.
14
+ */
15
+ export function useSlowBusy(busy) {
16
+ const [said, setSaid] = useState(false);
17
+ const since = useRef(0);
18
+ useEffect(() => {
19
+ if (busy) {
20
+ if (said)
21
+ return;
22
+ const timer = window.setTimeout(() => {
23
+ since.current = performance.now();
24
+ setSaid(true);
25
+ }, SLOW_DELAY);
26
+ return () => window.clearTimeout(timer);
27
+ }
28
+ if (!said)
29
+ return;
30
+ const left = since.current + SLOW_HOLD - performance.now();
31
+ if (left <= 0) {
32
+ setSaid(false);
33
+ return;
34
+ }
35
+ const timer = window.setTimeout(() => setSaid(false), left);
36
+ return () => window.clearTimeout(timer);
37
+ }, [busy, said]);
38
+ return said;
39
+ }
@@ -105,10 +105,19 @@ export declare abstract class CrudBackend {
105
105
  answeredRow<TRow extends TableRow>(result: unknown): Partial<TRow> | null;
106
106
  /**
107
107
  * The key an answer gives in place of the record: `Create` answering the new record's id. `null` when the answer
108
- * is the record itself, or says nothing. With a key the new record goes straight into the list, and where there is
109
- * a `details` endpoint it is read back on its own - one small request, never the whole list again.
108
+ * is the record itself, or says nothing. With a key the new record goes straight into the list, finished by `savedRow`;
109
+ * only a record whose list still cannot be told is read back on its own through `details` - never the whole list again.
110
110
  */
111
111
  answeredKey(result: unknown): TableKey | null;
112
+ /**
113
+ * A record a save wrote - what the form sent, under what the answer said - with the fields only the server keeps
114
+ * filled in as this backend writes them, so nothing has to be read back: a status, the times, the marks. `before` is
115
+ * the record as the page holds it now (an edit; `undefined` for a new one): an edit's form carries its own copy of those
116
+ * fields from when it opened, which a flag or a mark made since has left behind. Default: the record as it is - and a
117
+ * record whose list still cannot be told from it (`listMembership`) is read back once through `details`, where there is
118
+ * one.
119
+ */
120
+ savedRow<TRow extends TableRow>(action: "create" | "edit", record: TRow, before?: TRow): TRow;
112
121
  /**
113
122
  * Whether a record belongs in a list, read from the record itself. It is what lets one answer be written into the
114
123
  * lists that are **not** on screen: a flagged record joins the flagged list, a trashed one leaves the active list
@@ -176,6 +185,15 @@ export declare class NexusCrudBackend extends CrudBackend {
176
185
  answeredRow<TRow extends TableRow>(result: unknown): Partial<TRow> | null;
177
186
  /** `Create` and `Update` answer the record's id: a token when ids are encrypted, the number as text when not. */
178
187
  answeredKey(result: unknown): TableKey | null;
188
+ /**
189
+ * What a Nexus save leaves to the server, written as the server writes it, so nothing is read back (the owner,
190
+ * 2026-10-10: "the creationTime, recordStatus we will provide fake or just right now"). A new record lacking them is
191
+ * Active (every Create writes `NEXUS_ACTIVE_STATUS`), has no marks, was added now and never changed; what its answer
192
+ * said stays. An Update writes none of the status, the marks and the time added, so an edited record has the ones the
193
+ * page holds (`before`) - over its form's older copies - and was changed now. Times are UTC ISO strings, as a date-time
194
+ * column reads them.
195
+ */
196
+ savedRow<TRow extends TableRow>(action: "create" | "edit", record: TRow, before?: TRow): TRow;
179
197
  /**
180
198
  * The bands the owner settled on: under 40 is live, 25–29 of that is flagged, 40–59 is the trash, and 60 and up is
181
199
  * deleted. A row without a `recordStatus` says nothing, and the lists it cannot answer for load again.