nexus-shared 2.0.0 → 3.1.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 (176) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -903
  4. package/dist/Client.Index.d.ts +14 -0
  5. package/dist/Client.Index.js +14 -0
  6. package/dist/Components/Chats/Chat.d.ts +28 -0
  7. package/dist/Components/Chats/Chat.js +19 -0
  8. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  9. package/dist/Components/Chats/ChatButton.js +41 -0
  10. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  11. package/dist/Components/Chats/ChatComposer.js +177 -0
  12. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  13. package/dist/Components/Chats/ChatConversations.js +38 -0
  14. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  15. package/dist/Components/Chats/ChatPanel.js +94 -0
  16. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  17. package/dist/Components/Chats/ChatParts.js +96 -0
  18. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  19. package/dist/Components/Chats/ChatThread.js +96 -0
  20. package/dist/Components/Documents/Button.d.ts +6 -1
  21. package/dist/Components/Documents/Button.js +2 -2
  22. package/dist/Components/Documents/Menu.js +44 -28
  23. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  24. package/dist/Components/Documents/SplitButton.js +12 -8
  25. package/dist/Components/Documents/TabButtons.d.ts +29 -8
  26. package/dist/Components/Documents/TabButtons.js +24 -5
  27. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  28. package/dist/Components/Forms/ApiForm.js +15 -14
  29. package/dist/Components/Forms/Crud.js +342 -71
  30. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  31. package/dist/Components/Forms/CrudParts.js +37 -5
  32. package/dist/Components/Forms/ExcelImport.d.ts +58 -0
  33. package/dist/Components/Forms/ExcelImport.js +319 -0
  34. package/dist/Components/Forms/Form.js +9 -13
  35. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  36. package/dist/Components/Forms/SubmitForm.js +23 -12
  37. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  38. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  39. package/dist/Components/Inputs/DropdownParts.js +4 -6
  40. package/dist/Components/Inputs/GroupForm.js +4 -2
  41. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  42. package/dist/Components/Inputs/InputRenderer.js +1 -1
  43. package/dist/Components/Inputs/NumberBox.js +24 -2
  44. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  45. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  46. package/dist/Components/Inputs/RowsInput.js +55 -6
  47. package/dist/Components/Inputs/Slider.js +4 -1
  48. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  49. package/dist/Components/Inputs/TabularForm.js +117 -20
  50. package/dist/Components/Inputs/TimePanel.js +4 -3
  51. package/dist/Components/Inputs/TimePicker.js +2 -1
  52. package/dist/Components/Layouts/Popup.js +1 -4
  53. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  54. package/dist/Components/Layouts/ThemePicker.js +77 -6
  55. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  56. package/dist/Components/Layouts/ThemeScript.js +4 -2
  57. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  58. package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
  59. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  60. package/dist/Components/Viewers/Avatar.js +21 -0
  61. package/dist/Components/Viewers/DataTable.js +262 -60
  62. package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
  63. package/dist/Components/Viewers/DataTableColumns.js +200 -0
  64. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  65. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  66. package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
  67. package/dist/Components/Viewers/DataTableParts.js +24 -12
  68. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  69. package/dist/Helpers/AnimationHelpers.js +20 -2
  70. package/dist/Helpers/ApiClient.d.ts +22 -0
  71. package/dist/Helpers/ApiClient.js +4 -1
  72. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  73. package/dist/Helpers/ApiFormHelpers.js +73 -9
  74. package/dist/Helpers/ApiModules.d.ts +0 -4
  75. package/dist/Helpers/ApiModules.js +1 -0
  76. package/dist/Helpers/ApiResponses.d.ts +14 -4
  77. package/dist/Helpers/ApiResponses.js +116 -25
  78. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  79. package/dist/Helpers/ApiRoutes.js +2 -10
  80. package/dist/Helpers/ChatBackend.d.ts +134 -0
  81. package/dist/Helpers/ChatBackend.js +332 -0
  82. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  83. package/dist/Helpers/ChatHelpers.js +486 -0
  84. package/dist/Helpers/ChatHooks.d.ts +81 -0
  85. package/dist/Helpers/ChatHooks.js +175 -0
  86. package/dist/Helpers/ChatStore.d.ts +132 -0
  87. package/dist/Helpers/ChatStore.js +394 -0
  88. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  89. package/dist/Helpers/ChatSummaries.js +118 -0
  90. package/dist/Helpers/CrudBackend.d.ts +54 -9
  91. package/dist/Helpers/CrudBackend.js +103 -6
  92. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  93. package/dist/Helpers/CrudHelpers.js +76 -11
  94. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  95. package/dist/Helpers/ExcelBackend.js +296 -0
  96. package/dist/Helpers/ExcelHelpers.d.ts +126 -0
  97. package/dist/Helpers/ExcelHelpers.js +344 -0
  98. package/dist/Helpers/FormStore.d.ts +11 -5
  99. package/dist/Helpers/FormStore.js +44 -6
  100. package/dist/Helpers/MessageBuilder.js +1 -2
  101. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  102. package/dist/Helpers/NumberHelpers.js +41 -3
  103. package/dist/Helpers/PopoverHelpers.d.ts +46 -9
  104. package/dist/Helpers/PopoverHelpers.js +163 -24
  105. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  106. package/dist/Helpers/PopoverHooks.js +62 -0
  107. package/dist/Helpers/RowsStore.d.ts +14 -3
  108. package/dist/Helpers/RowsStore.js +37 -3
  109. package/dist/Helpers/TableColumns.d.ts +7 -0
  110. package/dist/Helpers/TableColumns.js +41 -13
  111. package/dist/Helpers/TableExport.js +12 -12
  112. package/dist/Helpers/TableFilters.d.ts +25 -0
  113. package/dist/Helpers/TableFilters.js +162 -0
  114. package/dist/Helpers/TableHelpers.d.ts +15 -4
  115. package/dist/Helpers/TableHelpers.js +28 -4
  116. package/dist/Helpers/TableStore.d.ts +11 -3
  117. package/dist/Helpers/TableStore.js +16 -5
  118. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  119. package/dist/Helpers/ThemeHelpers.js +46 -9
  120. package/dist/Helpers/UserPreferences.d.ts +14 -0
  121. package/dist/Helpers/UserPreferences.js +99 -0
  122. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  123. package/dist/Interfaces/ApiInterfaces.js +16 -10
  124. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  125. package/dist/Interfaces/ChatInterfaces.js +1 -0
  126. package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
  127. package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
  128. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  129. package/dist/Interfaces/FormInterfaces.d.ts +85 -7
  130. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  131. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  132. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  133. package/dist/Interfaces/TableInterfaces.d.ts +116 -9
  134. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  135. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  136. package/dist/Services/BrowserApi.d.ts +18 -1
  137. package/dist/Services/BrowserApi.js +18 -0
  138. package/dist/Services/ChatApi.d.ts +37 -0
  139. package/dist/Services/ChatApi.js +98 -0
  140. package/dist/Services/ExcelApi.d.ts +48 -0
  141. package/dist/Services/ExcelApi.js +196 -0
  142. package/dist/Services/ServerApi.d.ts +6 -1
  143. package/dist/Services/ServerApi.js +3 -0
  144. package/dist/Services/ThemeService.d.ts +18 -2
  145. package/dist/Services/ThemeService.js +80 -16
  146. package/dist/Shared.Index.d.ts +10 -0
  147. package/dist/Shared.Index.js +10 -0
  148. package/package.json +85 -82
  149. package/src/Styles/Nexus.Avatar.css +79 -0
  150. package/src/Styles/Nexus.Base.css +117 -70
  151. package/src/Styles/Nexus.Button.css +180 -151
  152. package/src/Styles/Nexus.Calendar.css +273 -275
  153. package/src/Styles/Nexus.Chat.css +1443 -0
  154. package/src/Styles/Nexus.Choice.css +343 -345
  155. package/src/Styles/Nexus.Crud.css +168 -84
  156. package/src/Styles/Nexus.DateTime.css +81 -81
  157. package/src/Styles/Nexus.Dropdown.css +725 -774
  158. package/src/Styles/Nexus.Editor.css +295 -297
  159. package/src/Styles/Nexus.Excel.css +389 -0
  160. package/src/Styles/Nexus.Form.css +279 -298
  161. package/src/Styles/Nexus.Index.css +37 -34
  162. package/src/Styles/Nexus.Input.css +602 -637
  163. package/src/Styles/Nexus.Markdown.css +237 -265
  164. package/src/Styles/Nexus.Menu.css +237 -249
  165. package/src/Styles/Nexus.Popup.css +724 -731
  166. package/src/Styles/Nexus.RichText.css +2 -32
  167. package/src/Styles/Nexus.Rows.css +637 -566
  168. package/src/Styles/Nexus.Slider.css +276 -278
  169. package/src/Styles/Nexus.Tab.Buttons.css +432 -364
  170. package/src/Styles/Nexus.Table.css +1386 -1014
  171. package/src/Styles/Nexus.Theme.Picker.css +286 -159
  172. package/src/Styles/Nexus.Themes.css +194 -194
  173. package/src/Styles/Nexus.Time.css +144 -147
  174. package/src/Styles/Nexus.Toast.css +263 -265
  175. package/src/Styles/Nexus.Tokens.css +89 -72
  176. package/src/Styles/Nexus.Tree.css +431 -435
@@ -0,0 +1,200 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconArrowBackUp, iconGripVertical, iconPin, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { isPopoverOpen } from "../../Helpers/PopoverHelpers.js";
7
+ import { usePopoverPanel } from "../../Helpers/PopoverHooks.js";
8
+ import { Button } from "../Documents/Button.js";
9
+ /** The rows of the panel: a band for every run of neighbours that name the same group, as the header draws them. */
10
+ function panelRows(columns, groups) {
11
+ const rows = [];
12
+ for (const column of columns) {
13
+ const group = groups.get(column.key);
14
+ const last = rows[rows.length - 1];
15
+ if (group && last && last.band === group)
16
+ last.columns.push(column);
17
+ else if (group)
18
+ rows.push({ band: group, columns: [column] });
19
+ else
20
+ rows.push({ band: null, column });
21
+ }
22
+ return rows;
23
+ }
24
+ /** The keys of a row, in order: one column, or every column of a band. */
25
+ function rowKeys(row) {
26
+ return row.band === null ? [row.column.key] : row.columns.map(column => column.key);
27
+ }
28
+ /** Moves `from` to `to` in a list. */
29
+ function moveItem(items, from, to) {
30
+ const next = [...items];
31
+ const [item] = next.splice(from, 1);
32
+ if (item === undefined)
33
+ return next;
34
+ next.splice(to, 0, item);
35
+ return next;
36
+ }
37
+ /** Which place the pointer is over, by the middle of each row: the index the row held would take. */
38
+ function dropIndex(boxes, y, from) {
39
+ for (let index = 0; index < boxes.length; index++) {
40
+ const box = boxes[index];
41
+ if (y < box.top + box.height / 2)
42
+ return index > from ? index - 1 : index;
43
+ }
44
+ return boxes.length - 1;
45
+ }
46
+ /** The text of a label that may be a node, for an accessible name. */
47
+ function textOf(label) {
48
+ return typeof label === "string" || typeof label === "number" ? String(label) : "the column";
49
+ }
50
+ /**
51
+ * The Columns panel, opened through its `open()` handle under `anchor`: a checkbox per column, bands with their columns under
52
+ * them, and a handle to drag a row into another place. The panel is a popover under the button, so it closes on Esc,
53
+ * on a click outside, and on Tab.
54
+ *
55
+ * @example
56
+ * <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
57
+ * onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
58
+ */
59
+ export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true, size = "md", changed = false, onToggle, onOrder, onReset, anchor, ref, }) {
60
+ const id = useId();
61
+ const panelId = `${id}-columns`;
62
+ const { panelRef, open, onToggle: onPanelToggle } = usePopoverPanel(anchor, ref);
63
+ // While a row is held: its key, the list it may move in, and where it would land.
64
+ const [drag, setDrag] = useState(null);
65
+ const [said, setSaid] = useState("");
66
+ // The columns of a band that were already hidden when its checkbox was cleared: shown again, the band keeps them off.
67
+ const bandMemory = useRef(new Map());
68
+ const all = columns.map(column => ({
69
+ key: column.key,
70
+ label: column.label,
71
+ shown: !hidden.has(column.key),
72
+ pin: pinned[column.key] ?? column.pinned ?? null,
73
+ movable: reorderable && column.movable !== false,
74
+ hideable: column.hideable !== false,
75
+ }));
76
+ const groups = new Map(columns.map(column => [column.key, column.group]));
77
+ const rows = panelRows(all, groups);
78
+ const shownCount = all.filter(column => column.shown).length;
79
+ const bands = rows.some(row => row.band !== null);
80
+ // A panel opened again starts without the last move's message, so a screen reader is not told about an old move.
81
+ useEffect(() => {
82
+ if (!open)
83
+ setSaid("");
84
+ }, [open]);
85
+ /** The whole order after moving a row of `list` ("" for the panel itself, a band's name for its own columns). */
86
+ function moved(list, from, to) {
87
+ if (list === "") {
88
+ const next = moveItem(rows, from, to);
89
+ return next.flatMap(rowKeys);
90
+ }
91
+ const band = rows.find(row => row.band === list);
92
+ if (!band || band.band === null)
93
+ return null;
94
+ const next = moveItem(band.columns, from, to);
95
+ return rows.flatMap(row => (row.band === list ? next.map(column => column.key) : rowKeys(row)));
96
+ }
97
+ function move(list, from, to, label) {
98
+ const length = list === "" ? rows.length : rows.find(row => row.band === list)?.columns.length;
99
+ if (!length)
100
+ return;
101
+ const target = Math.min(Math.max(to, 0), length - 1);
102
+ if (target === from)
103
+ return;
104
+ const order = moved(list, from, target);
105
+ if (!order)
106
+ return;
107
+ onOrder(order);
108
+ setSaid(list === "" ? `${label} is now ${target + 1} of ${length}.` : `${label} is now ${target + 1} of ${length} in ${list}.`);
109
+ }
110
+ function onHandleKeyDown(event, list, index, label) {
111
+ const length = list === "" ? rows.length : (rows.find(row => row.band === list)?.columns.length ?? 0);
112
+ const step = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : event.key === "Home" ? -index : event.key === "End" ? length - 1 - index : 0;
113
+ if (!step)
114
+ return;
115
+ event.preventDefault();
116
+ move(list, index, index + step, label);
117
+ }
118
+ function onHandlePointerDown(event, list, index, key, label) {
119
+ if (event.button !== 0)
120
+ return;
121
+ // A row moves inside its own list only: a band among the bands, a column of a band among its siblings.
122
+ const owner = event.currentTarget.closest("[data-list]");
123
+ if (!owner)
124
+ return;
125
+ const handle = event.currentTarget;
126
+ const boxes = [...owner.children].map(child => child.getBoundingClientRect());
127
+ handle.setPointerCapture(event.pointerId);
128
+ setDrag({ key, list, to: index });
129
+ const end = (done, y) => {
130
+ handle.removeEventListener("pointermove", onMove);
131
+ handle.removeEventListener("pointerup", up);
132
+ handle.removeEventListener("pointercancel", cancel);
133
+ handle.removeEventListener("keydown", escape);
134
+ setDrag(null);
135
+ if (done && y !== undefined)
136
+ move(list, index, dropIndex(boxes, y, index), label);
137
+ };
138
+ const onMove = (moved) => setDrag({ key, list, to: dropIndex(boxes, moved.clientY, index) });
139
+ const up = (moved) => {
140
+ if (handle.hasPointerCapture(moved.pointerId))
141
+ handle.releasePointerCapture(moved.pointerId);
142
+ end(true, moved.clientY);
143
+ };
144
+ const cancel = () => end(false);
145
+ // Esc puts the row back where it was.
146
+ const escape = (key) => {
147
+ if (key.key !== "Escape")
148
+ return;
149
+ key.preventDefault();
150
+ key.stopPropagation();
151
+ end(false);
152
+ };
153
+ handle.addEventListener("pointermove", onMove);
154
+ handle.addEventListener("pointerup", up);
155
+ handle.addEventListener("pointercancel", cancel);
156
+ handle.addEventListener("keydown", escape);
157
+ }
158
+ /** The band's checkbox: off hides every column under it, on brings back the ones it was not already hiding. */
159
+ function toggleBand(band, columnsOfBand, show) {
160
+ if (show) {
161
+ const kept = new Set(bandMemory.current.get(band) ?? []);
162
+ bandMemory.current.delete(band);
163
+ for (const column of columnsOfBand)
164
+ if (!column.shown && !kept.has(column.key))
165
+ onToggle(column.key, false);
166
+ return;
167
+ }
168
+ bandMemory.current.set(band, columnsOfBand.filter(column => !column.shown).map(column => column.key));
169
+ for (const column of columnsOfBand)
170
+ if (column.shown)
171
+ onToggle(column.key, true);
172
+ }
173
+ function hidePopover(panel) {
174
+ if (isPopoverOpen(panel))
175
+ panel.hidePopover();
176
+ }
177
+ /** One column's row: the handle, the checkbox, the label, and a mark when it is pinned. */
178
+ function columnRow(column, list, index, inBand, bandOff) {
179
+ const lastShown = column.shown && shownCount <= 1;
180
+ return (_jsxs("li", { className: cx("nx-columns__row", inBand && "nx-columns__row--child"), "data-held": drag?.key === column.key ? "" : undefined, "data-drop": drag && drag.list === list && drag.key !== column.key && drag.to === index ? "" : undefined, children: [column.movable && !bandOff ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${textOf(column.label)}`, title: inBand ? `Drag to move it inside ${list}, or use the arrow keys` : "Drag to move, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, list, index, column.key, textOf(column.label)), onKeyDown: event => onHandleKeyDown(event, list, index, textOf(column.label)), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: column.shown && !bandOff, disabled: bandOff || !column.hideable || lastShown, title: bandOff ? `${list} is hidden` : column.hideable ? undefined : "Always shown", onChange: event => onToggle(column.key, !event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: column.label }) })] }), column.pin && (_jsxs("span", { className: "nx-columns__pin", title: `Pinned to the ${column.pin === "left" ? "left" : "right"}`, children: [_jsx(InlineIcon, { icon: iconPin }), _jsx("span", { className: "nx-visually-hidden", children: `Pinned to the ${column.pin === "left" ? "left" : "right"}` })] }))] }, column.key));
181
+ }
182
+ return (_jsx(_Fragment, { children: _jsxs("div", { ref: panelRef, id: panelId, popover: "auto", role: "dialog", tabIndex: -1, "aria-labelledby": `${panelId}-title`, className: cx("nx-popover", "nx-table-panel", "nx-columns", size === "sm" && "nx-columns--sm"), "data-dragging": drag ? "" : undefined, onToggle: onPanelToggle, onKeyDown: event => {
183
+ if (event.key === "Tab")
184
+ hidePopover(event.currentTarget);
185
+ }, children: [_jsxs("div", { className: "nx-table-panel__head", children: [_jsx("span", { className: "nx-table-panel__title", id: `${panelId}-title`, children: "Columns" }), _jsxs("span", { className: "nx-table-panel__count", children: [shownCount, " of ", all.length, " shown"] })] }), _jsx("ul", { className: "nx-columns__list nx-popover__scroll", "data-list": "", children: rows.map((row, index) => {
186
+ if (row.band === null)
187
+ return columnRow(row.column, "", index, false, false);
188
+ const band = row.band;
189
+ // A band whose columns were split into two runs is drawn twice, so a row is known by its first column, never
190
+ // by the band's name: two rows under one name would be one row to React.
191
+ const bandId = `band:${row.columns[0].key}`;
192
+ const shown = row.columns.filter(column => column.shown).length;
193
+ const movable = row.columns.every(column => column.movable);
194
+ const last = shown > 0 && shownCount - shown < 1;
195
+ return (_jsxs("li", { className: "nx-columns__row nx-columns__row--band", "data-held": drag?.key === bandId ? "" : undefined, "data-drop": drag && drag.list === "" && drag.key !== bandId && drag.to === index ? "" : undefined, children: [_jsxs("div", { className: "nx-columns__band", children: [movable ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${band}`, title: "Drag to move the whole band, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, "", index, bandId, band), onKeyDown: event => onHandleKeyDown(event, "", index, band), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: shown > 0, disabled: last, ref: input => {
196
+ if (input)
197
+ input.indeterminate = shown > 0 && shown < row.columns.length;
198
+ }, "aria-label": `${band}: ${shown} of ${row.columns.length} shown`, onChange: event => toggleBand(band, row.columns, event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: band }) })] }), _jsxs("span", { className: "nx-columns__band-count", children: [shown, " / ", row.columns.length] })] }), _jsx("ul", { className: "nx-columns__children", "data-list": band, children: row.columns.map((column, childIndex) => columnRow(column, band, childIndex, true, shown === 0)) })] }, bandId));
199
+ }) }), _jsxs("p", { className: "nx-columns__note", children: [reorderable ? "Drag a handle, or move it with the arrow keys." : "This table keeps the order of its columns.", bands ? " A column under a band moves inside it; the band moves as one." : ""] }), onReset && (_jsx("div", { className: "nx-table-panel__foot", children: _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), disabled: !changed, onClick: () => onReset(), children: "Reset columns" }) })), _jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: said })] }) }));
200
+ }
@@ -0,0 +1,21 @@
1
+ import { type Ref, type RefObject } from "react";
2
+ import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
3
+ import type { InputParams } from "../../Interfaces/FormInterfaces.ts";
4
+ import type { TableFilters } from "../../Interfaces/TableInterfaces.ts";
5
+ export interface TableFiltersPanelProps {
6
+ /** The filter inputs, as the table prepared them (`asFilterInput`). */
7
+ inputs: readonly InputParams[];
8
+ /** The table's filters: a change from outside, such as Clear filters, loads them into the form. */
9
+ value: TableFilters | undefined;
10
+ /** Milliseconds a typed value waits: the table's search delay. */
11
+ delay: number;
12
+ /** The rows the search and the filters leave, and every row, for the foot. */
13
+ matches: number;
14
+ total: number;
15
+ onChange: (values: Readonly<Record<string, unknown>>) => void;
16
+ /** The options button (⋮) the panel opens under; Esc gives the focus back to it. */
17
+ anchor: RefObject<HTMLElement | null>;
18
+ ref?: Ref<PopoverPanelHandle>;
19
+ }
20
+ /** The Filters panel, opened through its `open()` handle: a `Form` of the filter inputs, the count, and Clear all. */
21
+ export declare function TableFiltersPanel({ inputs, value, delay, matches, total, onChange, anchor, ref }: TableFiltersPanelProps): import("react").JSX.Element;
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconFilterOff, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useId, useRef } from "react";
5
+ import { usePopoverPanel } from "../../Helpers/PopoverHooks.js";
6
+ import { activeFilters } from "../../Helpers/TableFilters.js";
7
+ import { Button } from "../Documents/Button.js";
8
+ import { Form, useForm } from "../Forms/Form.js";
9
+ import { formatCount } from "./DataTableParts.js";
10
+ /*
11
+ * The Filters panel: the table's filter inputs as one small form under the options button (⋮), applied as they change.
12
+ * A choice applies at once; a value that can come a key or a step at a time (text, a number, a slider, a date) waits
13
+ * the table's search delay, or Enter, or the panel closing. Esc closes the panel, an open list or calendar first, and
14
+ * never clears a filter on its way out.
15
+ */
16
+ /** Inputs typed into or dragged: their value waits until the person stops. A date box reads a date from part of one. */
17
+ const TYPED = new Set(["text", "email", "search", "tel", "url", "textarea", "number", "slider", "range-slider", "date", "date-range", "time", "date-time"]);
18
+ /** The Filters panel, opened through its `open()` handle: a `Form` of the filter inputs, the count, and Clear all. */
19
+ export function TableFiltersPanel({ inputs, value, delay, matches, total, onChange, anchor, ref }) {
20
+ const titleId = useId();
21
+ const form = useForm();
22
+ // The form is drawn on the first open and kept: what was typed, and the labels a server list loaded, stay.
23
+ const { panelRef, shown, onToggle } = usePopoverPanel(anchor, ref, () => form.focus());
24
+ const pending = useRef(undefined);
25
+ const count = value ? Object.keys(value).length : 0;
26
+ const apply = () => {
27
+ clearTimeout(pending.current);
28
+ pending.current = undefined;
29
+ onChange(form.getValue());
30
+ };
31
+ const wait = () => {
32
+ clearTimeout(pending.current);
33
+ pending.current = setTimeout(apply, delay);
34
+ };
35
+ const flush = () => pending.current !== undefined && apply();
36
+ // Filters changed from outside reach the form, unless a typed value is still on its way.
37
+ useEffect(() => {
38
+ if (form.started && pending.current === undefined && JSON.stringify(activeFilters(inputs, form.getValue())) !== JSON.stringify(value))
39
+ form.load(value ?? {});
40
+ }, [form, inputs, value]);
41
+ useEffect(() => () => clearTimeout(pending.current), []);
42
+ return (_jsxs("div", { ref: panelRef, popover: "auto", role: "dialog", tabIndex: -1, "aria-labelledby": titleId, className: "nx-popover nx-table-panel nx-table-filters", onToggle: onToggle, onBeforeToggle: event => {
43
+ // Before the click that closes it lands: a clear that click makes then comes after what was typed.
44
+ if (event.target === event.currentTarget && event.newState === "closed")
45
+ flush();
46
+ }, onKeyDownCapture: event => {
47
+ // Kept from the inputs, whose Esc clears a value first: with no list or calendar open, Esc closes the panel.
48
+ if (event.key === "Escape" && !event.currentTarget.querySelector(":popover-open"))
49
+ event.stopPropagation();
50
+ }, onKeyDown: event => {
51
+ if (event.key === "Enter")
52
+ flush();
53
+ }, onInput: () => {
54
+ // A key that changes nothing the box reads ("2025-01-01 -") is typing too: a waiting value waits on.
55
+ if (pending.current !== undefined)
56
+ wait();
57
+ }, children: [_jsxs("div", { className: "nx-table-panel__head", children: [_jsx("span", { className: "nx-table-panel__title", id: titleId, children: "Filters" }), _jsx("span", { className: "nx-table-panel__count", children: count ? `${count} on` : "None" })] }), _jsx("div", { className: "nx-popover__scroll", children: shown && (_jsx(Form, { form: form, inputs: inputs, size: "sm", defaultValue: value, className: "nx-table-filters__form", onValueChange: (_, name) => {
58
+ if (name === null)
59
+ return;
60
+ if (delay > 0 && TYPED.has(inputs.find(params => params.name === name)?.type ?? ""))
61
+ wait();
62
+ else
63
+ apply();
64
+ } })) }), _jsxs("div", { className: "nx-table-panel__foot", children: [_jsxs("span", { className: "nx-table-filters__rows", "aria-live": "polite", children: [formatCount(matches), " of ", formatCount(total), " rows"] }), _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconFilterOff }), disabled: !count, onClick: () => {
65
+ form.load({});
66
+ apply();
67
+ }, children: "Clear all" })] })] }));
68
+ }
@@ -38,8 +38,10 @@ export declare function TableColumnGroup<TRow extends TableRow>({ columns, width
38
38
  colRefs: Map<string, HTMLTableColElement>;
39
39
  }): import("react").JSX.Element;
40
40
  /** The number of a row, its checkbox, or both: the number until the pointer is over the row or something is selected. */
41
- export declare function TableIndexCell({ heading, own, number, numbered, selectable, checkbox, }: {
41
+ export declare function TableIndexCell({ heading, rowSpan, own, number, numbered, selectable, checkbox, }: {
42
42
  heading?: boolean;
43
+ /** 2 in the heading while the header has a band of group headings above the columns' own. */
44
+ rowSpan?: number;
43
45
  own: {
44
46
  offset: number;
45
47
  edge: boolean;
@@ -74,9 +76,11 @@ export interface TableRowViewProps<TRow extends TableRow> {
74
76
  onToggle: (row: TRow, on: boolean, event: MouseEvent<HTMLInputElement>) => void;
75
77
  onAction: (row: TRow, action: TableRowAction<TRow>) => void;
76
78
  onOpen?: (row: TRow) => void;
79
+ /** A single click opens the row, as a double-click does. */
80
+ openOnClick?: boolean;
77
81
  }
78
82
  /** One row. It renders again only when its row, its state, or the columns change. */
79
- declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, }: TableRowViewProps<TRow>): import("react").JSX.Element;
83
+ declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }: TableRowViewProps<TRow>): import("react").JSX.Element;
80
84
  export declare const TableRowView: typeof TableRowViewInner;
81
85
  /**
82
86
  * The head of the pinned section: one row across the table with a button that folds the pinned records open and shut,
@@ -123,8 +127,8 @@ export declare function TablePageSize({ pageSize, pageSizes, compact, onChange }
123
127
  onChange: (size: number) => void;
124
128
  }): import("react").JSX.Element | null;
125
129
  /**
126
- * Under the table: which rows show and how many the search left ("Showing 41–50 of 234 · filtered from 1,000"), the page
127
- * of how many, and the pager.
130
+ * Under the table: which rows show and how many the search and the filters left ("Showing 41–50 of 234 · filtered
131
+ * from 1,000"), the page of how many, and the pager.
128
132
  */
129
133
  export declare function TableFooter<TRow extends TableRow>({ state, disabled, onPage }: {
130
134
  state: TableState<TRow>;
@@ -75,7 +75,7 @@ export function TableColumnGroup({ columns, widths, frame, colRefs, }) {
75
75
  return (_jsxs("colgroup", { children: [own(TABLE_INDEX_COLUMN, Boolean(frame.index)), columns.map(view => (_jsx("col", { ref: hold(view.column.key), style: { width: widths[view.column.key] ?? cssLength(view.column.width) ?? FALLBACK_COLUMN_WIDTH } }, view.column.key))), _jsx("col", { className: "nx-table__col-filler" }), own(TABLE_ACTIONS_COLUMN, Boolean(frame.actions))] }));
76
76
  }
77
77
  /** The number of a row, its checkbox, or both: the number until the pointer is over the row or something is selected. */
78
- export function TableIndexCell({ heading, own, number, numbered, selectable, checkbox, }) {
78
+ export function TableIndexCell({ heading, rowSpan, own, number, numbered, selectable, checkbox, }) {
79
79
  const shared = {
80
80
  className: cx(heading ? "nx-table__heading" : "nx-table__cell", "nx-table__cell--index"),
81
81
  "data-col": TABLE_INDEX_COLUMN,
@@ -86,19 +86,31 @@ export function TableIndexCell({ heading, own, number, numbered, selectable, che
86
86
  style: ownStyle(own, "left"),
87
87
  };
88
88
  const content = (_jsxs(_Fragment, { children: [numbered && (_jsx("span", { className: "nx-table__number", "aria-hidden": selectable || undefined, children: number })), checkbox] }));
89
- return heading ? (_jsx("th", { scope: "col", ...shared, children: content })) : (_jsx("td", { ...shared, children: content }));
89
+ return heading ? (_jsx("th", { scope: "col", rowSpan: rowSpan, ...shared, children: content })) : (_jsx("td", { ...shared, children: content }));
90
+ }
91
+ /** What in a row is a control of its own: a click there does what the control does, and never opens the row. */
92
+ const ROW_CONTROLS = "button, a, input, select, textarea, label, [role=menu], [role=menuitem]";
93
+ /**
94
+ * Whether a click on a row should open it. Never on one of its controls. A single click also does not when it ends a
95
+ * text selection inside the row — somebody dragging across a cell to copy a code is not asking for a popup — but a
96
+ * double-click is not asked that, because a double-click selects the word under the pointer on its way in.
97
+ */
98
+ function opensRow(event, single) {
99
+ if (event.target.closest(ROW_CONTROLS))
100
+ return false;
101
+ if (!single)
102
+ return true;
103
+ const selection = window.getSelection();
104
+ return !(selection && !selection.isCollapsed && event.currentTarget.contains(selection.anchorNode));
90
105
  }
91
106
  /** One row. It renders again only when its row, its state, or the columns change. */
92
- function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, }) {
107
+ function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }) {
93
108
  const inline = actions?.filter(action => action.inline && action.icon) ?? [];
94
109
  const inMenu = actions?.filter(action => !(action.inline && action.icon)) ?? [];
95
110
  const items = inMenu.map(action => ({ value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled }));
96
- return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-pinned": pinned || undefined, "data-selected": selected || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, onDoubleClick: onOpen &&
97
- (event => {
98
- if (event.target.closest("button, a, input, label, [role=menu]"))
99
- return;
100
- onOpen(row);
101
- }), children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: pinned ? _jsx(InlineIcon, { icon: iconPin, className: "nx-table__pin-mark" }) : formatCount(number), numbered: numbered || Boolean(pinned), selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", title: selectionFull ? "No more rows can be selected." : undefined, children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: selected, disabled: selectionFull && !selected, "aria-label": `Select ${label}`, onClick: event => onToggle(row, event.currentTarget.checked, event), onChange: () => undefined }) })) })), columns.map(view => (_jsx("td", { className: cx("nx-table__cell", `nx-table__cell--${columnAlign(view.column)}`, view.column.wrap && "nx-table__cell--wrap"), "data-col": view.column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, style: columnStyle(view, sized), children: _jsx("span", { className: "nx-table__value", children: _jsx(TableCellValue, { column: view.column, row: row, options: cellOptions }) }) }, view.column.key))), sized && _jsx("td", { className: "nx-table__cell nx-table__cell--filler" }), frame.actions && (_jsx("td", { className: "nx-table__cell nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: ownStyle(frame.actions, "right"), children: _jsxs("div", { className: "nx-table__actions", children: [inline.map(action => (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": `${action.label}: ${label}`, title: action.label, "data-action": action.id, disabled: busy || action.disabled, onClick: () => onAction(row, action) }, action.id))), items.length > 0 && (_jsx(Menu, { items: items, align: "end", "aria-label": `Actions: ${label}`, onSelect: value => onAction(row, inMenu.find(action => action.id === value)), children: _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), loading: busy, "aria-label": `More actions: ${label}`, title: "More actions" }) })), items.length === 0 && busy && _jsx(Button, { variant: "ghost", size: "sm", loading: true, "aria-label": `Working: ${label}` })] }) }))] }));
111
+ return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-pinned": pinned || undefined, "data-selected": selected || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, "data-open": (onOpen && openOnClick) || undefined,
112
+ // One way in: with a single click the row is already open by the time a double-click would arrive.
113
+ onClick: onOpen && openOnClick ? event => opensRow(event, true) && onOpen(row) : undefined, onDoubleClick: onOpen && !openOnClick ? event => opensRow(event, false) && onOpen(row) : undefined, children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: pinned ? _jsx(InlineIcon, { icon: iconPin, className: "nx-table__pin-mark" }) : formatCount(number), numbered: numbered || Boolean(pinned), selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", title: selectionFull ? "No more rows can be selected." : undefined, children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: selected, disabled: selectionFull && !selected, "aria-label": `Select ${label}`, onClick: event => onToggle(row, event.currentTarget.checked, event), onChange: () => undefined }) })) })), columns.map(view => (_jsx("td", { className: cx("nx-table__cell", `nx-table__cell--${columnAlign(view.column)}`, view.column.wrap && "nx-table__cell--wrap"), "data-col": view.column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, style: columnStyle(view, sized), children: _jsx("span", { className: "nx-table__value", children: _jsx(TableCellValue, { column: view.column, row: row, options: cellOptions }) }) }, view.column.key))), sized && _jsx("td", { className: "nx-table__cell nx-table__cell--filler" }), frame.actions && (_jsx("td", { className: "nx-table__cell nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: ownStyle(frame.actions, "right"), children: _jsxs("div", { className: "nx-table__actions", children: [inline.map(action => (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": `${action.label}: ${label}`, title: action.label, "data-action": action.id, disabled: busy || action.disabled, onClick: () => onAction(row, action) }, action.id))), items.length > 0 && (_jsx(Menu, { items: items, align: "end", "aria-label": `Actions: ${label}`, onSelect: value => onAction(row, inMenu.find(action => action.id === value)), children: _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), loading: busy, "aria-label": `More actions: ${label}`, title: "More actions" }) })), items.length === 0 && busy && _jsx(Button, { variant: "ghost", size: "sm", loading: true, "aria-label": `Working: ${label}` })] }) }))] }));
102
114
  }
103
115
  export const TableRowView = memo(TableRowViewInner);
104
116
  /**
@@ -131,8 +143,8 @@ export function TablePageSize({ pageSize, pageSizes, compact = false, onChange }
131
143
  return (_jsx(Menu, { items: items, kind: "radio", size: "sm", align: compact ? "start" : "end", "aria-label": "Rows per page", onSelect: value => onChange(Number(value)), children: _jsx(Button, { variant: "ghost", size: "sm", iconEnd: _jsx(InlineIcon, { icon: iconChevronDown }), className: cx("nx-table__page-size", compact && "nx-table__page-size--field"), "aria-label": `Rows per page: ${pageSize}`, title: "Rows per page", children: compact ? formatCount(pageSize) : `${formatCount(pageSize)} / page` }) }));
132
144
  }
133
145
  /**
134
- * Under the table: which rows show and how many the search left ("Showing 41–50 of 234 · filtered from 1,000"), the page
135
- * of how many, and the pager.
146
+ * Under the table: which rows show and how many the search and the filters left ("Showing 41–50 of 234 · filtered
147
+ * from 1,000"), the page of how many, and the pager.
136
148
  */
137
149
  export function TableFooter({ state, disabled, onPage }) {
138
150
  const { status, rows, offset, matches, total, query } = state;
@@ -141,5 +153,5 @@ export function TableFooter({ state, disabled, onPage }) {
141
153
  const first = rows.length ? offset + 1 : 0;
142
154
  const last = offset + rows.length;
143
155
  const pages = pageCount(matches, query.pageSize);
144
- return (_jsxs("div", { className: "nx-table__footer", children: [_jsxs("p", { className: "nx-table__range", "aria-live": "polite", children: [rows.length ? (_jsxs(_Fragment, { children: ["Showing ", _jsx("b", { children: `${formatCount(first)}–${formatCount(last)}` }), " of ", _jsx("b", { children: formatCount(matches) })] })) : (_jsx(_Fragment, { children: "No rows on this page" })), query.search && matches !== total && _jsxs("span", { className: "nx-table__range-all", children: [" \u00B7 filtered from ", formatCount(total)] })] }), _jsxs("div", { className: "nx-table__paging", children: [_jsxs("span", { className: "nx-table__page-info", children: ["Page ", formatCount(query.page), " of ", formatCount(pages)] }), _jsx(TablePager, { page: query.page, matches: matches, pageSize: query.pageSize, disabled: disabled, onPage: onPage })] })] }));
156
+ return (_jsxs("div", { className: "nx-table__footer", children: [_jsxs("p", { className: "nx-table__range", "aria-live": "polite", children: [rows.length ? (_jsxs(_Fragment, { children: ["Showing ", _jsx("b", { children: `${formatCount(first)}–${formatCount(last)}` }), " of ", _jsx("b", { children: formatCount(matches) })] })) : (_jsx(_Fragment, { children: "No rows on this page" })), (query.search || query.filters) && matches !== total && _jsxs("span", { className: "nx-table__range-all", children: [" \u00B7 filtered from ", formatCount(total)] })] }), _jsxs("div", { className: "nx-table__paging", children: [_jsxs("span", { className: "nx-table__page-info", children: ["Page ", formatCount(query.page), " of ", formatCount(pages)] }), _jsx(TablePager, { page: query.page, matches: matches, pageSize: query.pageSize, disabled: disabled, onPage: onPage })] })] }));
145
157
  }
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Whether things may move, as the stylesheets read it: `data-nx-motion` on `<html>`, which `ThemeScript` works out
3
+ * from the visitor's Animations choice and the system setting. Motion started from script (a flight, a smooth scroll)
4
+ * asks this, never `prefers-reduced-motion`, so "on" animates on a machine that reports reduced motion.
5
+ */
6
+ export declare function motionAllowed(): boolean;
7
+ /**
8
+ * Whether a scroll may be smooth: the visitor allows motion, and so does the system, because a browser whose system
9
+ * turned animations off scrolls at once whatever it is asked, and a smooth scroll that is skipped must not be waited on.
10
+ */
11
+ export declare function smoothScrollAllowed(): boolean;
1
12
  /**
2
13
  * Calls `done` once the element's CSS animation `name` has finished, or at once when the element runs no animation of
3
14
  * that name. Call it after the class that starts the animation is on the element, e.g. in a layout effect.
@@ -1,8 +1,26 @@
1
1
  /*
2
2
  * Exit animations. A closing element gets a class that starts a CSS animation, and the next step (closing a dialog,
3
- * removing a toast) waits for it. When no animation runs, the step must not wait: with reduced motion (which Windows
4
- * reports when its animations are turned off) or when a theme sets `animation: none`.
3
+ * removing a toast) waits for it. When no animation runs, the step must not wait: with animations off (the visitor's
4
+ * choice, or the system's under "system" - Windows reports reduced motion when its animations are turned off) or when
5
+ * a theme sets `animation: none`.
5
6
  */
7
+ import { MOTION_ATTRIBUTE } from "../Interfaces/ThemeInterfaces.js";
8
+ import { REDUCED_MOTION_QUERY } from "./ThemeHelpers.js";
9
+ /**
10
+ * Whether things may move, as the stylesheets read it: `data-nx-motion` on `<html>`, which `ThemeScript` works out
11
+ * from the visitor's Animations choice and the system setting. Motion started from script (a flight, a smooth scroll)
12
+ * asks this, never `prefers-reduced-motion`, so "on" animates on a machine that reports reduced motion.
13
+ */
14
+ export function motionAllowed() {
15
+ return typeof document === "undefined" || document.documentElement.getAttribute(MOTION_ATTRIBUTE) !== "reduce";
16
+ }
17
+ /**
18
+ * Whether a scroll may be smooth: the visitor allows motion, and so does the system, because a browser whose system
19
+ * turned animations off scrolls at once whatever it is asked, and a smooth scroll that is skipped must not be waited on.
20
+ */
21
+ export function smoothScrollAllowed() {
22
+ return motionAllowed() && !(typeof window !== "undefined" && window.matchMedia?.(REDUCED_MOTION_QUERY).matches);
23
+ }
6
24
  /**
7
25
  * Calls `done` once the element's CSS animation `name` has finished, or at once when the element runs no animation of
8
26
  * that name. Call it after the class that starts the animation is on the element, e.g. in a layout effect.
@@ -12,6 +12,22 @@ export interface ApiCall<TOptions = object> {
12
12
  /** What the call does and acts on, for its messages. */
13
13
  context: MessageContext;
14
14
  }
15
+ /**
16
+ * Reads a server's answer as an `ApiResult`. A client's `read` is the app's default for every call that does not bring
17
+ * its own (`ApiCallOptions.read`); `call` says what was asked for, and is left out for an answer that came from
18
+ * somewhere other than a call (an app's own send function).
19
+ *
20
+ * @example
21
+ * // A backend that answers { success, message, data, error: { code, message, fields } }
22
+ * configureApi({
23
+ * read: (body, status) => {
24
+ * const answer = body as { success?: boolean; message?: string; data?: unknown; error?: { code: string; fields?: Record<string, string[]> } };
25
+ * if (answer?.success) return { ok: true, status, result: answer.data, message: answer.message };
26
+ * return { ok: false, status, message: answer?.message, messageCode: answer?.error?.code, errors: … };
27
+ * },
28
+ * });
29
+ */
30
+ export type ApiResponseReader<TOptions = object> = (body: unknown, status: number | undefined, call?: ApiCall<TOptions>) => ApiResult;
15
31
  export interface ApiClientConfig<TOptions = object> {
16
32
  /** The full URL of an endpoint, given its filled path: through the proxy in the browser, the module's root on a server. */
17
33
  resolveUrl: (endpoint: ApiEndpoint, path: string, call: ApiCall<TOptions>) => string | Promise<string>;
@@ -24,6 +40,12 @@ export interface ApiClientConfig<TOptions = object> {
24
40
  /** Milliseconds to wait for an answer. Default 60,000. */
25
41
  timeout?: number;
26
42
  credentials?: RequestCredentials;
43
+ /**
44
+ * Reads every answer the call itself did not bring a reader for. Default `readApiResponse`: Nexus API responses,
45
+ * ASP.NET Core problem details, and any JSON by its status. An app on another backend registers its own once, so
46
+ * components that call inside themselves (`Crud`, `ApiForm`) read that backend's envelope too.
47
+ */
48
+ read?: ApiResponseReader<TOptions>;
27
49
  /** Changes the request just before it is sent, such as Next.js caching on a server. */
28
50
  prepare?: (init: RequestInit, call: ApiCall<TOptions>) => RequestInit;
29
51
  /**
@@ -99,7 +99,10 @@ export function createApiClient(config) {
99
99
  controller.abort();
100
100
  }, timeout)
101
101
  : undefined;
102
- const read = options.read ?? ((answer, status) => readApiResponse(answer, status, call.context));
102
+ // The call's own reader, else the app's (`configureApi({ read })`), else the built-in one. The app's is looked up
103
+ // on every call, so registering it after a module was imported still takes effect.
104
+ const appRead = config.read;
105
+ const read = options.read ?? (appRead ? (answer, status) => appRead(answer, status, call) : (answer, status) => readApiResponse(answer, status, call.context));
103
106
  const stopped = () => caller?.aborted ? aborted() : timedOut ? { ok: false, timeout: true, message: buildMessage("reason", { ...call.context, timeout: true }) } : undefined;
104
107
  let init = { method, headers, body, credentials: options.credentials ?? config.credentials, signal: controller.signal };
105
108
  if (config.prepare)
@@ -4,7 +4,10 @@ export interface SendApiOptions {
4
4
  signal?: AbortSignal;
5
5
  read?: (body: unknown, status: number | undefined) => ApiResult;
6
6
  }
7
- /** The call options of a form's `{ url, method, headers, bodyType, credentials }` request with its values. */
7
+ /**
8
+ * The call options of a form's `{ url, method, headers, bodyType, credentials }` request with its values. Without a
9
+ * `read` the client's own reader is used: the app's (`configureApi({ read })`), else the built-in one.
10
+ */
8
11
  export declare function apiRequestOptions(request: ApiRequest, body: unknown, { signal, read }?: SendApiOptions): ApiCallOptions;
9
12
  /** An abort as the error `fetch` throws, for code that expects a stopped request to reject. */
10
13
  export declare function abortError(): Error;
@@ -16,11 +19,31 @@ export declare function sendApiRequest(request: ApiRequest, body: unknown, optio
16
19
  /** What an `ApiSend` function answered, read the same way: a `Response`, an `ApiResult`, or a body. */
17
20
  export declare function toApiResult(answer: unknown, read?: (body: unknown, status: number | undefined) => ApiResult): Promise<ApiResult>;
18
21
  /**
19
- * Puts a server's messages on the inputs they name. Names match without case, and a path matches its first part that is
20
- * an input ("Addresses[1].City" and "$.addresses" go to "addresses"; "dto.Email" to "email"). Messages that match no
21
- * input are returned in `others`, for the form's message.
22
+ * The input a server's field name belongs to, or `undefined` when the form has no such input. Names match without case,
23
+ * and a path matches its first part that is an input ("Addresses[1].City" and "$.addresses" go to "addresses";
24
+ * "dto.Email" to "email"). The one rule every server message is matched by.
25
+ */
26
+ export declare function matchInputName(name: string, inputNames: readonly string[]): string | undefined;
27
+ /**
28
+ * Puts a server's messages on the inputs they name, by `matchInputName`. Messages that match no input are returned in
29
+ * `others`, for the form's message.
22
30
  */
23
31
  export declare function matchFieldErrors(errors: Record<string, string> | undefined, inputNames: readonly string[]): {
24
32
  fields: FormErrors;
25
33
  others: string[];
26
34
  };
35
+ /**
36
+ * A failed answer as what a form shows, through the one path a 400's field errors already take: every message the server
37
+ * put on an input (`inputName`, several named with "|") goes to that input, every other message - including one naming an
38
+ * input this form does not have - goes under the form, and nothing is shown twice. `message` is `null` when the inputs
39
+ * carry it all. An answer that explains nothing falls back to a message for its HTTP status; that is the only time our
40
+ * own wording is shown.
41
+ *
42
+ * @example
43
+ * const { errors, message } = formErrorsFromResult(answer, store.inputs.map(params => params.name));
44
+ * store.setErrors(errors, message);
45
+ */
46
+ export declare function formErrorsFromResult(answer: ApiResult, inputNames: readonly string[]): {
47
+ errors: FormErrors;
48
+ message: string | null;
49
+ };