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,21 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Icon } from "nexus-icons";
4
+ import { useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
7
+ /**
8
+ * A person: a round picture, or their initials on a soft tone when there is none or it fails to load. The chat,
9
+ * the dropdown's avatar column and the admin bar all draw this one.
10
+ */
11
+ export function Avatar({ name, src, size = "md", className, onChangePicture, changeLabel = "Change picture", role, tabIndex }) {
12
+ const url = typeof src === "string" ? src.trim() : "";
13
+ const [failed, setFailed] = useState(null);
14
+ const photo = url !== "" && failed !== url;
15
+ const face = photo ? _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setFailed(url) }) : initials(name);
16
+ const shared = { className: cx("nx-avatar", size !== "md" && `nx-avatar--${size}`, className), "data-tone": photo ? undefined : avatarTone(name) };
17
+ if (!onChangePicture) {
18
+ return (_jsx("span", { ...shared, "aria-hidden": "true", children: face }));
19
+ }
20
+ return (_jsxs("button", { ...shared, type: "button", role: role, tabIndex: tabIndex, "aria-label": changeLabel, title: changeLabel, onClick: onChangePicture, children: [face, _jsx("span", { className: "nx-avatar__veil", "aria-hidden": "true", children: _jsx(Icon, { name: "camera", size: "40%" }) })] }));
21
+ }
@@ -1,18 +1,25 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconAlertCircle, iconArrowAutofitWidth, iconArrowBarToLeft, iconArrowBarToRight, iconArrowDown, iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowsSort, iconChevronDown, iconColumns3, iconDatabase, iconDotsVertical, iconEyeOff, iconFileExport, iconFileImport, iconPinnedOff, iconPrinter, iconRefresh, iconRestore, iconSearch, iconSearchOff, iconSortAscending, iconSortDescending, iconX, InlineIcon, } from "nexus-icons";
3
+ import { iconAlertCircle, iconArrowAutofitWidth, iconArrowBarToLeft, iconArrowBarToRight, iconArrowDown, iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowsSort, iconChecks, iconChevronDown, iconColumns3, iconDatabase, iconDotsVertical, iconEyeOff, iconDownload, iconFileExport, iconFileImport, iconFilter, iconFilterOff, iconPinnedOff, iconPlus, iconPrinter, iconRefresh, iconRestore, iconRowInsertBottom, iconSearch, iconSearchOff, iconSortAscending, iconSortDescending, iconX, InlineIcon, } from "nexus-icons";
4
4
  import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
+ import { flushSync } from "react-dom";
5
6
  import { cx } from "../../Helpers/ClassHelpers.js";
6
7
  import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
7
- import { fitColumnWidth, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
8
+ import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
8
9
  import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
9
- import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, MIN_COLUMN_WIDTH, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
10
+ import { asFilterInput, filterNames, filterRequest } from "../../Helpers/TableFilters.js";
11
+ import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, MIN_COLUMN_WIDTH, orderColumns, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
10
12
  import { TableStore } from "../../Helpers/TableStore.js";
13
+ import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
11
14
  import { Button } from "../Documents/Button.js";
12
15
  import { Menu } from "../Documents/Menu.js";
13
16
  import { SplitButton } from "../Documents/SplitButton.js";
14
17
  import { TextBox } from "../Inputs/TextBox.js";
15
18
  import { toast } from "../Layouts/Toaster.js";
19
+ import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
20
+ import { openExcelImport } from "../Forms/ExcelImport.js";
21
+ import { TableColumnsPanel } from "./DataTableColumns.js";
22
+ import { TableFiltersPanel } from "./DataTableFilters.js";
16
23
  import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
17
24
  /*
18
25
  * DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
@@ -86,26 +93,38 @@ const sameNumbers = (a, b) => {
86
93
  * />
87
94
  */
88
95
  export function DataTable(props) {
89
- const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, rowActions, onRowOpen, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, pageSize = DEFAULT_PAGE_SIZE, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, size = "md", id, className, style, ref, } = props;
96
+ const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize: ownPageSize, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
90
97
  const paging = givenRows ? "client" : (props.paging ?? "client");
91
98
  const reloadable = props.reloadable ?? Boolean(load);
92
99
  const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
93
100
  // A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer.
94
101
  const numbered = props.numbered ?? selectable;
95
- const [own] = useState(() => (givenTable ? null : new TableStore(pageSize)));
102
+ // The person's choices in the quick preference panel, once made, go over what the table names itself.
103
+ const preferences = useUserPreferences();
104
+ const openOnClick = preferences.rowClick ?? ownOpenOnClick ?? false;
105
+ /** The page size the table opens with: the person's default when this table offers it, else the table's own. */
106
+ const openingPageSize = () => {
107
+ const preferred = readUserPreferences().pageSize;
108
+ return preferred !== null && pageSizes.includes(preferred) ? preferred : (ownPageSize ?? DEFAULT_PAGE_SIZE);
109
+ };
110
+ const [own] = useState(() => (givenTable ? null : new TableStore(ownPageSize ?? DEFAULT_PAGE_SIZE)));
96
111
  const table = givenTable ?? own;
97
- table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange });
112
+ // Prepared once for the store and the panel: the same params without what would make them filter on their own.
113
+ const filterInputs = useMemo(() => props.filterInputs?.map(asFilterInput), [props.filterInputs]);
114
+ table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, filterInputs });
98
115
  useImperativeHandle(ref, () => table, [table]);
99
116
  const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
100
- const preferences = useDatePreferences();
117
+ const dates = useDatePreferences();
101
118
  const timeZone = useBrowserTimeZone();
102
- const cellOptions = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined }), [preferences, timeZone]);
119
+ const numbers = useNumberLocale();
120
+ const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers]);
103
121
  const tableId = useId();
104
122
  // The search box is uncontrolled: a new key clears it when a new list starts over.
105
123
  const [searchGeneration, setSearchGeneration] = useState(0);
106
124
  // The pinned section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
107
125
  const [pinsOpen, setPinsOpen] = useState(false);
108
126
  const searchTimer = useRef(undefined);
127
+ const searchRef = useRef(null);
109
128
  const started = useRef(false);
110
129
  const loadedKey = useRef(loadKey);
111
130
  // First rows: saved settings first, then the rows given or the first load. A layout effect, so rows that are at hand
@@ -115,7 +134,7 @@ export function DataTable(props) {
115
134
  started.current = true;
116
135
  const saved = readSaved(stateKey);
117
136
  table.restore({
118
- pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : pageSize,
137
+ pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
119
138
  sort: saved?.sort !== undefined ? saved.sort : defaultSort,
120
139
  hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
121
140
  order: saved?.order,
@@ -156,7 +175,8 @@ export function DataTable(props) {
156
175
  if (!stateKey || !started.current)
157
176
  return;
158
177
  writeSaved(stateKey, {
159
- pageSize: state.query.pageSize,
178
+ // Only a size the person picked here: one equal to the default is not kept, so a new default still reaches it.
179
+ pageSize: state.query.pageSize === openingPageSize() ? undefined : state.query.pageSize,
160
180
  sort: state.query.sort,
161
181
  hidden: hiddenList,
162
182
  order: state.order.length ? [...state.order] : undefined,
@@ -164,8 +184,23 @@ export function DataTable(props) {
164
184
  pinned: Object.keys(state.pinned).length ? state.pinned : undefined,
165
185
  });
166
186
  }, [stateKey, state.query.pageSize, state.query.sort, hiddenList, state.order, state.widths, state.pinned]);
187
+ // A new default page size from the panel reaches an open table where no size was picked. Only a change counts: on the
188
+ // first effects the save above still holds the store's opening size and has not written the restored one yet, so a
189
+ // check then would find nothing saved and undo a size picked earlier. The size is read from storage, not from the
190
+ // hook's value, which is the server's default while the page hydrates.
191
+ const preferredSize = useRef(preferences.pageSize);
192
+ useEffect(() => {
193
+ if (preferredSize.current === preferences.pageSize)
194
+ return;
195
+ preferredSize.current = preferences.pageSize;
196
+ if (!started.current || readSaved(stateKey)?.pageSize)
197
+ return;
198
+ table.setPageSize(openingPageSize());
199
+ // eslint-disable-next-line react-hooks/exhaustive-deps
200
+ }, [preferences.pageSize]);
167
201
  const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
168
- const hideable = columns.filter(column => column.hideable !== false);
202
+ // Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
203
+ const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
169
204
  const labelOf = useCallback((row) => {
170
205
  if (rowLabel)
171
206
  return rowLabel(row);
@@ -220,6 +255,11 @@ export function DataTable(props) {
220
255
  const sized = Object.keys(state.widths).length > 0;
221
256
  const views = useMemo(() => viewColumns(visible, { order: state.order, pinned: state.pinned, widths: state.widths, offsets, reorderable, resizable }), [visible, state.order, state.pinned, state.widths, offsets, reorderable, resizable]);
222
257
  const indexOn = selectable || numbered;
258
+ // The bands above the headings. With no group anywhere the header is the one row it always was.
259
+ const bands = useMemo(() => headBands(views), [views]);
260
+ const grouped = bands.some(band => band.group !== null);
261
+ /** How many rows the header has: a heading with no band above it is as tall as both. */
262
+ const headRows = grouped ? 2 : undefined;
223
263
  // The shadow while columns scroll under a pinned side belongs to its last column, which may be one of the table's own.
224
264
  const pinnedLeft = views.some(view => view.pin === "left");
225
265
  const pinnedRight = views.some(view => view.pin === "right");
@@ -232,13 +272,13 @@ export function DataTable(props) {
232
272
  // How far the pinned cells sit from their edge, measured from the headings before them. It runs after every render
233
273
  // (before the browser paints) and settles at once, since nothing changes when the numbers are the same.
234
274
  useLayoutEffect(() => {
235
- const row = headRef.current;
275
+ const cells = headLeafCells(headRef.current);
236
276
  const root = rootRef.current;
237
- if (!row)
277
+ if (cells.length === 0)
238
278
  return;
239
279
  const next = {};
240
280
  let left = 0;
241
- for (const cell of row.children) {
281
+ for (const cell of cells) {
242
282
  if (cell.getAttribute("data-pin") !== "left")
243
283
  break;
244
284
  const key = cell.getAttribute("data-col");
@@ -247,8 +287,8 @@ export function DataTable(props) {
247
287
  left += cell.getBoundingClientRect().width;
248
288
  }
249
289
  let right = 0;
250
- for (let index = row.children.length - 1; index >= 0; index--) {
251
- const cell = row.children[index];
290
+ for (let index = cells.length - 1; index >= 0; index--) {
291
+ const cell = cells[index];
252
292
  if (cell.getAttribute("data-pin") !== "right")
253
293
  break;
254
294
  const key = cell.getAttribute("data-col");
@@ -289,9 +329,8 @@ export function DataTable(props) {
289
329
  });
290
330
  /* Resizing a column: the table freezes the widths it has, then only the dragged edge moves */
291
331
  const measureWidths = useCallback(() => {
292
- const row = headRef.current;
293
332
  const widths = {};
294
- for (const cell of row?.children ?? []) {
333
+ for (const cell of headLeafCells(headRef.current)) {
295
334
  const key = cell.getAttribute("data-col");
296
335
  if (key)
297
336
  widths[key] = Math.round(cell.getBoundingClientRect().width);
@@ -352,29 +391,128 @@ export function DataTable(props) {
352
391
  });
353
392
  };
354
393
  /* The bar above the table, and the options menu */
394
+ // The labels of the filters that are on: the menu's second line, the dot's title, and an export's note.
395
+ const filtersOn = filterNames(filterInputs ?? [], query.filters);
355
396
  const outputName = exportName ?? props["aria-label"] ?? "Table";
356
397
  const output = () => {
357
398
  const shown = views.map(view => view.column);
358
399
  const rowsOut = table.getViewRows();
359
- const note = [`${formatCount(rowsOut.length)} ${rowsOut.length === 1 ? "row" : "rows"}`, query.search && `search: ${query.search}`].filter(Boolean).join(" · ");
400
+ const note = [`${formatCount(rowsOut.length)} ${rowsOut.length === 1 ? "row" : "rows"}`, query.search && `search: ${query.search}`, filtersOn.length && `filters: ${filtersOn.join(", ")}`]
401
+ .filter(Boolean)
402
+ .join(" · ");
360
403
  return { columns: shown, rows: rowsOut, cellOptions, name: outputName, note };
361
404
  };
362
405
  const serverPage = paging === "server";
406
+ /** What an export to Excel asks the server for: the search, the filters and the sort on screen, and the page's own filters. */
407
+ const exportQuery = () => {
408
+ const column = query.sort ? columns.find(candidate => candidate.key === query.sort.key) : undefined;
409
+ return {
410
+ params: excel?.params,
411
+ subject: excel?.subject,
412
+ rows: state.matches,
413
+ query: {
414
+ search: query.search || null,
415
+ sort: column ? (column.sortKey ?? column.key) : null,
416
+ direction: query.sort?.direction,
417
+ filters: { ...filterRequest(query.filters, filterInputs ?? [], columns), ...excel?.filters?.() },
418
+ },
419
+ };
420
+ };
421
+ /**
422
+ * After an import: the rows it wrote are the server's, so the table reads its page again - after the page has heard
423
+ * of it. A `Crud` drops the lists it keeps in `onImported`, and a reload asked first was answered from that cache: on a
424
+ * page just opened, the list stayed as it was until the person reloaded it themselves (the owner, 2026-10-07).
425
+ */
426
+ const onImported = () => {
427
+ excel?.onImported?.();
428
+ void table.reload({ quiet: true });
429
+ };
430
+ // The Filters and Columns panels open from the options menu, under its button.
431
+ const optionsRef = useRef(null);
432
+ const filtersRef = useRef(null);
433
+ const columnsRef = useRef(null);
434
+ const hasColumnsPanel = columnsMenu && columns.length > 1;
435
+ // The stylesheet takes the bar's steps by the table's width. A bar wider than those steps expect (a long Add label,
436
+ // the page's own buttons, cozy density) is squeezed into the next step as soon as a line would break, a frame after
437
+ // its size changed, so the ResizeObserver never loops. Reload waits in the options menu whenever its button is hidden:
438
+ // the menu asks the button, so what decides it is written once, in the stylesheet.
439
+ const barRef = useRef(null);
440
+ const reloadRef = useRef(null);
441
+ const [reloadHidden, setReloadHidden] = useState(false);
442
+ const addPart = Boolean(add || actions);
443
+ const listsPart = Boolean(toolbar);
444
+ useLayoutEffect(() => {
445
+ const root = rootRef.current;
446
+ const bar = barRef.current;
447
+ if (!root || !bar)
448
+ return;
449
+ let frame = 0;
450
+ const fit = () => {
451
+ squeezeBar(bar);
452
+ setReloadHidden(reloadRef.current ? getComputedStyle(reloadRef.current).display === "none" : false);
453
+ };
454
+ const observer = new ResizeObserver(() => {
455
+ cancelAnimationFrame(frame);
456
+ frame = requestAnimationFrame(fit);
457
+ });
458
+ observer.observe(root);
459
+ for (const part of Array.from(bar.children))
460
+ observer.observe(part);
461
+ return () => {
462
+ observer.disconnect();
463
+ cancelAnimationFrame(frame);
464
+ };
465
+ }, [addPart, listsPart, searchable, reloadable]);
466
+ const reload = () => {
467
+ onReload?.();
468
+ void table.reload({ quiet: true });
469
+ };
470
+ // A spreadsheet: the template to fill, the file to bring back, and an export the server writes from what is on
471
+ // screen. With an Add, they are other ways to add, under its arrow; without one, they are in the options menu.
472
+ const sheetItems = excel
473
+ ? [
474
+ { id: "excel-template", label: "Download template", description: `A spreadsheet to fill in`, icon: _jsx(InlineIcon, { icon: iconDownload }), separator: true, onSelect: () => void downloadExcelTemplate(excel.endpoints, { params: excel.params }) },
475
+ { id: "excel-import", label: "Import from a spreadsheet", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: () => openExcelImport({ endpoints: excel.endpoints, subject: excel.subject, params: excel.params, onImported: onImported }) },
476
+ // A bulk insert is an Add of many rows, so it is offered only beside an Add: a CRUD shows its Add only to a person
477
+ // who may create, and nobody types fifty rows to be refused at the end.
478
+ ...(excel.bulk === false || !add
479
+ ? []
480
+ : [{ id: "excel-bulk", label: "Bulk insert", description: "Type the rows here, with no file", icon: _jsx(InlineIcon, { icon: iconRowInsertBottom }), onSelect: () => openExcelImport({ endpoints: excel.endpoints, subject: excel.subject, params: excel.params, onImported: onImported, mode: "bulk" }) }]),
481
+ ...(excel.endpoints.export
482
+ ? [{ id: "excel-export", label: "Export to Excel", description: "Everything the search and filters leave, from the server", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => void exportExcel(excel.endpoints, exportQuery()) }]
483
+ : []),
484
+ ]
485
+ : [];
486
+ // In the bar it is `block`: as wide as its own label there, and the whole line once the Add section takes one.
487
+ const addButton = (block = false) => add && (_jsx(Button, { variant: "primary", size: "sm", block: block, icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: add.onClick, children: add.label }));
363
488
  const optionItems = [
489
+ ...(filterInputs?.length
490
+ ? [{ id: "filters", label: "Filters", description: filtersOn.join(", ") || undefined, icon: _jsx(InlineIcon, { icon: iconFilter }), onSelect: () => filtersRef.current?.open() }]
491
+ : []),
492
+ ...(filtersOn.length ? [{ id: "clear-filters", label: "Clear filters", icon: _jsx(InlineIcon, { icon: iconFilterOff }), onSelect: () => table.setFilters({}) }] : []),
493
+ ...(hasColumnsPanel ? [{ id: "columns", label: "Columns", icon: _jsx(InlineIcon, { icon: iconColumns3 }), onSelect: () => columnsRef.current?.open() }] : []),
364
494
  ...(exportable && status === "ready" && rows.length > 0
365
495
  ? [{ id: "export", label: serverPage ? "Export this page (CSV)" : "Export as CSV", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => exportTableCsv(output()) }]
366
496
  : []),
367
497
  ...(printable && status === "ready" && rows.length > 0
368
498
  ? [{ id: "print", label: serverPage ? "Print this page" : "Print", icon: _jsx(InlineIcon, { icon: iconPrinter }), onSelect: () => printTable(output()) }]
369
499
  : []),
370
- ...(onImport ? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }] : []),
500
+ // The sheet's own Import replaces the page's `onImport` hook.
501
+ ...(excel
502
+ ? add
503
+ ? []
504
+ : sheetItems
505
+ : onImport
506
+ ? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }]
507
+ : []),
371
508
  ...(moreActions ?? []),
372
- ...(table.hasColumnChanges() ? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }] : []),
509
+ // Reset belongs in the Columns panel, beside what it resets; it is offered here only for a table without one.
510
+ ...(!columnsMenu && table.hasColumnChanges()
511
+ ? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }]
512
+ : []),
513
+ ...(reloadable && reloadHidden ? [{ id: "reload", label: "Reload", icon: _jsx(InlineIcon, { icon: iconRefresh }), separator: true, onSelect: reload }] : []),
373
514
  ];
374
- const optionEntries = optionItems.flatMap((action, index) => [
375
- ...(action.separator && index > 0 ? [{ separator: true }] : []),
376
- { value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled },
377
- ]);
515
+ const optionEntries = menuEntries(optionItems);
378
516
  const pageSizePicker = _jsx(TablePageSize, { pageSize: query.pageSize, pageSizes: pageSizes, compact: searchable, onChange: value => table.setPageSize(value) });
379
517
  let body;
380
518
  if (status === "loading") {
@@ -383,21 +521,30 @@ export function DataTable(props) {
383
521
  else if (status === "error") {
384
522
  body = (_jsx(TableMessageRow, { span: span, tone: "danger", icon: iconAlertCircle, title: "The rows could not be loaded.", detail: state.error, children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => void table.reload({ quiet: false }), children: "Retry" }) }));
385
523
  }
386
- else if (rows.length === 0 && query.search) {
387
- body = (_jsx(TableMessageRow, { span: span, icon: iconSearchOff, title: `Nothing matches “${query.search}”.`, detail: "Check the spelling, or search for something shorter.", children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconX }), onClick: () => {
388
- clearTimeout(searchTimer.current);
389
- setSearchGeneration(count => count + 1);
390
- table.setSearch("");
391
- }, children: "Clear search" }) }));
524
+ else if (rows.length === 0 && (query.search || query.filters)) {
525
+ // What left no rows, the search, the filters or both, and a way to clear each.
526
+ const title = !query.filters ? `Nothing matches “${query.search}”.` : query.search ? `Nothing matches “${query.search}” with these filters.` : "No rows match the filters.";
527
+ const detail = !query.filters ? "Check the spelling, or search for something shorter." : query.search ? undefined : "Change them, or clear them.";
528
+ body = (_jsxs(TableMessageRow, { span: span, icon: query.search ? iconSearchOff : iconFilterOff, title: title, detail: detail, children: [query.search && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconX }), onClick: () => {
529
+ clearTimeout(searchTimer.current);
530
+ flushSync(() => {
531
+ setSearchGeneration(count => count + 1);
532
+ table.setSearch("");
533
+ });
534
+ searchRef.current?.focus();
535
+ }, children: "Clear search" })), query.filters && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconFilterOff }), onClick: () => {
536
+ optionsRef.current?.focus();
537
+ table.setFilters({});
538
+ }, children: "Clear filters" }))] }));
392
539
  }
393
540
  else if (rows.length === 0) {
394
- body = (_jsx(TableMessageRow, { span: span, icon: iconDatabase, title: emptyMessage, children: emptyAction }));
541
+ body = (_jsx(TableMessageRow, { span: span, icon: iconDatabase, title: emptyMessage, children: emptyAction === undefined ? addButton() : emptyAction }));
395
542
  }
396
543
  else {
397
544
  body = rows.map((row, index) => {
398
545
  const key = pageKeys[index];
399
546
  const selected = state.selected.has(key);
400
- return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen }, key || index));
547
+ return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
401
548
  });
402
549
  }
403
550
  /* The pinned section above the page: the records the user pinned, folded open or shut, remembered on this device */
@@ -413,30 +560,37 @@ export function DataTable(props) {
413
560
  pins?.map((row, index) => {
414
561
  const key = table.keyOf(row);
415
562
  const selected = state.selected.has(key);
416
- return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, pinned: true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen }, `pin:${key || index}`));
563
+ return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, pinned: true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, `pin:${key || index}`));
417
564
  });
418
565
  const selectedRows = selecting ? [...state.selected.values()] : [];
566
+ // Select again by what the rows are: exactly the rows on the page that pass, or the whole page.
567
+ const pick = (label, test) => {
568
+ const matching = test ? rows.filter(test) : rows;
569
+ if (matching.length === 0)
570
+ return toast(`No row on this page is ${label.toLowerCase()}.`);
571
+ table.clearSelection();
572
+ table.select(matching, true);
573
+ };
574
+ const picks = selectBy?.length
575
+ ? [
576
+ { id: "page", label: "All on this page", icon: _jsx(InlineIcon, { icon: iconChecks }), onSelect: () => pick("on this page") },
577
+ ...selectBy.map((rule, index) => ({ id: `by-${index}`, label: rule.label, icon: rule.icon, separator: index === 0, onSelect: () => pick(rule.label, rule.test) })),
578
+ ]
579
+ : [];
419
580
  const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
420
- const columnItems = hideable.map(column => ({ value: column.key, label: column.label, checked: !state.hidden.has(column.key) }));
421
- return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-table", `nx-table--${size}`, className), style: style, "data-status": status, "data-refreshing": refreshing || undefined, "data-selecting": selecting || undefined, "data-sized": sized || undefined, children: [_jsxs("div", { className: "nx-table__bar", children: [toolbar && _jsx("div", { className: "nx-table__bar-start", children: toolbar }), _jsxs("div", { className: "nx-table__bar-end", children: [searchable ? (_jsx(TextBox, { type: "search", size: "sm", className: "nx-table__search", actionsStart: pageSizePicker, icon: _jsx(InlineIcon, { icon: iconSearch }), placeholder: searchPlaceholder, "aria-label": searchPlaceholder.replace(/…$/, ""), "aria-controls": tableId, defaultValue: query.search, clearable: true, onValueChange: onSearch, onKeyDown: event => {
422
- if (event.key === "Enter") {
423
- clearTimeout(searchTimer.current);
424
- table.setSearch(event.currentTarget.value);
425
- }
426
- } }, searchGeneration)) : (pageSizePicker), columnsMenu && hideable.length > 1 && (_jsx(Menu, { items: columnItems, kind: "checkbox", size: "sm", align: "end", closeOnSelect: false, "aria-label": "Columns", onSelect: value => {
427
- const hide = !state.hidden.has(value);
428
- // At least one column stays.
429
- if (hide && visible.length <= 1)
430
- return;
431
- table.setHidden(value, hide);
432
- }, children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconColumns3 }), "aria-label": "Columns", title: "Show or hide columns" }) })), reloadable && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh, className: refreshing ? "nx-spin" : undefined }), "aria-label": "Reload", title: "Reload", disabled: status === "loading", onClick: () => {
433
- onReload?.();
434
- void table.reload({ quiet: true });
435
- } })), optionEntries.length > 0 && (_jsx(Menu, { items: optionEntries, size: "sm", align: "end", "aria-label": "More options", onSelect: value => optionItems.find(action => action.id === value)?.onSelect(), children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), "aria-label": "More options", title: "More options", "data-table-options": true }) })), actions] })] }), _jsxs("div", { className: "nx-table__frame", children: [selectedRows.length > 0 && (_jsx(SelectionBar, { count: selectedRows.length, actions: mass, rows: selectedRows, onClear: () => table.clearSelection(), controls: tableId })), _jsx("div", { className: "nx-table__progress", "aria-hidden": "true" }), _jsx("div", { ref: scrollRef, className: "nx-table__scroll", style: { maxHeight: cssLength(maxHeight) }, children: _jsxs("table", { ref: tableRef, id: tableId, className: "nx-table__table", "aria-label": props["aria-label"], "aria-labelledby": props["aria-labelledby"], "aria-busy": status === "loading" || refreshing || undefined, children: [sized && _jsx(TableColumnGroup, { columns: views, widths: state.widths, frame: frame, colRefs: colRefs }), _jsx("thead", { children: _jsxs("tr", { ref: headRef, className: "nx-table__head", children: [frame.index && (_jsx(TableIndexCell, { heading: true, own: frame.index, number: "#", numbered: numbered, selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", children: _jsx("input", { ref: headerCheck, type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: allOnPage, disabled: rows.length === 0 || status !== "ready", "aria-label": "Select every row on this page", onChange: event => {
436
- lastToggled.current = null;
437
- if (table.selectPage(event.currentTarget.checked) > 0)
438
- warnFull();
439
- } }) })) })), views.map(view => (_jsx(HeaderCell, { view: view, sized: sized, sort: query.sort, disabled: status !== "ready" || rows.length < 2, covered: selectedRows.length > 0, canPin: canPinColumn(view.column, { pinnable }), canHide: view.column.hideable !== false && visible.length > 1, neighbours: moveTargets(views, view.column.key), onSort: direction => table.setSort(direction ? { key: view.column.key, direction } : null), onPin: side => table.setPinned(view.column.key, side), onMove: beforeKey => table.moveColumn(view.column.key, beforeKey), onFit: () => fitColumn(view.column.key), onHide: () => table.setHidden(view.column.key, true), onPointerDown: event => onColumnPointerDown(event, view), onResizerDown: event => onResizerPointerDown(event, view.column.key) }, view.column.key))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler" }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }) }), pins && (_jsxs("tbody", { className: "nx-table__pins", "data-open": pinsOpen || undefined, children: [_jsx(TablePinnedHead, { span: span, label: pinnedLabel, count: pins.length, max: pinnedMax, loading: pinnedLoading, open: pinsOpen, onToggle: togglePins }), pinnedBody] })), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
581
+ // A column's own heading, wherever it stands: across both rows when no band is above it, in the row under its band when one is.
582
+ const headingCell = (view, rowSpan) => (_jsx(HeaderCell, { view: view, sized: sized, rowSpan: rowSpan, sort: query.sort, disabled: status !== "ready" || rows.length < 2, covered: selectedRows.length > 0, canPin: canPinColumn(view.column, { pinnable }), canHide: view.column.hideable !== false && visible.length > 1, neighbours: moveTargets(views, view.column.key), onSort: direction => table.setSort(direction ? { key: view.column.key, direction } : null), onPin: side => table.setPinned(view.column.key, side), onMove: beforeKey => table.moveColumn(view.column.key, beforeKey), onFit: () => fitColumn(view.column.key), onHide: () => table.setHidden(view.column.key, true), onPointerDown: event => onColumnPointerDown(event, view), onResizerDown: event => onResizerPointerDown(event, view.column.key) }, view.column.key));
583
+ return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-table", `nx-table--${size}`, fill && "nx-table--fill", className), style: style, "data-head-rows": headRows, "data-status": status, "data-refreshing": refreshing || undefined, "data-selecting": selecting || undefined, "data-sized": sized || undefined, children: [_jsxs("div", { ref: barRef, className: "nx-table__bar", children: [addPart && (_jsxs("div", { className: "nx-table__bar-add", children: [add &&
584
+ (sheetItems.length > 0 ? (_jsx(SplitButton, { fixed: true, block: true, size: "sm", menuLabel: "More ways to add", options: [{ value: "add", label: add.label, icon: _jsx(InlineIcon, { icon: iconPlus }) }, ...sheetItems.map(item => ({ value: item.id, label: item.label, icon: item.icon, description: item.description }))], onAction: value => (value === "add" ? add.onClick() : sheetItems.find(item => item.id === value)?.onSelect()) })) : (addButton(true))), actions] })), toolbar && _jsx("div", { className: "nx-table__bar-start", children: toolbar }), _jsxs("div", { className: "nx-table__bar-end", children: [filterInputs?.length ? (_jsx(TableFiltersPanel, { ref: filtersRef, anchor: optionsRef, inputs: filterInputs, value: query.filters, delay: searchDelay, matches: state.matches, total: state.total, onChange: values => table.setFilters(values) })) : null, hasColumnsPanel && (_jsx(TableColumnsPanel, { ref: columnsRef, anchor: optionsRef, columns: ordered, hidden: state.hidden, pinned: state.pinned, reorderable: reorderable, size: "sm", changed: table.hasColumnChanges(), onToggle: (key, hide) => table.setHidden(key, hide), onOrder: keys => table.setOrder(keys), onReset: () => table.resetColumns() })), reloadable && (_jsx(Button, { ref: reloadRef, size: "sm", variant: "ghost", className: "nx-table__reload", icon: _jsx(InlineIcon, { icon: iconRefresh, className: refreshing ? "nx-spin" : undefined }), "aria-label": "Reload", title: "Reload", disabled: status === "loading", onClick: reload })), optionEntries.length > 0 && (_jsx(Menu, { items: optionEntries, size: "sm", align: "end", "aria-label": "More options", onSelect: value => optionItems.find(action => action.id === value)?.onSelect(), children: _jsx(Button, { ref: optionsRef, size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), dot: filtersOn.length > 0 && "primary", "aria-label": "More options", title: filtersOn.length ? `More options · ${filtersOn.length} ${filtersOn.length === 1 ? "filter" : "filters"} on` : "More options", "data-table-options": true }) })), !searchable && pageSizePicker] }), searchable && (_jsx(TextBox, { ref: searchRef, type: "search", size: "sm", className: "nx-table__search", actionsStart: pageSizePicker, icon: _jsx(InlineIcon, { icon: iconSearch }), placeholder: searchPlaceholder, "aria-label": searchPlaceholder.replace(/…$/, ""), "aria-controls": tableId, defaultValue: query.search, clearable: true, onValueChange: onSearch, onKeyDown: event => {
585
+ if (event.key === "Enter") {
586
+ clearTimeout(searchTimer.current);
587
+ table.setSearch(event.currentTarget.value);
588
+ }
589
+ } }, searchGeneration))] }), _jsxs("div", { className: "nx-table__frame", children: [selectedRows.length > 0 && (_jsx(SelectionBar, { count: selectedRows.length, actions: mass, picks: picks, rows: selectedRows, onClear: () => table.clearSelection(), controls: tableId })), _jsx("div", { className: "nx-table__progress", "aria-hidden": "true" }), _jsx("div", { ref: scrollRef, className: "nx-table__scroll", style: fill ? undefined : { maxHeight: cssLength(maxHeight) }, children: _jsxs("table", { ref: tableRef, id: tableId, className: "nx-table__table", "aria-label": props["aria-label"], "aria-labelledby": props["aria-labelledby"], "aria-busy": status === "loading" || refreshing || undefined, children: [sized && _jsx(TableColumnGroup, { columns: views, widths: state.widths, frame: frame, colRefs: colRefs }), _jsxs("thead", { ref: headRef, className: "nx-table__thead", children: [_jsxs("tr", { className: "nx-table__head", children: [frame.index && (_jsx(TableIndexCell, { heading: true, rowSpan: headRows, own: frame.index, number: "#", numbered: numbered, selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", children: _jsx("input", { ref: headerCheck, type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: allOnPage, disabled: rows.length === 0 || status !== "ready", "aria-label": "Select every row on this page", onChange: event => {
590
+ lastToggled.current = null;
591
+ if (table.selectPage(event.currentTarget.checked) > 0)
592
+ warnFull();
593
+ } }) })) })), bands.map(band => band.group === null ? (headingCell(band.columns[0], headRows)) : (_jsx(GroupHeading, { band: band }, `group:${band.group}:${band.columns[0].column.key}`))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler", rowSpan: headRows }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", rowSpan: headRows, "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }), grouped && _jsx("tr", { className: "nx-table__head nx-table__head--leaves", children: bands.filter(band => band.group !== null).flatMap(band => band.columns.map(view => headingCell(view))) })] }), pins && (_jsxs("tbody", { className: "nx-table__pins", "data-open": pinsOpen || undefined, children: [_jsx(TablePinnedHead, { span: span, label: pinnedLabel, count: pins.length, max: pinnedMax, loading: pinnedLoading, open: pinsOpen, onToggle: togglePins }), pinnedBody] })), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
440
594
  }
441
595
  /** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
442
596
  function moveTargets(views, key) {
@@ -451,12 +605,23 @@ function moveTargets(views, key) {
451
605
  right: at < group.length - 1 ? (group[at + 2]?.column.key ?? null) : undefined,
452
606
  };
453
607
  }
608
+ /**
609
+ * The heading of a band: one cell centred over the columns of its run, which reads as a heading and not as a column —
610
+ * it sorts nothing, has no menu, and has no edge to drag. A band over pinned columns stays with them.
611
+ */
612
+ function GroupHeading({ band }) {
613
+ const { columns } = band;
614
+ const pin = columns[0].pin;
615
+ // Pinned: the band sits where the outermost column of its own side sits.
616
+ const style = pin === "left" ? { left: columns[0].offset } : pin === "right" ? { right: columns[columns.length - 1].offset } : undefined;
617
+ return (_jsx("th", { scope: "colgroup", className: "nx-table__heading nx-table__heading--group", colSpan: columns.length, "data-group": band.group, "data-pin": pin ?? undefined, "data-pin-edge": (pin && columns.some(view => view.edge)) || undefined, style: style, children: _jsx("span", { className: "nx-table__group", children: band.group }) }));
618
+ }
454
619
  /**
455
620
  * A column's heading: a button that sorts (the direction as an arrow and in `aria-sort`), a menu that pins, moves,
456
621
  * fits, and hides the column, and an edge to drag for its width. The heading itself drags into another place.
457
622
  * While the selection bar covers the headings (`covered`), Tab skips what is under it.
458
623
  */
459
- function HeaderCell({ view, sized, sort, disabled, covered, canPin, canHide, neighbours, onSort, onPin, onMove, onFit, onHide, onPointerDown, onResizerDown }) {
624
+ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, canHide, neighbours, onSort, onPin, onMove, onFit, onHide, onPointerDown, onResizerDown }) {
460
625
  const { column } = view;
461
626
  const direction = sort?.key === column.key ? sort.direction : null;
462
627
  const align = columnAlign(column);
@@ -514,15 +679,52 @@ function HeaderCell({ view, sized, sort, disabled, covered, canPin, canHide, nei
514
679
  onHide();
515
680
  }
516
681
  const label = _jsx("span", { className: "nx-table__heading-label", children: column.label });
517
- return (_jsxs("th", { scope: "col", className: cx("nx-table__heading", `nx-table__cell--${align}`), "data-col": column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, "data-movable": view.movable || undefined, style: columnStyle(view, sized), "aria-sort": direction === "asc" ? "ascending" : direction === "desc" ? "descending" : undefined, onPointerDown: onPointerDown, children: [_jsxs("div", { className: "nx-table__heading-inner", children: [sortable ? (_jsxs("button", { type: "button", className: "nx-table__sort", "data-sorted": direction ?? undefined, disabled: disabled && !direction, tabIndex: covered ? -1 : undefined, onClick: () => onSort(direction === "asc" ? "desc" : direction === "desc" ? null : "asc"), title: `Sort by ${column.label}`, children: [label, _jsx(InlineIcon, { icon: direction === "asc" ? iconArrowUp : direction === "desc" ? iconArrowDown : iconArrowsSort, className: "nx-table__sort-icon" })] })) : (label), entries.length > 0 && (_jsx(Menu, { items: entries, size: "sm", align: "end", "aria-label": `${column.label}: column`, onSelect: choose, children: _jsx(Button, { variant: "ghost", size: "sm", className: "nx-table__col-menu", icon: _jsx(InlineIcon, { icon: iconChevronDown }), tabIndex: covered ? -1 : undefined, "aria-label": `${column.label}: column options`, title: "Column options" }) }))] }), view.resizable && (_jsx("span", { className: "nx-table__resizer", "aria-hidden": "true", "data-resize": column.key, title: `Drag to resize ${column.label}, double-click to fit`, onPointerDown: onResizerDown, onDoubleClick: onFit }))] }));
682
+ return (_jsxs("th", { scope: "col", className: cx("nx-table__heading", `nx-table__cell--${align}`), rowSpan: rowSpan, "data-col": column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, "data-movable": view.movable || undefined, style: columnStyle(view, sized), "aria-sort": direction === "asc" ? "ascending" : direction === "desc" ? "descending" : undefined, onPointerDown: onPointerDown, children: [_jsxs("div", { className: "nx-table__heading-inner", children: [sortable ? (_jsxs("button", { type: "button", className: "nx-table__sort", "data-sorted": direction ?? undefined, disabled: disabled && !direction, tabIndex: covered ? -1 : undefined, onClick: () => onSort(direction === "asc" ? "desc" : direction === "desc" ? null : "asc"), title: `Sort by ${column.label}`, children: [label, _jsx(InlineIcon, { icon: direction === "asc" ? iconArrowUp : direction === "desc" ? iconArrowDown : iconArrowsSort, className: "nx-table__sort-icon" })] })) : (label), entries.length > 0 && (_jsx(Menu, { items: entries, size: "sm", align: "end", "aria-label": `${column.label}: column`, onSelect: choose, children: _jsx(Button, { variant: "ghost", size: "sm", className: "nx-table__col-menu", icon: _jsx(InlineIcon, { icon: iconChevronDown }), tabIndex: covered ? -1 : undefined, "aria-label": `${column.label}: column options`, title: "Column options" }) }))] }), view.resizable && (_jsx("span", { className: "nx-table__resizer", "aria-hidden": "true", "data-resize": column.key, title: `Drag to resize ${column.label}, double-click to fit`, onPointerDown: onResizerDown, onDoubleClick: onFit }))] }));
683
+ }
684
+ /** The steps a bar is squeezed into when its parts do not fit, each one keeping the steps before it. */
685
+ const BAR_SQUEEZES = ["search", "search reload", "search reload add"];
686
+ /** Whether the parts of the bar that are not meant to take a line of their own share one line. */
687
+ function barFits(bar) {
688
+ let middle = null;
689
+ for (const part of Array.from(bar.children)) {
690
+ if (part.offsetWidth === 0 || getComputedStyle(part).flexBasis === "100%")
691
+ continue;
692
+ const at = part.offsetTop + part.offsetHeight / 2;
693
+ if (middle === null)
694
+ middle = at;
695
+ else if (Math.abs(at - middle) > 4)
696
+ return false;
697
+ }
698
+ return true;
699
+ }
700
+ /** Takes the squeeze off, then squeezes the bar one step at a time until it fits (or runs out of steps). */
701
+ function squeezeBar(bar) {
702
+ bar.removeAttribute("data-squeeze");
703
+ for (const squeeze of BAR_SQUEEZES) {
704
+ if (barFits(bar))
705
+ return;
706
+ bar.setAttribute("data-squeeze", squeeze);
707
+ }
518
708
  }
519
709
  /** The bar over the column headings while rows are selected: the count, one button for the actions, and Clear. */
520
- function SelectionBar({ count, actions, rows, onClear, controls }) {
710
+ /** Menu actions as a menu's entries: a separator wherever an action starts a group. */
711
+ function menuEntries(actions) {
712
+ return actions.flatMap((action, index) => [
713
+ ...(action.separator && index > 0 ? [{ separator: true }] : []),
714
+ { value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled },
715
+ ]);
716
+ }
717
+ function SelectionBar({ count, actions, picks, rows, onClear, controls }) {
521
718
  const [running, setRunning] = useState(null);
522
719
  const [chosen, setChosen] = useState(undefined);
523
720
  const alive = useRef(true);
524
- useEffect(() => () => {
525
- alive.current = false;
721
+ // The bar is alive again on every mount, not only the first: React runs this twice in development, and the second
722
+ // mount would otherwise keep the `false` the first one left, so an action's spinner would never stop.
723
+ useEffect(() => {
724
+ alive.current = true;
725
+ return () => {
726
+ alive.current = false;
727
+ };
526
728
  }, []);
527
729
  const usable = actions.filter(action => !action.disabled);
528
730
  const current = actions.find(action => action.id === chosen && !action.disabled) ?? usable[0] ?? actions[0];
@@ -538,5 +740,5 @@ function SelectionBar({ count, actions, rows, onClear, controls }) {
538
740
  // Every action on the selected rows lives in one button: the one shown runs on a click, the menu holds the rest and
539
741
  // makes the one picked the button, the way a split button works everywhere else.
540
742
  const options = actions.map(action => ({ value: action.id, label: action.label, icon: action.icon, disabled: action.disabled }));
541
- return (_jsxs("div", { className: "nx-table__selection", role: "toolbar", "aria-label": "Selected rows", "aria-controls": controls, children: [_jsxs("span", { className: "nx-table__selection-count", "aria-live": "polite", children: [formatCount(count), " selected"] }), _jsx("div", { className: "nx-table__selection-actions", children: actions.length > 1 ? (_jsx(SplitButton, { options: options, value: current?.id, size: "sm", variant: current?.tone === "danger" ? "danger" : "primary", loading: running !== null, menuLabel: "More actions on the selected rows", className: "nx-table__mass", onValueChange: value => setChosen(value), onAction: value => run(actions.find(action => action.id === value)) })) : (actions.map(action => (_jsx(Button, { variant: "primary", size: "sm", icon: action.icon, loading: running === action.id, disabled: action.disabled, "data-action": action.id, title: action.label, className: "nx-table__mass", onClick: () => run(action), children: action.label }, action.id)))) }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconX }), className: "nx-table__selection-clear", disabled: running !== null, onClick: onClear, children: "Clear" })] }));
743
+ return (_jsxs("div", { className: "nx-table__selection", role: "toolbar", "aria-label": "Selected rows", "aria-controls": controls, children: [picks.length > 0 ? (_jsx(Menu, { items: menuEntries(picks), size: "sm", "aria-label": "Select rows", onSelect: value => picks.find(item => item.id === value)?.onSelect(), children: _jsx(Button, { variant: "ghost", size: "sm", iconEnd: _jsx(InlineIcon, { icon: iconChevronDown }), title: "Select by what the rows are", className: "nx-table__selection-pick", children: _jsxs("span", { className: "nx-table__selection-count", "aria-live": "polite", children: [formatCount(count), " selected"] }) }) })) : (_jsxs("span", { className: "nx-table__selection-count", "aria-live": "polite", children: [formatCount(count), " selected"] })), _jsx("div", { className: "nx-table__selection-actions", children: actions.length > 1 ? (_jsx(SplitButton, { options: options, value: current?.id, size: "sm", variant: current?.tone === "danger" ? "danger" : "primary", loading: running !== null, menuLabel: "More actions on the selected rows", className: "nx-table__mass", onValueChange: value => setChosen(value), onAction: value => run(actions.find(action => action.id === value)) })) : (actions.map(action => (_jsx(Button, { variant: "primary", size: "sm", icon: action.icon, loading: running === action.id, disabled: action.disabled, "data-action": action.id, title: action.label, className: "nx-table__mass", onClick: () => run(action), children: action.label }, action.id)))) }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconX }), className: "nx-table__selection-clear", disabled: running !== null, onClick: onClear, children: "Clear" })] }));
542
744
  }
@@ -0,0 +1,32 @@
1
+ import { type Ref, type RefObject } from "react";
2
+ import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
3
+ import type { TableColumn, TablePinSide, TableRow } from "../../Interfaces/TableInterfaces.ts";
4
+ export interface TableColumnsPanelProps<TRow extends TableRow> {
5
+ /** Every column the table has, in the order they are in now - hidden ones among them. */
6
+ columns: readonly TableColumn<TRow>[];
7
+ hidden: ReadonlySet<string>;
8
+ pinned: Readonly<Record<string, TablePinSide>>;
9
+ /** False while the table is not reorderable: the panel then only shows and hides. */
10
+ reorderable?: boolean;
11
+ /** Default `md`. */
12
+ size?: "sm" | "md";
13
+ /** Whether the user has arranged anything, so Reset has something to undo. */
14
+ changed?: boolean;
15
+ onToggle: (key: string, hidden: boolean) => void;
16
+ /** The whole order, by key, after a move. */
17
+ onOrder: (keys: readonly string[]) => void;
18
+ onReset?: () => void;
19
+ /** What the panel opens under, such as the table's options button; Esc gives the focus back to it. */
20
+ anchor: RefObject<HTMLElement | null>;
21
+ ref?: Ref<PopoverPanelHandle>;
22
+ }
23
+ /**
24
+ * The Columns panel, opened through its `open()` handle under `anchor`: a checkbox per column, bands with their columns under
25
+ * them, and a handle to drag a row into another place. The panel is a popover under the button, so it closes on Esc,
26
+ * on a click outside, and on Tab.
27
+ *
28
+ * @example
29
+ * <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
30
+ * onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
31
+ */
32
+ export declare function TableColumnsPanel<TRow extends TableRow>({ columns, hidden, pinned, reorderable, size, changed, onToggle, onOrder, onReset, anchor, ref, }: TableColumnsPanelProps<TRow>): import("react").JSX.Element;