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,136 @@
1
+ import { type IconNode } from "nexus-icons";
2
+ import type { ReactNode } from "react";
3
+ import type { CrudBackend } from "../../Helpers/CrudBackend.ts";
4
+ import { type CrudJoinedRecord } from "../../Helpers/CrudHelpers.ts";
5
+ import type { ApiResult } from "../../Interfaces/ApiInterfaces.ts";
6
+ import type { CrudEndpoints, CrudFormOptions, CrudInputs, CrudMassActionName, CrudRowActionName } from "../../Interfaces/CrudInterfaces.ts";
7
+ import { type FormValues, type InputParams } from "../../Interfaces/FormInterfaces.ts";
8
+ /** The subject the endpoints were given: `apiController(module, "Guest", "guest")` → "guest". */
9
+ export declare function crudSubject(api: CrudEndpoints): string | undefined;
10
+ /** How the built-in actions on one record read wherever they are offered: a table's row, a tree's row, a panel's buttons. */
11
+ export declare const CRUD_ACTION_WORDS: {
12
+ readonly create: {
13
+ readonly label: "Add New";
14
+ readonly icon: IconNode;
15
+ };
16
+ readonly edit: {
17
+ readonly label: "Edit";
18
+ readonly icon: IconNode;
19
+ };
20
+ readonly history: {
21
+ readonly label: "History";
22
+ readonly icon: IconNode;
23
+ };
24
+ readonly trash: {
25
+ readonly label: "Move to trash";
26
+ readonly icon: IconNode;
27
+ readonly tone: "danger";
28
+ };
29
+ readonly recover: {
30
+ readonly label: "Recover";
31
+ readonly description: "Back to the active list";
32
+ readonly icon: IconNode;
33
+ };
34
+ readonly delete: {
35
+ readonly label: "Delete permanently";
36
+ readonly icon: IconNode;
37
+ readonly tone: "danger";
38
+ };
39
+ readonly trashFromDelete: {
40
+ readonly label: "Move back to trash";
41
+ readonly icon: IconNode;
42
+ };
43
+ };
44
+ /** An action's words as a menu entry or a button takes them: the label, the icon drawn, and its tone and second line. */
45
+ export declare function crudActionEntry(action: keyof typeof CRUD_ACTION_WORDS): {
46
+ label: string;
47
+ icon: ReactNode;
48
+ tone?: "danger";
49
+ description?: string;
50
+ };
51
+ /** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
52
+ export declare const CRUD_ASKED: ReadonlySet<CrudRowActionName | CrudMassActionName>;
53
+ /**
54
+ * The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
55
+ * a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
56
+ * and the form is first in the DOM — which is also the order they stack in when there is no room for two. A details
57
+ * popup is marked, because there the panel is what the popup is for and takes the larger share.
58
+ */
59
+ export declare function CrudFormBody({ aside, details, below, children }: {
60
+ aside: ReactNode;
61
+ details?: boolean;
62
+ below?: boolean;
63
+ children: ReactNode;
64
+ }): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
65
+ /** The inputs of the Add or the Edit form, from one list, a list each, or a function of the action and the record. */
66
+ export declare function crudInputsFor<TRow extends FormValues>(inputs: CrudInputs<TRow>, action: "create" | "edit", row: TRow | null): Promise<readonly InputParams[]>;
67
+ /** What the Add or Edit popup is opened with. */
68
+ export interface CrudFormRequest<TRow extends FormValues = FormValues> {
69
+ api: CrudEndpoints;
70
+ action: "create" | "edit";
71
+ /** The record edited; `null` for a new one. */
72
+ row: TRow | null;
73
+ /** Values over the new record's (`form.newValues`): the parent it is added under. */
74
+ values?: Partial<TRow>;
75
+ /**
76
+ * Inputs fixed for this opening, by name, each with why: read-only with the lock and the reason as its note, their
77
+ * values (from `values`, or the edited record) kept and sent (`lockInputs`), and never rewritten by the form's own
78
+ * `onFieldChange`. An Add with fixed inputs is a popup and a draft of its own per set of fixed values (`crudFormPlace`),
79
+ * so an Add started inside one parent never comes back inside another; an Edit stays one popup per record, its draft
80
+ * per set of fixed values.
81
+ */
82
+ locked?: Readonly<Record<string, string>>;
83
+ /**
84
+ * Records the caller holds for the values it fixes - the row picked in another list that a new record is added for: a
85
+ * fixed dropdown names its value from them, with no lookup (`lockInputs`).
86
+ */
87
+ known?: readonly FormValues[];
88
+ inputs: CrudInputs<TRow>;
89
+ form?: CrudFormOptions<TRow>;
90
+ /** What one record is: "region". */
91
+ subject: string;
92
+ /** Tells the popups of one controller apart from another's. */
93
+ name: string;
94
+ /** The field that tells records apart. Default `id`. */
95
+ rowKey?: string;
96
+ /** The record's key as text, for the Edit popup's key and its draft. */
97
+ key?: string;
98
+ /** Beside the title, and under it on the dock while minimized: the record an Edit is of, where an Add adds ("Inside Bagmati"). */
99
+ label?: string;
100
+ /**
101
+ * After the save: the answer, the values sent, and what the form's dropdowns know of the records their keys name
102
+ * (`joined`), so the record's joined names are written with no call. The popup closes itself.
103
+ */
104
+ onSaved: (result: unknown, saved: TRow, joined: CrudJoinedLookup) => void;
105
+ }
106
+ /**
107
+ * The record behind a key of a form's dropdown (`name`), as a save names its joins (`crudJoinedNames`): the item the
108
+ * user picked for that key, else one of the dropdown's `selectedOptions` (given for a saved or a fixed value), else one
109
+ * the page holds (the dropdown's `peekOption`), else one of `held` (records the caller holds: a tree's parent, a narrowed
110
+ * list's picked record), else one the page held once and has marked old (`peekOption(key, true)`: an old name beats
111
+ * none). Its name is the dropdown's `labelKey` field, its status the record's `recordStatus`. `null` when none is known:
112
+ * nothing is asked for it.
113
+ */
114
+ export type CrudJoinedLookup = (name: string, key: string, held?: readonly FormValues[]) => CrudJoinedRecord | null;
115
+ /**
116
+ * Opens the Add or the Edit popup of a controller's record: its inputs in an `ApiForm` that calls `create` or `update`,
117
+ * a discard question before changes are lost, and `onSaved` with the answer.
118
+ *
119
+ * - An Add form with changes comes back as it was - the one of the same place, when inputs are fixed (`locked`). Asking
120
+ * for one replaces every untouched Add of the controller, so the dock keeps only forms with work in them.
121
+ * - A record has one Edit popup wherever it was opened from. Asked for again with the same inputs fixed, it comes back;
122
+ * with others (a tree's fixed form, a data table's free one), an untouched one is replaced, and one with changes comes
123
+ * back as it is, saying why: an older form's save would undo what the newer one saved.
124
+ */
125
+ export declare function openCrudForm<TRow extends FormValues>(request: CrudFormRequest<TRow>): Promise<void>;
126
+ /**
127
+ * Calls the endpoint of a built-in action on one record - asking first for those that move it between the lists - with
128
+ * the record's key in the path and its name in the messages. A flag carries the way it goes (`markOn`). `null` when the
129
+ * controller has no such endpoint or the question was answered No.
130
+ */
131
+ export declare function sendCrudRowAction<TRow extends FormValues>(api: CrudEndpoints, action: CrudRowActionName, row: TRow, options: {
132
+ rowKey?: string;
133
+ label?: string;
134
+ backend: CrudBackend;
135
+ markOn?: boolean;
136
+ }): Promise<ApiResult | null>;
@@ -0,0 +1,184 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconArrowBackUp, iconHistory, iconPencil, iconPlus, iconTrash, iconTrashX, InlineIcon } from "nexus-icons";
4
+ import { unwrapApiResult } from "../../Helpers/ApiResponses.js";
5
+ import { crudFormPlace, crudMessageAction, crudRowEndpoint } from "../../Helpers/CrudHelpers.js";
6
+ import { cx } from "../../Helpers/ClassHelpers.js";
7
+ import { FormStore } from "../../Helpers/FormStore.js";
8
+ import { lockedFieldChange, lockInputs } from "../../Helpers/InputParamsHelpers.js";
9
+ import { readPath } from "../../Helpers/TableHelpers.js";
10
+ import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
11
+ import { api as browserApi } from "../../Services/BrowserApi.js";
12
+ import { openPopup } from "../Layouts/PopupHost.js";
13
+ import { popupRuleFor } from "../Layouts/PopupRules.js";
14
+ import { toast } from "../Layouts/Toaster.js";
15
+ import { ApiForm } from "./ApiForm.js";
16
+ import { confirmDiscard, FormButtons } from "./SubmitForm.js";
17
+ /*
18
+ * What every view of a controller's records does the same way - the data table (`Crud`) and the tree (`CrudTree`): the
19
+ * Add and Edit popups, the call behind an action on one record, and the words and icons each action shows. One copy, so
20
+ * a record added from a tree is added through exactly the form the table opens.
21
+ */
22
+ /** The subject the endpoints were given: `apiController(module, "Guest", "guest")` → "guest". */
23
+ export function crudSubject(api) {
24
+ const endpoints = [api.create, api.update, api.details, ...Object.values(api.lists ?? {}), ...Object.values(api.pages ?? {})];
25
+ return endpoints.find(endpoint => endpoint?.subject)?.subject;
26
+ }
27
+ /** How the built-in actions on one record read wherever they are offered: a table's row, a tree's row, a panel's buttons. */
28
+ export const CRUD_ACTION_WORDS = {
29
+ // One fixed label on every CRUD, whatever its subject (the owner, 2026-10-10); the form it opens says "Add <subject>".
30
+ create: { label: "Add New", icon: iconPlus },
31
+ edit: { label: "Edit", icon: iconPencil },
32
+ history: { label: "History", icon: iconHistory },
33
+ trash: { label: "Move to trash", icon: iconTrash, tone: "danger" },
34
+ recover: { label: "Recover", description: "Back to the active list", icon: iconArrowBackUp },
35
+ delete: { label: "Delete permanently", icon: iconTrashX, tone: "danger" },
36
+ trashFromDelete: { label: "Move back to trash", icon: iconArrowBackUp },
37
+ };
38
+ /** An action's words as a menu entry or a button takes them: the label, the icon drawn, and its tone and second line. */
39
+ export function crudActionEntry(action) {
40
+ const words = CRUD_ACTION_WORDS[action];
41
+ return { label: words.label, icon: _jsx(InlineIcon, { icon: words.icon }), tone: words.tone, description: words.description };
42
+ }
43
+ /** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
44
+ export const CRUD_ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
45
+ /**
46
+ * The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
47
+ * a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
48
+ * and the form is first in the DOM — which is also the order they stack in when there is no room for two. A details
49
+ * popup is marked, because there the panel is what the popup is for and takes the larger share.
50
+ */
51
+ export function CrudFormBody({ aside, details, below, children }) {
52
+ if (!aside)
53
+ return children;
54
+ return (_jsxs("div", { className: cx("nx-crud-aside", details && "nx-crud-aside--details", below && "nx-crud-aside--below"), children: [_jsx("div", { className: "nx-crud-aside__main", children: children }), _jsx("aside", { className: "nx-crud-aside__side", children: aside })] }));
55
+ }
56
+ /** The inputs of the Add or the Edit form, from one list, a list each, or a function of the action and the record. */
57
+ export async function crudInputsFor(inputs, action, row) {
58
+ if (typeof inputs === "function")
59
+ return inputs(action, row);
60
+ if (Array.isArray(inputs))
61
+ return inputs;
62
+ const split = inputs;
63
+ return (action === "create" ? (split.create ?? split.edit) : (split.edit ?? split.create)) ?? [];
64
+ }
65
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
66
+ const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
67
+ /** `CrudJoinedLookup` over a form's inputs and its store. */
68
+ function joinedLookup(inputs, form) {
69
+ return (name, key, held = []) => {
70
+ const params = inputs.find(input => input.name === name);
71
+ if (!params || params.type !== EInputTypes.Dropdown || params.multiple)
72
+ return null;
73
+ const valueKey = params.valueKey ?? "value";
74
+ const labelKey = params.labelKey ?? "label";
75
+ const matches = (item) => isRecord(item) && String(item[valueKey]) === key;
76
+ // The form's own first: what the user picked, then the records the input was given for its saved value.
77
+ const record = [form.pickedItem(name), ...(params.selectedOptions ?? []), params.peekOption?.(key), ...held].find(matches) ?? [params.peekOption?.(key, true)].find(matches);
78
+ return record ? { name: record[labelKey], status: record.recordStatus } : null;
79
+ };
80
+ }
81
+ /** The Add and Edit popups open now, by popup key: the controller's name, the action, the form, and the place it was opened for. */
82
+ const openForms = new Map();
83
+ /** Brings a popup back from the dock; one on screen is left as it is. */
84
+ function bringBack(handle) {
85
+ if (handle.windowState === "minimized")
86
+ handle.restore();
87
+ }
88
+ /**
89
+ * Opens the Add or the Edit popup of a controller's record: its inputs in an `ApiForm` that calls `create` or `update`,
90
+ * a discard question before changes are lost, and `onSaved` with the answer.
91
+ *
92
+ * - An Add form with changes comes back as it was - the one of the same place, when inputs are fixed (`locked`). Asking
93
+ * for one replaces every untouched Add of the controller, so the dock keeps only forms with work in them.
94
+ * - A record has one Edit popup wherever it was opened from. Asked for again with the same inputs fixed, it comes back;
95
+ * with others (a tree's fixed form, a data table's free one), an untouched one is replaced, and one with changes comes
96
+ * back as it is, saying why: an older form's save would undo what the newer one saved.
97
+ */
98
+ export async function openCrudForm(request) {
99
+ const { api, action, row, values, locked, subject, name, rowKey = "id", key = "", label, onSaved } = request;
100
+ const endpoint = action === "create" ? api.create : api.update;
101
+ if (!endpoint)
102
+ return;
103
+ const options = request.form ?? {};
104
+ const newValues = typeof options.newValues === "function" ? options.newValues() : options.newValues;
105
+ // What the form opens with: a new record's values, or the record edited.
106
+ const opening = (action === "create" ? { ...newValues, ...values } : (row ?? {}));
107
+ // Fixed inputs make it the form of one place: an Add's popup and every draft carry the values fixed.
108
+ const place = locked ? crudFormPlace(Object.fromEntries(Object.keys(locked).map(field => [field, opening[field]]))) : "";
109
+ const popupKey = action === "create" ? `${name}-new${place}` : `${name}-edit-${key}`;
110
+ const open = openForms.get(popupKey);
111
+ if (open && (open.form.isChanged() || (action === "edit" && open.place === place))) {
112
+ bringBack(open.handle);
113
+ if (open.place !== place)
114
+ toast.info(`${label ?? capitalize(subject)} is already being edited`, "Its form is open with changes: save or discard them first.");
115
+ return;
116
+ }
117
+ if (action === "create") {
118
+ for (const [other, entry] of openForms)
119
+ if (other !== popupKey && entry.name === name && entry.action === "create" && !entry.form.isChanged())
120
+ entry.handle.close("replaced");
121
+ }
122
+ const given = await crudInputsFor(request.inputs, action, row);
123
+ const inputs = locked ? lockInputs(given, locked, opening, request.known) : given;
124
+ // The inputs lockInputs fixed - a copy each - are out of reach of the form's own rules too.
125
+ const onFieldChange = lockedFieldChange(options.onFieldChange, inputs.filter((params, index) => params !== given[index]).map(params => params.name));
126
+ const form = new FormStore();
127
+ const loader = options.load;
128
+ const id = row ? readPath(row, rowKey) : undefined;
129
+ const loadValue = action === "edit" && row && loader && loader !== "row"
130
+ ? loader === "details"
131
+ ? api.details
132
+ ? (signal) => browserApi.get(api.details, { params: { id, [rowKey]: id }, signal, toast: false }).then(answer => ({ ...row, ...unwrapApiResult(answer) }))
133
+ : undefined
134
+ : (signal) => loader(row, signal).then(full => ({ ...row, ...full }))
135
+ : undefined;
136
+ const handle = openPopup({
137
+ key: popupKey,
138
+ // One still open under this key was untouched, or opened for another place: a fresh form takes its place.
139
+ reopen: "replace",
140
+ title: action === "create" ? `Add ${subject}` : `Edit ${subject}`,
141
+ description: label,
142
+ icon: _jsx(InlineIcon, { icon: action === "create" ? iconPlus : iconPencil }),
143
+ size: options.size ?? (inputs.length > 8 ? "lg" : "md"),
144
+ closeOnBackdrop: false,
145
+ // A CRUD page's Add and Edit are the popups that can be put aside on the dock; a page's rule for the key may say no.
146
+ minimizable: popupRuleFor(popupKey)?.minimizable ?? true,
147
+ beforeClose: async () => {
148
+ const discard = await confirmDiscard(form);
149
+ if (discard)
150
+ form.dropDraft();
151
+ return discard;
152
+ },
153
+ content: popup => (_jsx(CrudFormBody, { aside: typeof options.aside === "function" ? options.aside({ form, action, row }) : options.aside, children: _jsx(ApiForm, { form: form, inputs: inputs, buttons: false, defaultValue: (action === "create" ? opening : (row ?? undefined)), loadValue: loadValue, api: endpoint, transform: options.transform ? formValues => options.transform(formValues, { action, row }) : undefined, confirm: options.confirm, validate: options.validate, onFieldChange: onFieldChange, design: options.design, draftKey: options.draftKey ? `${options.draftKey}:${name}:${action === "create" ? "new" : key}${place}` : undefined, "aria-label": action === "create" ? `Add ${subject}` : `Edit ${label}`, onSuccess: (result, saved) => {
154
+ onSaved(result, saved, joinedLookup(inputs, form));
155
+ popup.close();
156
+ } }) })),
157
+ footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
158
+ });
159
+ const entry = { name, action, handle, form: form, place };
160
+ openForms.set(popupKey, entry);
161
+ void handle.closed.then(() => {
162
+ if (openForms.get(popupKey) === entry)
163
+ openForms.delete(popupKey);
164
+ });
165
+ }
166
+ /**
167
+ * Calls the endpoint of a built-in action on one record - asking first for those that move it between the lists - with
168
+ * the record's key in the path and its name in the messages. A flag carries the way it goes (`markOn`). `null` when the
169
+ * controller has no such endpoint or the question was answered No.
170
+ */
171
+ export async function sendCrudRowAction(api, action, row, options) {
172
+ const endpoint = crudRowEndpoint(api, action);
173
+ if (!endpoint)
174
+ return null;
175
+ const rowKey = options.rowKey ?? "id";
176
+ const key = readPath(row, rowKey);
177
+ const call = {
178
+ params: { id: key, [rowKey]: key },
179
+ subject: options.label,
180
+ action: crudMessageAction(action, options.markOn),
181
+ ...(options.markOn !== undefined ? options.backend.flagRequest(options.markOn) : {}),
182
+ };
183
+ return CRUD_ASKED.has(action) ? browserApi.confirm(endpoint, call) : browserApi.send(endpoint, call);
184
+ }
@@ -0,0 +1,31 @@
1
+ import { type ReactNode } from "react";
2
+ import type { CrudNarrow, CrudNarrowPicker } from "../../Interfaces/CrudInterfaces.ts";
3
+ /** What a narrowed list says while nothing is picked, and on its Add: the box's reason, read with it. */
4
+ export interface CrudNarrowPickerWords {
5
+ empty: string;
6
+ add: string;
7
+ }
8
+ /** The narrowing a `narrowPicker` gives its CRUD, the parts it draws, and what the CRUD's Add and list do with it. */
9
+ export interface CrudNarrowPickerParts {
10
+ /** The CRUD's `narrow`, the picked record in it; nothing without a `narrowPicker`. */
11
+ narrow: CrudNarrow | undefined;
12
+ /** The box, for the toolbar. */
13
+ box: ReactNode;
14
+ /** The list's button to the box, for its empty message while the box asks for a pick; null otherwise. */
15
+ pickButton: ReactNode;
16
+ /** The box is settling as the page opens (a kept pick looked up, its records counted for a lone one): the list waits. */
17
+ waiting: boolean;
18
+ /** Nothing is picked and the box has settled: it asks for a pick. */
19
+ wanted: boolean;
20
+ /**
21
+ * Add pressed with nothing picked: the cursor goes to the box, the box is nudged (still, with reduced motion) and a
22
+ * screen reader hears Add's reason with it. False while the box cannot take it (settling, or not drawn).
23
+ */
24
+ askForAdd: () => boolean;
25
+ }
26
+ /**
27
+ * The narrowing a `narrowPicker` gives its CRUD, and the box it draws in the toolbar. Without one: nothing, and the
28
+ * CRUD's own `narrow` stands. `words` are the list's: the box's reason while it asks for a pick, and Add's after a press
29
+ * (default: the box's own words for "records").
30
+ */
31
+ export declare function useCrudNarrowPicker(options: CrudNarrowPicker | undefined, words?: CrudNarrowPickerWords): CrudNarrowPickerParts;
@@ -0,0 +1,180 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconArrowUp, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ import { flushSync } from "react-dom";
6
+ import { narrowBoxTakesCursor, narrowPickerPlaceholder, narrowPickerStorageKey, narrowPickWords, readNarrowPick, startNarrowPick, writeNarrowPick } from "../../Helpers/CrudNarrowPick.js";
7
+ import { Button } from "../Documents/Button.js";
8
+ import { Dropdown } from "../Inputs/Dropdown.js";
9
+ /** This browser's local storage, or nothing where it cannot be reached (a server draw, storage switched off). */
10
+ function browserStorage() {
11
+ try {
12
+ return typeof window === "undefined" ? null : window.localStorage;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /** Whether a popup (a dialog) or a panel (a menu, a list) is open over the page: a toast or a field's note is not one. */
19
+ function somethingOpen() {
20
+ if (document.querySelector("dialog[open]"))
21
+ return true;
22
+ try {
23
+ return Boolean(document.querySelector(":popover-open:not(.nx-toast, .nx-field-notice)"));
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /** Whether the whole of the box is on screen, so a cursor sent there scrolls nothing. */
30
+ function wholeOnScreen(box) {
31
+ const rect = box.getBoundingClientRect();
32
+ return rect.width > 0 && rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
33
+ }
34
+ /** A touch screen with no mouse, where a cursor in a text box raises the on-screen keyboard. */
35
+ function touchOnly() {
36
+ return typeof window.matchMedia === "function" && window.matchMedia("(hover: none) and (pointer: coarse)").matches;
37
+ }
38
+ /** Nudges the box (`.nx-field__box[data-nudge]`), again on every call. The stylesheet stills it with reduced motion. */
39
+ function nudge(input) {
40
+ const box = input.closest(".nx-field__box");
41
+ if (!(box instanceof HTMLElement))
42
+ return;
43
+ box.removeAttribute("data-nudge");
44
+ void box.offsetWidth;
45
+ box.setAttribute("data-nudge", "");
46
+ box.addEventListener("animationend", () => box.removeAttribute("data-nudge"), { once: true });
47
+ }
48
+ /**
49
+ * The narrowing a `narrowPicker` gives its CRUD, and the box it draws in the toolbar. Without one: nothing, and the
50
+ * CRUD's own `narrow` stands. `words` are the list's: the box's reason while it asks for a pick, and Add's after a press
51
+ * (default: the box's own words for "records").
52
+ */
53
+ export function useCrudNarrowPicker(options, words = narrowPickWords(options?.label, "record", "records")) {
54
+ const [picked, setPicked] = useState(null);
55
+ // Which opening has settled (`storage|valueKey`): until the one asked for now has, the list waits.
56
+ const [settledFor, setSettledFor] = useState(null);
57
+ // Add was pressed with nothing picked: the box's reason is Add's until a pick.
58
+ const [asked, setAsked] = useState(false);
59
+ const latest = useRef(options);
60
+ latest.current = options;
61
+ // A pick made before the box settled is the person's: settling never replaces it.
62
+ const touched = useRef(false);
63
+ // Pressed a key or a pointer since the page opened: the box then does not take the cursor.
64
+ const used = useRef(false);
65
+ // The moment the page opened has been judged: the box takes the cursor then or never.
66
+ const judged = useRef(false);
67
+ const inputRef = useRef(null);
68
+ const reasonId = `${useId()}-reason`;
69
+ const active = Boolean(options);
70
+ const storage = options?.remember ? narrowPickerStorageKey(options.remember) : null;
71
+ const valueKey = options ? (options.valueKey ?? options.picker.valueKey ?? "id") : "id";
72
+ const labelKey = options ? (options.labelKey ?? options.picker.labelKey) : undefined;
73
+ const opening = `${storage ?? ""}|${valueKey}`;
74
+ const waiting = active && settledFor !== opening && picked === null;
75
+ const wanted = active && !waiting && picked === null;
76
+ // The box opens on the remembered pick while it still offers it, as it offers it now (asked for by its key alone), else
77
+ // on the one record it offers, else it asks for a pick.
78
+ useEffect(() => {
79
+ if (!active)
80
+ return;
81
+ const controller = new AbortController();
82
+ const kept = browserStorage();
83
+ const saved = storage && kept ? readNarrowPick(kept, storage) : null;
84
+ void startNarrowPick(latest.current.picker, saved, valueKey, controller.signal).then(start => {
85
+ if (controller.signal.aborted)
86
+ return;
87
+ if (!touched.current) {
88
+ // Kept again as the key alone (a pick kept by an older build held its name too), forgotten when the box no longer
89
+ // offers it, or the lone record kept as a person's pick is.
90
+ if (storage && kept && start.keep !== undefined)
91
+ writeNarrowPick(kept, storage, start.keep);
92
+ if (start.picked) {
93
+ setPicked(start.picked);
94
+ latest.current?.onPick?.(start.picked);
95
+ }
96
+ }
97
+ setSettledFor(opening);
98
+ });
99
+ return () => controller.abort();
100
+ }, [active, storage, valueKey, opening]);
101
+ // Until the page's opening is judged, a key or a pointer pressed anywhere counts as the person at work; no longer after.
102
+ const stopWatching = useRef(null);
103
+ useEffect(() => {
104
+ if (!active || judged.current)
105
+ return;
106
+ const mark = () => {
107
+ used.current = true;
108
+ };
109
+ document.addEventListener("pointerdown", mark, true);
110
+ document.addEventListener("keydown", mark, true);
111
+ const stop = () => {
112
+ document.removeEventListener("pointerdown", mark, true);
113
+ document.removeEventListener("keydown", mark, true);
114
+ stopWatching.current = null;
115
+ };
116
+ stopWatching.current = stop;
117
+ return stop;
118
+ }, [active]);
119
+ // Settled with nothing picked as the page opens: the box takes the cursor, unless the person is at work elsewhere, a
120
+ // popup is open, the box is off screen, or a cursor would raise a phone's keyboard over the list.
121
+ useEffect(() => {
122
+ if (!active || waiting || judged.current)
123
+ return;
124
+ judged.current = true;
125
+ stopWatching.current?.();
126
+ const input = inputRef.current;
127
+ if (picked || !input || input.disabled || input.readOnly)
128
+ return;
129
+ const focused = document.activeElement;
130
+ const takes = narrowBoxTakesCursor({
131
+ used: used.current,
132
+ focusElsewhere: focused !== null && focused !== document.body && focused !== document.documentElement,
133
+ popupOpen: somethingOpen(),
134
+ touchOnly: touchOnly(),
135
+ inView: wholeOnScreen(input.closest(".nx-field__box") ?? input),
136
+ });
137
+ if (takes)
138
+ input.focus({ preventScroll: true });
139
+ }, [active, waiting, picked]);
140
+ if (!options)
141
+ return { narrow: undefined, box: null, pickButton: null, waiting: false, wanted: false, askForAdd: () => false };
142
+ const { picker, label, placeholder, remember: _remember, onPick: _onPick, ...narrowing } = options;
143
+ const boxPlaceholder = placeholder ?? narrowPickerPlaceholder(label);
144
+ function pick(value, item) {
145
+ touched.current = true;
146
+ // The box names the record it picked; one it only holds by key is looked up in what it has loaded.
147
+ const row = value === null ? null : (item ?? picker.peekOption?.(String(value)) ?? { [valueKey]: value });
148
+ setPicked(row);
149
+ setAsked(false);
150
+ const kept = browserStorage();
151
+ if (storage && kept)
152
+ writeNarrowPick(kept, storage, row ? String(row[valueKey]) : null);
153
+ latest.current?.onPick?.(row);
154
+ }
155
+ function askForAdd() {
156
+ const input = inputRef.current;
157
+ if (!wanted || !input || input.disabled || input.readOnly)
158
+ return false;
159
+ // The reason is Add's before the cursor lands, so a screen reader reads it with the box.
160
+ flushSync(() => setAsked(true));
161
+ input.focus();
162
+ nudge(input);
163
+ return true;
164
+ }
165
+ // The list's button: the cursor to the box and its list open, as a click on the box opens it.
166
+ function openBox() {
167
+ const input = inputRef.current;
168
+ if (!input)
169
+ return;
170
+ input.focus();
171
+ input.click();
172
+ }
173
+ const value = picked ? picked[valueKey] : null;
174
+ const box = (_jsxs(_Fragment, { children: [_jsx(Dropdown, { ...picker, ref: inputRef, "aria-label": label, "aria-describedby": wanted ? reasonId : undefined, size: "sm", placeholder: boxPlaceholder, multiple: false, value: value, selectedOptions: picked ? [picked] : [],
175
+ // The list needs a pick: required always, drawn as a field that must be set while it has none. Its reason is the
176
+ // list's, read with the box, never a message line under it in the toolbar.
177
+ required: true, validateOn: "submit", invalid: wanted, loading: waiting, onValueChange: (next, item) => pick(next, item) }), wanted && (_jsx("span", { id: reasonId, hidden: true, children: asked ? words.add : words.empty }))] }));
178
+ const pickButton = wanted ? (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowUp }), onClick: openBox, children: boxPlaceholder })) : null;
179
+ return { narrow: { ...narrowing, valueKey, ...(labelKey && { labelKey }), picked }, box, pickButton, waiting, wanted, askForAdd };
180
+ }
@@ -14,6 +14,34 @@ export interface CrudDetailsProps<TRow extends FormValues = FormValues> {
14
14
  load?: (signal: AbortSignal) => Promise<Partial<TRow> | null | undefined>;
15
15
  /** Draws the details instead of the list of fields. */
16
16
  render?: (row: TRow) => ReactNode;
17
+ /** The currency of a `money` field without its own, as the table writes it (`DataTableProps.currency`). */
18
+ currency?: string | null;
17
19
  }
18
- /** A record's fields as label and value, two to a line where there is room, in the formats of the table's columns. */
19
- export declare function CrudDetails<TRow extends FormValues = FormValues>({ row, columns, load, render }: CrudDetailsProps<TRow>): import("react").JSX.Element;
20
+ /**
21
+ * A record's fields as label and value, two to a line where there is room, in the formats of the table's columns. Where
22
+ * columns name a `group` (a table's band), each run of neighbouring columns with one group is a section under that
23
+ * heading; columns with no group draw as before, without one.
24
+ */
25
+ export declare function CrudDetails<TRow extends FormValues = FormValues>({ row, columns, load, render, currency }: CrudDetailsProps<TRow>): import("react").JSX.Element;
26
+ export interface CrudTabFooterProps {
27
+ /** Its words first (a `<p>`: what saving does), then the buttons (`FormButtons`, or `Button`s). */
28
+ children: ReactNode;
29
+ className?: string;
30
+ }
31
+ /**
32
+ * The footer of a tab in a record's workspace (`CrudDetailsOptions.tabs`): one bar, its words at the start and its
33
+ * buttons at the end, put last in the tab. While the tab scrolls it stays at the bottom of the workspace's panel, so a
34
+ * long form's Save never falls below the window; at the end of the tab it sits in its place. It is drawn on the panel's
35
+ * own surface under a hairline, across the panel's whole width, with the popup footer's room, and on a phone its words
36
+ * and its buttons wrap as whole groups. Outside a workspace it is a plain bar where it stands.
37
+ *
38
+ * A field the keyboard moves to is scrolled into view above it, never under it.
39
+ *
40
+ * @example
41
+ * <ApiForm form={form} buttons={false} api={STAYS.create} inputs={STAY_INPUTS} />
42
+ * <CrudTabFooter>
43
+ * <p role="status" aria-live="polite">3 nights in a double room · To pay Rs. 10,500.00</p>
44
+ * <FormButtons form={form} submitLabel="Save" />
45
+ * </CrudTabFooter>
46
+ */
47
+ export declare function CrudTabFooter({ children, className }: CrudTabFooterProps): import("react").JSX.Element;