nexus-shared 3.0.0 → 3.2.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 (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
@@ -1,26 +1,32 @@
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, iconDatabase, iconDotsVertical, iconEyeOff, iconDownload, 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, iconPin, 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
8
  import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
8
9
  import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
10
+ import { asFilterInput, filterNames, filterRequest } from "../../Helpers/TableFilters.js";
11
+ import { joinKeys, joinSourceName, tableJoins } from "../../Helpers/TableJoins.js";
9
12
  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
13
  import { TableStore } from "../../Helpers/TableStore.js";
14
+ import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
11
15
  import { Button } from "../Documents/Button.js";
12
16
  import { Menu } from "../Documents/Menu.js";
13
17
  import { SplitButton } from "../Documents/SplitButton.js";
14
18
  import { TextBox } from "../Inputs/TextBox.js";
15
19
  import { toast } from "../Layouts/Toaster.js";
16
20
  import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
21
+ import { joinSource } from "../../Services/JoinSources.js";
17
22
  import { openExcelImport } from "../Forms/ExcelImport.js";
18
23
  import { TableColumnsPanel } from "./DataTableColumns.js";
19
- import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
24
+ import { TableFiltersPanel } from "./DataTableFilters.js";
25
+ import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TableRowView, TableSectionHead, TableSkeletonRows, } from "./DataTableParts.js";
20
26
  /*
21
27
  * DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
22
28
  * actions on each row, and columns the user arranges (shown or hidden, in any order, resized, pinned to a side). Rows
23
- * given as `pinnedRows` sit in a section above the page that folds open and shut. Rows are given, loaded once, or
29
+ * given as `sections` (the records pinned, the favourites) sit above the page, each folding open and shut. Rows are given, loaded once, or
24
30
  * loaded a page at a time; what the table shows lives in a `TableStore`, which pages (and the CRUD component) change
25
31
  * from outside.
26
32
  */
@@ -33,6 +39,10 @@ export function useTable() {
33
39
  return store;
34
40
  }
35
41
  const STATE_PREFIX = "nx-table:";
42
+ /** The key of the section `pinnedRows` makes. */
43
+ const PINS_SECTION = "pins";
44
+ /** The lookups' version for a table with no `join` column, which never listens to them. */
45
+ const NO_JOINS = () => 0;
36
46
  function readSaved(key) {
37
47
  if (!key || typeof localStorage === "undefined")
38
48
  return null;
@@ -44,7 +54,7 @@ function readSaved(key) {
44
54
  return null;
45
55
  }
46
56
  }
47
- // Settings are written in two places (the query and the columns together, the pinned section on its own click), so a
57
+ // Settings are written in two places (the query and the columns together, a section's fold on its own click), so a
48
58
  // write keeps whatever it does not mention.
49
59
  function writeSaved(key, changes) {
50
60
  try {
@@ -89,26 +99,45 @@ const sameNumbers = (a, b) => {
89
99
  * />
90
100
  */
91
101
  export function DataTable(props) {
92
- 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, openOnClick = false, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize = DEFAULT_PAGE_SIZE, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
102
+ 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, sections: ownSections, rowMarks, 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;
93
103
  const paging = givenRows ? "client" : (props.paging ?? "client");
94
104
  const reloadable = props.reloadable ?? Boolean(load);
95
105
  const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
96
106
  // A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer.
97
107
  const numbered = props.numbered ?? selectable;
98
- const [own] = useState(() => (givenTable ? null : new TableStore(pageSize)));
108
+ // The person's choices in the quick preference panel, once made, go over what the table names itself.
109
+ const preferences = useUserPreferences();
110
+ const openOnClick = preferences.rowClick ?? ownOpenOnClick ?? false;
111
+ /** The page size the table opens with: the person's default when this table offers it, else the table's own. */
112
+ const openingPageSize = () => {
113
+ const preferred = readUserPreferences().pageSize;
114
+ return preferred !== null && pageSizes.includes(preferred) ? preferred : (ownPageSize ?? DEFAULT_PAGE_SIZE);
115
+ };
116
+ const [own] = useState(() => (givenTable ? null : new TableStore(ownPageSize ?? DEFAULT_PAGE_SIZE)));
99
117
  const table = givenTable ?? own;
100
- table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange });
118
+ // Prepared once for the store and the panel: the same params without what would make them filter on their own.
119
+ const filterInputs = useMemo(() => props.filterInputs?.map(asFilterInput), [props.filterInputs]);
120
+ table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, filterInputs });
101
121
  useImperativeHandle(ref, () => table, [table]);
102
122
  const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
103
- const preferences = useDatePreferences();
123
+ // The columns whose values are other records', looked up by key (`join`). Only a table with one listens to the page's
124
+ // lookups, and it draws again - its rows too, through `cellOptions` - each time names come in.
125
+ const joins = useMemo(() => columns.flatMap(column => (column.join ? [column.join] : [])), [columns]);
126
+ const subscribeJoins = useCallback((listener) => (joins.length ? tableJoins.subscribe(listener) : () => { }), [joins.length]);
127
+ const joinsVersion = useSyncExternalStore(subscribeJoins, joins.length ? tableJoins.getVersion : NO_JOINS, NO_JOINS);
128
+ const dates = useDatePreferences();
104
129
  const timeZone = useBrowserTimeZone();
105
- const cellOptions = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined }), [preferences, timeZone]);
130
+ const numbers = useNumberLocale();
131
+ // `joinsVersion` is read by no cell option: a new object is what tells the memoized rows that their names came in.
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers, joinsVersion]);
106
134
  const tableId = useId();
107
135
  // The search box is uncontrolled: a new key clears it when a new list starts over.
108
136
  const [searchGeneration, setSearchGeneration] = useState(0);
109
- // The pinned section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
110
- const [pinsOpen, setPinsOpen] = useState(false);
137
+ // Every section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
138
+ const [openSections, setOpenSections] = useState(() => new Set());
111
139
  const searchTimer = useRef(undefined);
140
+ const searchRef = useRef(null);
112
141
  const started = useRef(false);
113
142
  const loadedKey = useRef(loadKey);
114
143
  // First rows: saved settings first, then the rows given or the first load. A layout effect, so rows that are at hand
@@ -118,15 +147,16 @@ export function DataTable(props) {
118
147
  started.current = true;
119
148
  const saved = readSaved(stateKey);
120
149
  table.restore({
121
- pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : pageSize,
150
+ pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
122
151
  sort: saved?.sort !== undefined ? saved.sort : defaultSort,
123
152
  hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
124
153
  order: saved?.order,
125
154
  widths: saved?.widths,
126
155
  pinned: saved?.pinned,
127
156
  });
128
- if (saved?.pinsOpen)
129
- setPinsOpen(true);
157
+ const open = saved?.openSections ?? (saved?.pinsOpen ? [PINS_SECTION] : []);
158
+ if (open.length)
159
+ setOpenSections(new Set(open));
130
160
  if (givenRows)
131
161
  table.setRows(givenRows);
132
162
  else
@@ -159,7 +189,8 @@ export function DataTable(props) {
159
189
  if (!stateKey || !started.current)
160
190
  return;
161
191
  writeSaved(stateKey, {
162
- pageSize: state.query.pageSize,
192
+ // Only a size the person picked here: one equal to the default is not kept, so a new default still reaches it.
193
+ pageSize: state.query.pageSize === openingPageSize() ? undefined : state.query.pageSize,
163
194
  sort: state.query.sort,
164
195
  hidden: hiddenList,
165
196
  order: state.order.length ? [...state.order] : undefined,
@@ -167,6 +198,34 @@ export function DataTable(props) {
167
198
  pinned: Object.keys(state.pinned).length ? state.pinned : undefined,
168
199
  });
169
200
  }, [stateKey, state.query.pageSize, state.query.sort, hiddenList, state.order, state.widths, state.pinned]);
201
+ // A new default page size from the panel reaches an open table where no size was picked. Only a change counts: on the
202
+ // first effects the save above still holds the store's opening size and has not written the restored one yet, so a
203
+ // check then would find nothing saved and undo a size picked earlier. The size is read from storage, not from the
204
+ // hook's value, which is the server's default while the page hydrates.
205
+ const preferredSize = useRef(preferences.pageSize);
206
+ useEffect(() => {
207
+ if (preferredSize.current === preferences.pageSize)
208
+ return;
209
+ preferredSize.current = preferences.pageSize;
210
+ if (!started.current || readSaved(stateKey)?.pageSize)
211
+ return;
212
+ table.setPageSize(openingPageSize());
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ }, [preferences.pageSize]);
215
+ // Every key the rows hold for a join, asked for together: a client table's every row, since it searches and sorts them
216
+ // all, a server table's page, and the rows of the sections above it. What is known already is not asked again.
217
+ useEffect(() => {
218
+ if (!joins.length || state.status !== "ready")
219
+ return;
220
+ const shown = [...table.getRows(), ...(pinnedRows ?? []), ...(ownSections ?? []).flatMap(section => section.rows)];
221
+ for (const [source, keys] of joinKeys(joins, shown))
222
+ tableJoins.request(joinSource(source), keys);
223
+ }, [joins, state.status, state.rows, pinnedRows, ownSections, table]);
224
+ // Names came in: a client table searches and sorts again with them.
225
+ useEffect(() => {
226
+ if (joinsVersion)
227
+ table.refreshValues();
228
+ }, [joinsVersion, table]);
170
229
  const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
171
230
  // Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
172
231
  const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
@@ -360,15 +419,19 @@ export function DataTable(props) {
360
419
  });
361
420
  };
362
421
  /* The bar above the table, and the options menu */
422
+ // The labels of the filters that are on: the menu's second line, the dot's title, and an export's note.
423
+ const filtersOn = filterNames(filterInputs ?? [], query.filters);
363
424
  const outputName = exportName ?? props["aria-label"] ?? "Table";
364
425
  const output = () => {
365
426
  const shown = views.map(view => view.column);
366
427
  const rowsOut = table.getViewRows();
367
- const note = [`${formatCount(rowsOut.length)} ${rowsOut.length === 1 ? "row" : "rows"}`, query.search && `search: ${query.search}`].filter(Boolean).join(" · ");
428
+ const note = [`${formatCount(rowsOut.length)} ${rowsOut.length === 1 ? "row" : "rows"}`, query.search && `search: ${query.search}`, filtersOn.length && `filters: ${filtersOn.join(", ")}`]
429
+ .filter(Boolean)
430
+ .join(" · ");
368
431
  return { columns: shown, rows: rowsOut, cellOptions, name: outputName, note };
369
432
  };
370
433
  const serverPage = paging === "server";
371
- /** What an export to Excel asks the server for: the search and the sort on screen, and the page's own filters. */
434
+ /** What an export to Excel asks the server for: the search, the filters and the sort on screen, and the page's own filters. */
372
435
  const exportQuery = () => {
373
436
  const column = query.sort ? columns.find(candidate => candidate.key === query.sort.key) : undefined;
374
437
  return {
@@ -379,32 +442,98 @@ export function DataTable(props) {
379
442
  search: query.search || null,
380
443
  sort: column ? (column.sortKey ?? column.key) : null,
381
444
  direction: query.sort?.direction,
382
- filters: excel?.filters?.(),
445
+ filters: { ...filterRequest(query.filters, filterInputs ?? [], columns), ...excel?.filters?.() },
383
446
  },
384
447
  };
385
448
  };
386
- /** After an import: the rows it wrote are the server's, so the table reads its page again. */
449
+ /**
450
+ * After an import: the rows it wrote are the server's, so the table reads its page again - after the page has heard
451
+ * of it. A `Crud` drops the lists it keeps in `onImported`, and a reload asked first was answered from that cache: on a
452
+ * page just opened, the list stayed as it was until the person reloaded it themselves (the owner, 2026-10-07).
453
+ */
387
454
  const onImported = () => {
388
- void table.reload({ quiet: true });
389
455
  excel?.onImported?.();
456
+ void table.reload({ quiet: true });
457
+ };
458
+ // The Filters and Columns panels open from the options menu, under its button.
459
+ const optionsRef = useRef(null);
460
+ const filtersRef = useRef(null);
461
+ const columnsRef = useRef(null);
462
+ const hasColumnsPanel = columnsMenu && columns.length > 1;
463
+ // The stylesheet takes the bar's steps by the table's width. A bar wider than those steps expect (a long Add label,
464
+ // the page's own buttons, cozy density) is squeezed into the next step as soon as a line would break, a frame after
465
+ // its size changed, so the ResizeObserver never loops. Reload waits in the options menu whenever its button is hidden:
466
+ // the menu asks the button, so what decides it is written once, in the stylesheet.
467
+ const barRef = useRef(null);
468
+ const reloadRef = useRef(null);
469
+ const [reloadHidden, setReloadHidden] = useState(false);
470
+ const addPart = Boolean(add || actions);
471
+ const listsPart = Boolean(toolbar);
472
+ useLayoutEffect(() => {
473
+ const root = rootRef.current;
474
+ const bar = barRef.current;
475
+ if (!root || !bar)
476
+ return;
477
+ let frame = 0;
478
+ const fit = () => {
479
+ squeezeBar(bar);
480
+ setReloadHidden(reloadRef.current ? getComputedStyle(reloadRef.current).display === "none" : false);
481
+ };
482
+ const observer = new ResizeObserver(() => {
483
+ cancelAnimationFrame(frame);
484
+ frame = requestAnimationFrame(fit);
485
+ });
486
+ observer.observe(root);
487
+ for (const part of Array.from(bar.children))
488
+ observer.observe(part);
489
+ return () => {
490
+ observer.disconnect();
491
+ cancelAnimationFrame(frame);
492
+ };
493
+ }, [addPart, listsPart, searchable, reloadable]);
494
+ // A person's Reload is a refresh of what the table shows, the names it looked up included: those are marked old - still
495
+ // drawn, asked again with the rows - and nothing else the page keeps is touched.
496
+ const reload = () => {
497
+ for (const join of joins)
498
+ tableJoins.expire(joinSourceName(join.source));
499
+ onReload?.();
500
+ void table.reload({ quiet: true });
390
501
  };
502
+ // A spreadsheet: the template to fill, the file to bring back, and an export the server writes from what is on
503
+ // screen. With an Add, they are other ways to add, under its arrow; without one, they are in the options menu.
504
+ const sheetItems = excel
505
+ ? [
506
+ { 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 }) },
507
+ { 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 }) },
508
+ // 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
509
+ // who may create, and nobody types fifty rows to be refused at the end.
510
+ ...(excel.bulk === false || !add
511
+ ? []
512
+ : [{ 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" }) }]),
513
+ ...(excel.endpoints.export
514
+ ? [{ 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()) }]
515
+ : []),
516
+ ]
517
+ : [];
518
+ // In the bar it is `block`: as wide as its own label there, and the whole line once the Add section takes one.
519
+ const addButton = (block = false) => add && (_jsx(Button, { variant: "primary", size: "sm", block: block, icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: add.onClick, children: add.label }));
391
520
  const optionItems = [
521
+ ...(filterInputs?.length
522
+ ? [{ id: "filters", label: "Filters", description: filtersOn.join(", ") || undefined, icon: _jsx(InlineIcon, { icon: iconFilter }), onSelect: () => filtersRef.current?.open() }]
523
+ : []),
524
+ ...(filtersOn.length ? [{ id: "clear-filters", label: "Clear filters", icon: _jsx(InlineIcon, { icon: iconFilterOff }), onSelect: () => table.setFilters({}) }] : []),
525
+ ...(hasColumnsPanel ? [{ id: "columns", label: "Columns", icon: _jsx(InlineIcon, { icon: iconColumns3 }), onSelect: () => columnsRef.current?.open() }] : []),
392
526
  ...(exportable && status === "ready" && rows.length > 0
393
527
  ? [{ id: "export", label: serverPage ? "Export this page (CSV)" : "Export as CSV", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => exportTableCsv(output()) }]
394
528
  : []),
395
529
  ...(printable && status === "ready" && rows.length > 0
396
530
  ? [{ id: "print", label: serverPage ? "Print this page" : "Print", icon: _jsx(InlineIcon, { icon: iconPrinter }), onSelect: () => printTable(output()) }]
397
531
  : []),
398
- // A spreadsheet: the template to fill, the file to bring back, and an export the server writes from what is on
399
- // screen. Its own Import replaces the page's `onImport` hook.
532
+ // The sheet's own Import replaces the page's `onImport` hook.
400
533
  ...(excel
401
- ? [
402
- { 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 }) },
403
- { 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 }) },
404
- ...(excel.endpoints.export
405
- ? [{ id: "excel-export", label: "Export to Excel", description: "Everything the search leaves, from the server", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => void exportExcel(excel.endpoints, exportQuery()) }]
406
- : []),
407
- ]
534
+ ? add
535
+ ? []
536
+ : sheetItems
408
537
  : onImport
409
538
  ? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }]
410
539
  : []),
@@ -413,11 +542,9 @@ export function DataTable(props) {
413
542
  ...(!columnsMenu && table.hasColumnChanges()
414
543
  ? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }]
415
544
  : []),
545
+ ...(reloadable && reloadHidden ? [{ id: "reload", label: "Reload", icon: _jsx(InlineIcon, { icon: iconRefresh }), separator: true, onSelect: reload }] : []),
416
546
  ];
417
- const optionEntries = optionItems.flatMap((action, index) => [
418
- ...(action.separator && index > 0 ? [{ separator: true }] : []),
419
- { value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled },
420
- ]);
547
+ const optionEntries = menuEntries(optionItems);
421
548
  const pageSizePicker = _jsx(TablePageSize, { pageSize: query.pageSize, pageSizes: pageSizes, compact: searchable, onChange: value => table.setPageSize(value) });
422
549
  let body;
423
550
  if (status === "loading") {
@@ -426,55 +553,86 @@ export function DataTable(props) {
426
553
  else if (status === "error") {
427
554
  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" }) }));
428
555
  }
429
- else if (rows.length === 0 && query.search) {
430
- 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: () => {
431
- clearTimeout(searchTimer.current);
432
- setSearchGeneration(count => count + 1);
433
- table.setSearch("");
434
- }, children: "Clear search" }) }));
556
+ else if (rows.length === 0 && (query.search || query.filters)) {
557
+ // What left no rows, the search, the filters or both, and a way to clear each.
558
+ const title = !query.filters ? `Nothing matches “${query.search}”.` : query.search ? `Nothing matches “${query.search}” with these filters.` : "No rows match the filters.";
559
+ const detail = !query.filters ? "Check the spelling, or search for something shorter." : query.search ? undefined : "Change them, or clear them.";
560
+ 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: () => {
561
+ clearTimeout(searchTimer.current);
562
+ flushSync(() => {
563
+ setSearchGeneration(count => count + 1);
564
+ table.setSearch("");
565
+ });
566
+ searchRef.current?.focus();
567
+ }, children: "Clear search" })), query.filters && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconFilterOff }), onClick: () => {
568
+ optionsRef.current?.focus();
569
+ table.setFilters({});
570
+ }, children: "Clear filters" }))] }));
435
571
  }
436
572
  else if (rows.length === 0) {
437
- body = (_jsx(TableMessageRow, { span: span, icon: iconDatabase, title: emptyMessage, children: emptyAction }));
573
+ body = (_jsx(TableMessageRow, { span: span, icon: iconDatabase, title: emptyMessage, children: emptyAction === undefined ? addButton() : emptyAction }));
438
574
  }
439
575
  else {
440
576
  body = rows.map((row, index) => {
441
577
  const key = pageKeys[index];
442
578
  const selected = state.selected.has(key);
443
- 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));
579
+ 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, marks: rowMarks?.(row), frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
444
580
  });
445
581
  }
446
- /* The pinned section above the page: the records the user pinned, folded open or shut, remembered on this device */
582
+ /* The sections above the page: the records pinned, the favourites, each folded open or shut, remembered on this device */
583
+ const pinsSection = pinnedRows ? { key: PINS_SECTION, label: pinnedLabel, icon: iconPin, rows: pinnedRows, loading: pinnedLoading, max: pinnedMax } : null;
447
584
  // A section with nothing in it and nothing on its way would be an empty heading, so it does not show at all.
448
- const pins = pinnedRows && (pinnedRows.length > 0 || pinnedLoading) ? pinnedRows : null;
449
- const togglePins = () => {
450
- const next = !pinsOpen;
451
- setPinsOpen(next);
585
+ const sections = [...(pinsSection ? [pinsSection] : []), ...(ownSections ?? [])].filter(section => section.rows.length > 0 || section.loading);
586
+ const toggleSection = (key) => {
587
+ const next = new Set(openSections);
588
+ if (!next.delete(key))
589
+ next.add(key);
590
+ setOpenSections(next);
452
591
  if (stateKey)
453
- writeSaved(stateKey, { pinsOpen: next });
592
+ writeSaved(stateKey, { openSections: [...next], pinsOpen: undefined });
454
593
  };
455
- const pinnedBody = pinsOpen &&
456
- pins?.map((row, index) => {
594
+ const sectionRows = (section) => {
595
+ // A row with no marks of its own still wears the section's icon.
596
+ const own = [{ icon: section.icon, label: section.label }];
597
+ return section.rows.map((row, index) => {
457
598
  const key = table.keyOf(row);
458
599
  const selected = state.selected.has(key);
459
- 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}`));
600
+ const marks = rowMarks?.(row);
601
+ 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, marks: marks?.length ? marks : own, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
460
602
  });
603
+ };
461
604
  const selectedRows = selecting ? [...state.selected.values()] : [];
605
+ // Select again by what the rows are: exactly the rows on the page that pass, or the whole page.
606
+ const pick = (label, test) => {
607
+ const matching = test ? rows.filter(test) : rows;
608
+ if (matching.length === 0)
609
+ return toast(`No row on this page is ${label.toLowerCase()}.`);
610
+ table.clearSelection();
611
+ table.select(matching, true);
612
+ };
613
+ const picks = selectBy?.length
614
+ ? [
615
+ { id: "page", label: "All on this page", icon: _jsx(InlineIcon, { icon: iconChecks }), onSelect: () => pick("on this page") },
616
+ ...selectBy.map((rule, index) => ({ id: `by-${index}`, label: rule.label, icon: rule.icon, separator: index === 0, onSelect: () => pick(rule.label, rule.test) })),
617
+ ]
618
+ : [];
462
619
  const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
463
620
  // 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.
464
621
  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));
465
- 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", { 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 => {
466
- if (event.key === "Enter") {
467
- clearTimeout(searchTimer.current);
468
- table.setSearch(event.currentTarget.value);
469
- }
470
- } }, searchGeneration)) : (pageSizePicker), columnsMenu && columns.length > 1 && (_jsx(TableColumnsPanel, { 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, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh, className: refreshing ? "nx-spin" : undefined }), "aria-label": "Reload", title: "Reload", disabled: status === "loading", onClick: () => {
471
- onReload?.();
472
- void table.reload({ quiet: true });
473
- } })), 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: 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 => {
622
+ 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 &&
623
+ (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 => {
624
+ if (event.key === "Enter") {
625
+ clearTimeout(searchTimer.current);
626
+ table.setSearch(event.currentTarget.value);
627
+ }
628
+ } }, 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 => {
474
629
  lastToggled.current = null;
475
630
  if (table.selectPage(event.currentTarget.checked) > 0)
476
631
  warnFull();
477
- } }) })) })), 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) })] }));
632
+ } }) })) })), 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))) })] }), sections.map(section => {
633
+ const open = openSections.has(section.key);
634
+ return (_jsxs("tbody", { className: "nx-table__section", "data-section": section.key, "data-open": open || undefined, children: [_jsx(TableSectionHead, { span: span, label: section.label, icon: section.icon, count: section.rows.length, max: section.max, loading: Boolean(section.loading), open: open, onToggle: () => toggleSection(section.key) }), open && sectionRows(section)] }, section.key));
635
+ }), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
478
636
  }
479
637
  /** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
480
638
  function moveTargets(views, key) {
@@ -565,8 +723,40 @@ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, can
565
723
  const label = _jsx("span", { className: "nx-table__heading-label", children: column.label });
566
724
  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 }))] }));
567
725
  }
726
+ /** The steps a bar is squeezed into when its parts do not fit, each one keeping the steps before it. */
727
+ const BAR_SQUEEZES = ["search", "search reload", "search reload add"];
728
+ /** Whether the parts of the bar that are not meant to take a line of their own share one line. */
729
+ function barFits(bar) {
730
+ let middle = null;
731
+ for (const part of Array.from(bar.children)) {
732
+ if (part.offsetWidth === 0 || getComputedStyle(part).flexBasis === "100%")
733
+ continue;
734
+ const at = part.offsetTop + part.offsetHeight / 2;
735
+ if (middle === null)
736
+ middle = at;
737
+ else if (Math.abs(at - middle) > 4)
738
+ return false;
739
+ }
740
+ return true;
741
+ }
742
+ /** Takes the squeeze off, then squeezes the bar one step at a time until it fits (or runs out of steps). */
743
+ function squeezeBar(bar) {
744
+ bar.removeAttribute("data-squeeze");
745
+ for (const squeeze of BAR_SQUEEZES) {
746
+ if (barFits(bar))
747
+ return;
748
+ bar.setAttribute("data-squeeze", squeeze);
749
+ }
750
+ }
568
751
  /** The bar over the column headings while rows are selected: the count, one button for the actions, and Clear. */
569
- function SelectionBar({ count, actions, rows, onClear, controls }) {
752
+ /** Menu actions as a menu's entries: a separator wherever an action starts a group. */
753
+ function menuEntries(actions) {
754
+ return actions.flatMap((action, index) => [
755
+ ...(action.separator && index > 0 ? [{ separator: true }] : []),
756
+ { value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled },
757
+ ]);
758
+ }
759
+ function SelectionBar({ count, actions, picks, rows, onClear, controls }) {
570
760
  const [running, setRunning] = useState(null);
571
761
  const [chosen, setChosen] = useState(undefined);
572
762
  const alive = useRef(true);
@@ -592,5 +782,5 @@ function SelectionBar({ count, actions, rows, onClear, controls }) {
592
782
  // Every action on the selected rows lives in one button: the one shown runs on a click, the menu holds the rest and
593
783
  // makes the one picked the button, the way a split button works everywhere else.
594
784
  const options = actions.map(action => ({ value: action.id, label: action.label, icon: action.icon, disabled: action.disabled }));
595
- 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" })] }));
785
+ 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" })] }));
596
786
  }
@@ -1,3 +1,5 @@
1
+ import { type Ref, type RefObject } from "react";
2
+ import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
1
3
  import type { TableColumn, TablePinSide, TableRow } from "../../Interfaces/TableInterfaces.ts";
2
4
  export interface TableColumnsPanelProps<TRow extends TableRow> {
3
5
  /** Every column the table has, in the order they are in now - hidden ones among them. */
@@ -14,10 +16,12 @@ export interface TableColumnsPanelProps<TRow extends TableRow> {
14
16
  /** The whole order, by key, after a move. */
15
17
  onOrder: (keys: readonly string[]) => void;
16
18
  onReset?: () => void;
17
- "aria-label"?: string;
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>;
18
22
  }
19
23
  /**
20
- * The button that opens the Columns panel, and the panel itself: a checkbox per column, bands with their columns under
24
+ * The Columns panel, opened through its `open()` handle under `anchor`: a checkbox per column, bands with their columns under
21
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,
22
26
  * on a click outside, and on Tab.
23
27
  *
@@ -25,4 +29,4 @@ export interface TableColumnsPanelProps<TRow extends TableRow> {
25
29
  * <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
26
30
  * onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
27
31
  */
28
- export declare function TableColumnsPanel<TRow extends TableRow>({ columns, hidden, pinned, reorderable, size, changed, onToggle, onOrder, onReset, "aria-label": ariaLabel, }: TableColumnsPanelProps<TRow>): import("react").JSX.Element;
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;