nexus-shared 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CHANGELOG.md +2268 -446
  2. package/LICENSE +21 -21
  3. package/README.md +1705 -1084
  4. package/dist/Client.Index.d.ts +17 -0
  5. package/dist/Client.Index.js +17 -0
  6. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  7. package/dist/Components/Documents/DocumentFrame.js +83 -0
  8. package/dist/Components/Documents/Menu.d.ts +4 -0
  9. package/dist/Components/Documents/Menu.js +40 -24
  10. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  11. package/dist/Components/Documents/PrintDocument.js +62 -0
  12. package/dist/Components/Documents/Tabs.d.ts +59 -0
  13. package/dist/Components/Documents/Tabs.js +70 -0
  14. package/dist/Components/Forms/ApiForm.js +19 -2
  15. package/dist/Components/Forms/Crud.d.ts +2 -2
  16. package/dist/Components/Forms/Crud.js +871 -377
  17. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  18. package/dist/Components/Forms/CrudActions.js +184 -0
  19. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  20. package/dist/Components/Forms/CrudHistory.js +140 -0
  21. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  22. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  23. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  24. package/dist/Components/Forms/CrudParts.js +56 -5
  25. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  26. package/dist/Components/Forms/CrudRowForms.js +114 -0
  27. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  28. package/dist/Components/Forms/CrudScopes.js +76 -0
  29. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  30. package/dist/Components/Forms/CrudTree.js +363 -0
  31. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  32. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  33. package/dist/Components/Forms/ExcelImport.js +3 -3
  34. package/dist/Components/Forms/Form.d.ts +4 -4
  35. package/dist/Components/Forms/Form.js +125 -16
  36. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  37. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  38. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  39. package/dist/Components/Forms/RecordFlows.js +282 -0
  40. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  41. package/dist/Components/Forms/SubmitForm.js +195 -10
  42. package/dist/Components/Inputs/CheckBox.js +1 -1
  43. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  44. package/dist/Components/Inputs/ChoiceField.js +28 -2
  45. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  46. package/dist/Components/Inputs/CodeBox.js +316 -0
  47. package/dist/Components/Inputs/DatePicker.js +4 -1
  48. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  49. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  50. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  51. package/dist/Components/Inputs/Dropdown.js +8 -5
  52. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  53. package/dist/Components/Inputs/DropdownParts.js +16 -4
  54. package/dist/Components/Inputs/Field.d.ts +7 -1
  55. package/dist/Components/Inputs/Field.js +2 -2
  56. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  57. package/dist/Components/Inputs/GroupForm.js +10 -11
  58. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  59. package/dist/Components/Inputs/InputRenderer.js +39 -31
  60. package/dist/Components/Inputs/NumberBox.js +4 -2
  61. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  62. package/dist/Components/Inputs/PictureInput.js +70 -0
  63. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  64. package/dist/Components/Inputs/RadioGroup.js +8 -3
  65. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  66. package/dist/Components/Inputs/RowsInput.js +18 -3
  67. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  68. package/dist/Components/Inputs/TabularForm.js +29 -29
  69. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  70. package/dist/Components/Inputs/TextArea.js +14 -2
  71. package/dist/Components/Inputs/TextBox.js +4 -1
  72. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  73. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  74. package/dist/Components/Inputs/TimePicker.js +4 -1
  75. package/dist/Components/Inputs/TreeView.d.ts +107 -6
  76. package/dist/Components/Inputs/TreeView.js +513 -30
  77. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  78. package/dist/Components/Layouts/MessageBox.js +56 -7
  79. package/dist/Components/Layouts/Popup.d.ts +13 -6
  80. package/dist/Components/Layouts/Popup.js +37 -9
  81. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  82. package/dist/Components/Layouts/PopupDock.js +2 -1
  83. package/dist/Components/Layouts/PopupHost.js +5 -4
  84. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  85. package/dist/Components/Layouts/ThemePicker.js +16 -4
  86. package/dist/Components/Viewers/DataTable.js +205 -61
  87. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  88. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  89. package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
  90. package/dist/Components/Viewers/DataTableParts.js +62 -21
  91. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  92. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  93. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  94. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  95. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  96. package/dist/Components/Viewers/RecordCards.js +509 -0
  97. package/dist/Helpers/ApiModules.d.ts +3 -34
  98. package/dist/Helpers/ApiModules.js +3 -3
  99. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  100. package/dist/Helpers/ApiRoutes.js +31 -27
  101. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  102. package/dist/Helpers/ArrayHelpers.js +2 -0
  103. package/dist/Helpers/BusyHooks.d.ts +6 -0
  104. package/dist/Helpers/BusyHooks.js +39 -0
  105. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  106. package/dist/Helpers/CodeHelpers.js +332 -0
  107. package/dist/Helpers/CrudBackend.d.ts +114 -6
  108. package/dist/Helpers/CrudBackend.js +214 -11
  109. package/dist/Helpers/CrudColumns.d.ts +98 -0
  110. package/dist/Helpers/CrudColumns.js +160 -0
  111. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  112. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  113. package/dist/Helpers/CrudHelpers.d.ts +130 -6
  114. package/dist/Helpers/CrudHelpers.js +304 -12
  115. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  116. package/dist/Helpers/CrudNarrowPick.js +157 -0
  117. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  118. package/dist/Helpers/CrudRowForms.js +43 -0
  119. package/dist/Helpers/CrudScopes.d.ts +32 -0
  120. package/dist/Helpers/CrudScopes.js +79 -0
  121. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  122. package/dist/Helpers/DropdownHelpers.js +6 -2
  123. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  124. package/dist/Helpers/ExcelHelpers.js +12 -11
  125. package/dist/Helpers/FileAdapter.d.ts +78 -0
  126. package/dist/Helpers/FileAdapter.js +102 -0
  127. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  128. package/dist/Helpers/FilePrimaries.js +138 -0
  129. package/dist/Helpers/FormConfirm.d.ts +32 -0
  130. package/dist/Helpers/FormConfirm.js +40 -0
  131. package/dist/Helpers/FormStore.d.ts +90 -5
  132. package/dist/Helpers/FormStore.js +318 -22
  133. package/dist/Helpers/InputConditions.d.ts +50 -0
  134. package/dist/Helpers/InputConditions.js +168 -0
  135. package/dist/Helpers/InputMemory.d.ts +106 -0
  136. package/dist/Helpers/InputMemory.js +385 -0
  137. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  138. package/dist/Helpers/InputNarrowing.js +67 -0
  139. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  140. package/dist/Helpers/InputParamsHelpers.js +162 -71
  141. package/dist/Helpers/MessageBuilder.js +2 -0
  142. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  143. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  144. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  145. package/dist/Helpers/NexusHelpers.js +1 -0
  146. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  147. package/dist/Helpers/NumberHelpers.js +126 -0
  148. package/dist/Helpers/Permissions.js +2 -1
  149. package/dist/Helpers/QrCode.d.ts +26 -0
  150. package/dist/Helpers/QrCode.js +463 -0
  151. package/dist/Helpers/RecordCards.d.ts +205 -0
  152. package/dist/Helpers/RecordCards.js +378 -0
  153. package/dist/Helpers/RequestCache.d.ts +47 -0
  154. package/dist/Helpers/RequestCache.js +132 -0
  155. package/dist/Helpers/RowsStore.d.ts +37 -2
  156. package/dist/Helpers/RowsStore.js +128 -10
  157. package/dist/Helpers/ServerPreview.d.ts +69 -0
  158. package/dist/Helpers/ServerPreview.js +99 -0
  159. package/dist/Helpers/TableColumns.js +4 -3
  160. package/dist/Helpers/TableExport.d.ts +7 -1
  161. package/dist/Helpers/TableExport.js +21 -15
  162. package/dist/Helpers/TableFilters.d.ts +1 -1
  163. package/dist/Helpers/TableFilters.js +24 -23
  164. package/dist/Helpers/TableHelpers.d.ts +24 -3
  165. package/dist/Helpers/TableHelpers.js +58 -12
  166. package/dist/Helpers/TableJoins.d.ts +83 -0
  167. package/dist/Helpers/TableJoins.js +284 -0
  168. package/dist/Helpers/TableStore.d.ts +26 -1
  169. package/dist/Helpers/TableStore.js +73 -7
  170. package/dist/Helpers/TreeStore.d.ts +306 -11
  171. package/dist/Helpers/TreeStore.js +1048 -90
  172. package/dist/Helpers/UserPreferences.d.ts +8 -0
  173. package/dist/Helpers/UserPreferences.js +17 -0
  174. package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
  175. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  176. package/dist/Interfaces/FileInterfaces.js +4 -0
  177. package/dist/Interfaces/FormInterfaces.d.ts +393 -29
  178. package/dist/Interfaces/FormInterfaces.js +79 -1
  179. package/dist/Interfaces/InputInterfaces.d.ts +39 -3
  180. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  181. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  182. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  183. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  184. package/dist/Interfaces/RecordInterfaces.js +1 -0
  185. package/dist/Interfaces/TableInterfaces.d.ts +160 -5
  186. package/dist/Services/ApiProxy.d.ts +36 -1
  187. package/dist/Services/ApiProxy.js +182 -52
  188. package/dist/Services/ApiProxyRules.d.ts +203 -0
  189. package/dist/Services/ApiProxyRules.js +426 -0
  190. package/dist/Services/ExcelApi.js +5 -0
  191. package/dist/Services/FileApi.d.ts +13 -0
  192. package/dist/Services/FileApi.js +29 -0
  193. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  194. package/dist/Services/ImageEditorLoader.js +51 -0
  195. package/dist/Services/JoinSources.d.ts +80 -0
  196. package/dist/Services/JoinSources.js +150 -0
  197. package/dist/Services/ModuleImport.d.ts +7 -0
  198. package/dist/Services/ModuleImport.js +22 -0
  199. package/dist/Services/ServerApi.d.ts +13 -2
  200. package/dist/Services/ServerApi.js +8 -3
  201. package/dist/Shared.Index.d.ts +20 -0
  202. package/dist/Shared.Index.js +20 -0
  203. package/package.json +92 -85
  204. package/src/Styles/Nexus.Avatar.css +80 -79
  205. package/src/Styles/Nexus.Base.css +117 -117
  206. package/src/Styles/Nexus.Button.css +186 -180
  207. package/src/Styles/Nexus.Calendar.css +273 -273
  208. package/src/Styles/Nexus.Chat.css +1443 -1443
  209. package/src/Styles/Nexus.Choice.css +359 -343
  210. package/src/Styles/Nexus.Crud.css +542 -168
  211. package/src/Styles/Nexus.DateTime.css +81 -81
  212. package/src/Styles/Nexus.Dropdown.css +733 -725
  213. package/src/Styles/Nexus.Editor.css +295 -295
  214. package/src/Styles/Nexus.Excel.css +389 -389
  215. package/src/Styles/Nexus.Files.css +410 -0
  216. package/src/Styles/Nexus.Form.css +282 -279
  217. package/src/Styles/Nexus.Index.css +41 -37
  218. package/src/Styles/Nexus.Input.css +238 -4
  219. package/src/Styles/Nexus.Markdown.css +237 -237
  220. package/src/Styles/Nexus.Menu.css +246 -237
  221. package/src/Styles/Nexus.Popup.css +800 -724
  222. package/src/Styles/Nexus.Print.css +288 -0
  223. package/src/Styles/Nexus.QrCode.css +12 -0
  224. package/src/Styles/Nexus.Record.css +430 -0
  225. package/src/Styles/Nexus.Rows.css +637 -637
  226. package/src/Styles/Nexus.Slider.css +276 -276
  227. package/src/Styles/Nexus.Tab.Buttons.css +497 -432
  228. package/src/Styles/Nexus.Table.css +98 -35
  229. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  230. package/src/Styles/Nexus.Themes.css +200 -194
  231. package/src/Styles/Nexus.Time.css +144 -144
  232. package/src/Styles/Nexus.Toast.css +263 -263
  233. package/src/Styles/Nexus.Tokens.css +94 -89
  234. package/src/Styles/Nexus.Tree.css +530 -431
@@ -1,30 +1,34 @@
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, 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";
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
5
  import { flushSync } from "react-dom";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
8
+ import { canCall, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
8
9
  import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
9
10
  import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
10
11
  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";
12
+ import { joinKeys, joinSourceName, tableJoins } from "../../Helpers/TableJoins.js";
13
+ import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, MIN_COLUMN_WIDTH, orderColumns, releasedPins, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
12
14
  import { TableStore } from "../../Helpers/TableStore.js";
13
15
  import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
14
16
  import { Button } from "../Documents/Button.js";
15
17
  import { Menu } from "../Documents/Menu.js";
16
18
  import { SplitButton } from "../Documents/SplitButton.js";
19
+ import { useReadOnlyNotice } from "../Inputs/ReadOnlyNotice.js";
17
20
  import { TextBox } from "../Inputs/TextBox.js";
18
21
  import { toast } from "../Layouts/Toaster.js";
19
22
  import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
23
+ import { joinSource } from "../../Services/JoinSources.js";
20
24
  import { openExcelImport } from "../Forms/ExcelImport.js";
21
25
  import { TableColumnsPanel } from "./DataTableColumns.js";
22
26
  import { TableFiltersPanel } from "./DataTableFilters.js";
23
- import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
27
+ import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TableRowView, TableSectionHead, TableSkeletonRows, } from "./DataTableParts.js";
24
28
  /*
25
29
  * DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
26
30
  * actions on each row, and columns the user arranges (shown or hidden, in any order, resized, pinned to a side). Rows
27
- * given as `pinnedRows` sit in a section above the page that folds open and shut. Rows are given, loaded once, or
31
+ * given as `sections` (the records pinned, the favourites) sit above the page, each folding open and shut. Rows are given, loaded once, or
28
32
  * loaded a page at a time; what the table shows lives in a `TableStore`, which pages (and the CRUD component) change
29
33
  * from outside.
30
34
  */
@@ -37,6 +41,12 @@ export function useTable() {
37
41
  return store;
38
42
  }
39
43
  const STATE_PREFIX = "nx-table:";
44
+ /** The key of the section `pinnedRows` makes. */
45
+ const PINS_SECTION = "pins";
46
+ /** A version that never changes: the lookups' for a table with no `join` column, the permissions' for one with no sheet. */
47
+ const NO_VERSION = () => 0;
48
+ /** The page size of a table read whole (`pager="none"`): every row on the first page. */
49
+ const WHOLE_PAGE = Number.MAX_SAFE_INTEGER;
40
50
  function readSaved(key) {
41
51
  if (!key || typeof localStorage === "undefined")
42
52
  return null;
@@ -48,7 +58,7 @@ function readSaved(key) {
48
58
  return null;
49
59
  }
50
60
  }
51
- // Settings are written in two places (the query and the columns together, the pinned section on its own click), so a
61
+ // Settings are written in two places (the query and the columns together, a section's fold on its own click), so a
52
62
  // write keeps whatever it does not mention.
53
63
  function writeSaved(key, changes) {
54
64
  try {
@@ -71,6 +81,9 @@ function usePerRow(make) {
71
81
  return value;
72
82
  }, [cache, make]);
73
83
  }
84
+ /** No pinned column scrolls: the table is wide enough for them all. */
85
+ const NONE_RELEASED = new Set();
86
+ const sameKeys = (a, b) => a.size === b.size && [...a].every(key => b.has(key));
74
87
  const sameNumbers = (a, b) => {
75
88
  const keys = Object.keys(a);
76
89
  return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key]);
@@ -93,17 +106,24 @@ const sameNumbers = (a, b) => {
93
106
  * />
94
107
  */
95
108
  export function DataTable(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;
109
+ const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", caption, toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, onPick, 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, currency, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
97
110
  const paging = givenRows ? "client" : (props.paging ?? "client");
111
+ // A server's pages cannot all be shown at once: such a table keeps its paging in the footer instead.
112
+ const pager = props.pager === "none" && paging === "server" ? "footer" : (props.pager ?? "bar");
113
+ const whole = pager === "none";
98
114
  const reloadable = props.reloadable ?? Boolean(load);
99
115
  const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
100
- // A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer.
101
- const numbered = props.numbered ?? selectable;
116
+ // A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer. So
117
+ // does one whose rows are picked, whose first column holds each row's radio button.
118
+ const pickable = Boolean(onPick);
119
+ const numbered = props.numbered ?? (selectable || pickable);
102
120
  // The person's choices in the quick preference panel, once made, go over what the table names itself.
103
121
  const preferences = useUserPreferences();
104
122
  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. */
123
+ /** The page size the table opens with: every row for a table read whole, the person's default when this table offers it, else the table's own. */
106
124
  const openingPageSize = () => {
125
+ if (whole)
126
+ return WHOLE_PAGE;
107
127
  const preferred = readUserPreferences().pageSize;
108
128
  return preferred !== null && pageSizes.includes(preferred) ? preferred : (ownPageSize ?? DEFAULT_PAGE_SIZE);
109
129
  };
@@ -111,18 +131,29 @@ export function DataTable(props) {
111
131
  const table = givenTable ?? own;
112
132
  // Prepared once for the store and the panel: the same params without what would make them filter on their own.
113
133
  const filterInputs = useMemo(() => props.filterInputs?.map(asFilterInput), [props.filterInputs]);
114
- table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, filterInputs });
134
+ table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, onPickChange: onPick, filterInputs });
115
135
  useImperativeHandle(ref, () => table, [table]);
116
136
  const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
137
+ // The columns whose values are other records', looked up by key (`join`). Only a table with one listens to the page's
138
+ // lookups, and it draws again - its rows too, through `cellOptions` - each time names come in.
139
+ const joins = useMemo(() => columns.flatMap(column => (column.join ? [column.join] : [])), [columns]);
140
+ const subscribeJoins = useCallback((listener) => (joins.length ? tableJoins.subscribe(listener) : () => { }), [joins.length]);
141
+ const joinsVersion = useSyncExternalStore(subscribeJoins, joins.length ? tableJoins.getVersion : NO_VERSION, NO_VERSION);
142
+ // A spreadsheet's items ask the app's permissions, as a CRUD's actions do, so only a table with one listens for them.
143
+ const hasSheet = Boolean(excel);
144
+ const subscribeSheet = useCallback((listener) => (hasSheet ? subscribePermissions(listener) : () => { }), [hasSheet]);
145
+ useSyncExternalStore(subscribeSheet, hasSheet ? permissionsVersion : NO_VERSION, NO_VERSION);
117
146
  const dates = useDatePreferences();
118
147
  const timeZone = useBrowserTimeZone();
119
148
  const numbers = useNumberLocale();
120
- const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers]);
149
+ // `joinsVersion` is read by no cell option: a new object is what tells the memoized rows that their names came in.
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers, currency }), [dates, timeZone, numbers, currency, joinsVersion]);
121
152
  const tableId = useId();
122
153
  // The search box is uncontrolled: a new key clears it when a new list starts over.
123
154
  const [searchGeneration, setSearchGeneration] = useState(0);
124
- // The pinned section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
125
- const [pinsOpen, setPinsOpen] = useState(false);
155
+ // Every section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
156
+ const [openSections, setOpenSections] = useState(() => new Set());
126
157
  const searchTimer = useRef(undefined);
127
158
  const searchRef = useRef(null);
128
159
  const started = useRef(false);
@@ -134,15 +165,16 @@ export function DataTable(props) {
134
165
  started.current = true;
135
166
  const saved = readSaved(stateKey);
136
167
  table.restore({
137
- pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
168
+ pageSize: !whole && saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
138
169
  sort: saved?.sort !== undefined ? saved.sort : defaultSort,
139
170
  hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
140
171
  order: saved?.order,
141
172
  widths: saved?.widths,
142
173
  pinned: saved?.pinned,
143
174
  });
144
- if (saved?.pinsOpen)
145
- setPinsOpen(true);
175
+ const open = saved?.openSections ?? (saved?.pinsOpen ? [PINS_SECTION] : []);
176
+ if (open.length)
177
+ setOpenSections(new Set(open));
146
178
  if (givenRows)
147
179
  table.setRows(givenRows);
148
180
  else
@@ -159,6 +191,26 @@ export function DataTable(props) {
159
191
  if (started.current && givenRows)
160
192
  table.setRows(givenRows);
161
193
  }, [table, givenRows]);
194
+ // Another table's settings behind the same table (a CRUD's scope keeps its own): read as on the first draw, before the
195
+ // rows its new list loads, and the settings on screen are not written under the new key (`restoredKey` is what says
196
+ // the store already holds that key's settings).
197
+ const restoredKey = useRef(stateKey);
198
+ useLayoutEffect(() => {
199
+ if (!started.current || restoredKey.current === stateKey)
200
+ return;
201
+ restoredKey.current = stateKey;
202
+ const saved = readSaved(stateKey);
203
+ table.restore({
204
+ pageSize: !whole && saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
205
+ sort: saved?.sort !== undefined ? saved.sort : (defaultSort ?? null),
206
+ hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
207
+ order: saved?.order ?? [],
208
+ widths: saved?.widths ?? {},
209
+ pinned: saved?.pinned ?? {},
210
+ }, { notify: true });
211
+ setOpenSections(new Set(saved?.openSections ?? (saved?.pinsOpen ? [PINS_SECTION] : [])));
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [table, stateKey]);
162
214
  // Another list behind the same table: start over from the first page, with no search and no selection.
163
215
  useLayoutEffect(() => {
164
216
  if (loadedKey.current === loadKey)
@@ -169,20 +221,24 @@ export function DataTable(props) {
169
221
  void table.reload({ reset: true });
170
222
  }, [table, loadKey]);
171
223
  useEffect(() => () => clearTimeout(searchTimer.current), []);
172
- // Saved settings follow the user's choices.
224
+ // Saved settings follow the user's choices. What is written is the store's state now, never this draw's: the first
225
+ // draw, and the draw that took another key's settings, still hold the settings from before the restore, and writing
226
+ // those would put the opening defaults (or another key's settings) under the key.
173
227
  const hiddenList = useMemo(() => [...state.hidden].sort(), [state.hidden]);
174
228
  useEffect(() => {
175
229
  if (!stateKey || !started.current)
176
230
  return;
231
+ const now = table.getState();
177
232
  writeSaved(stateKey, {
178
233
  // 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,
180
- sort: state.query.sort,
181
- hidden: hiddenList,
182
- order: state.order.length ? [...state.order] : undefined,
183
- widths: Object.keys(state.widths).length ? state.widths : undefined,
184
- pinned: Object.keys(state.pinned).length ? state.pinned : undefined,
234
+ pageSize: now.query.pageSize === openingPageSize() ? undefined : now.query.pageSize,
235
+ sort: now.query.sort,
236
+ hidden: [...now.hidden].sort(),
237
+ order: now.order.length ? [...now.order] : undefined,
238
+ widths: Object.keys(now.widths).length ? now.widths : undefined,
239
+ pinned: Object.keys(now.pinned).length ? now.pinned : undefined,
185
240
  });
241
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
242
  }, [stateKey, state.query.pageSize, state.query.sort, hiddenList, state.order, state.widths, state.pinned]);
187
243
  // A new default page size from the panel reaches an open table where no size was picked. Only a change counts: on the
188
244
  // first effects the save above still holds the store's opening size and has not written the restored one yet, so a
@@ -193,11 +249,25 @@ export function DataTable(props) {
193
249
  if (preferredSize.current === preferences.pageSize)
194
250
  return;
195
251
  preferredSize.current = preferences.pageSize;
196
- if (!started.current || readSaved(stateKey)?.pageSize)
252
+ if (whole || !started.current || readSaved(stateKey)?.pageSize)
197
253
  return;
198
254
  table.setPageSize(openingPageSize());
199
255
  // eslint-disable-next-line react-hooks/exhaustive-deps
200
256
  }, [preferences.pageSize]);
257
+ // Every key the rows hold for a join, asked for together: a client table's every row, since it searches and sorts them
258
+ // all, a server table's page, and the rows of the sections above it. What is known already is not asked again.
259
+ useEffect(() => {
260
+ if (!joins.length || state.status !== "ready")
261
+ return;
262
+ const shown = [...table.getRows(), ...(pinnedRows ?? []), ...(ownSections ?? []).flatMap(section => section.rows)];
263
+ for (const [source, keys] of joinKeys(joins, shown))
264
+ tableJoins.request(joinSource(source), keys);
265
+ }, [joins, state.status, state.rows, pinnedRows, ownSections, table]);
266
+ // Names came in: a client table searches and sorts again with them.
267
+ useEffect(() => {
268
+ if (joinsVersion)
269
+ table.refreshValues();
270
+ }, [joinsVersion, table]);
201
271
  const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
202
272
  // Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
203
273
  const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
@@ -205,7 +275,7 @@ export function DataTable(props) {
205
275
  if (rowLabel)
206
276
  return rowLabel(row);
207
277
  const first = columns[0];
208
- const text = first ? formatCellText(first, columnValue(first, row), cellOptions) : "";
278
+ const text = first ? formatCellText(first, columnValue(first, row), cellOptions, row) : "";
209
279
  return text || `row ${table.keyOf(row)}`;
210
280
  }, [rowLabel, columns, cellOptions, table]);
211
281
  const actionsOf = usePerRow(rowActions);
@@ -232,6 +302,10 @@ export function DataTable(props) {
232
302
  void result.finally(() => table.setBusy([key], false)).catch(() => undefined);
233
303
  }
234
304
  }, [table]);
305
+ // The picked row (`onPick`): a click on a row, or its radio button, picks it through the store, which follows it.
306
+ const pickRow = useCallback((row) => table.pick(row), [table]);
307
+ const pickName = `${tableId}pick`;
308
+ const pickedKey = state.picked ? table.keyOf(state.picked) : null;
235
309
  const onSearch = (text) => {
236
310
  clearTimeout(searchTimer.current);
237
311
  if (searchDelay > 0 && text.trim())
@@ -252,9 +326,11 @@ export function DataTable(props) {
252
326
  const headRef = useRef(null);
253
327
  const colRefs = useMemo(() => new Map(), []);
254
328
  const [offsets, setOffsets] = useState({});
329
+ // Pinned columns that scroll for now: on a narrow table the ones past `PINNED_SHARE` of its width give way.
330
+ const [released, setReleased] = useState(NONE_RELEASED);
255
331
  const sized = Object.keys(state.widths).length > 0;
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]);
257
- const indexOn = selectable || numbered;
332
+ const views = useMemo(() => viewColumns(visible, { order: state.order, pinned: state.pinned, widths: state.widths, offsets, released, reorderable, resizable }), [visible, state.order, state.pinned, state.widths, offsets, released, reorderable, resizable]);
333
+ const indexOn = selectable || numbered || pickable;
258
334
  // The bands above the headings. With no group anywhere the header is the one row it always was.
259
335
  const bands = useMemo(() => headBands(views), [views]);
260
336
  const grouped = bands.some(band => band.group !== null);
@@ -299,8 +375,36 @@ export function DataTable(props) {
299
375
  // The bar of the selected rows starts after the checkbox column, whatever width it took.
300
376
  root?.style.setProperty("--nx-table-index-w", `${Math.round(left)}px`);
301
377
  setOffsets(previous => (sameNumbers(previous, next) ? previous : next));
378
+ settlePins.current();
302
379
  });
380
+ // Which pinned columns give way: measured from the headings after every render and whenever the table's width
381
+ // changes. Staying or scrolling changes no width, so it settles at once.
382
+ const settlePins = useRef(() => undefined);
383
+ settlePins.current = () => {
384
+ const scroller = scrollRef.current;
385
+ const cells = headLeafCells(headRef.current);
386
+ if (!scroller || cells.length === 0)
387
+ return;
388
+ const sides = new Map(views.map(view => [view.column.key, view.side]));
389
+ let frameWidth = 0;
390
+ const left = [];
391
+ const right = [];
392
+ for (const cell of cells) {
393
+ const key = cell.getAttribute("data-col");
394
+ const width = cell.getBoundingClientRect().width;
395
+ if (key === TABLE_INDEX_COLUMN || key === TABLE_ACTIONS_COLUMN)
396
+ frameWidth += width;
397
+ else if (key && sides.get(key) === "left")
398
+ left.push([key, width]);
399
+ else if (key && sides.get(key) === "right")
400
+ right.unshift([key, width]);
401
+ }
402
+ const next = left.length + right.length === 0 ? NONE_RELEASED : releasedPins(scroller.clientWidth, frameWidth, left, right);
403
+ setReleased(previous => (sameKeys(previous, next) ? previous : next));
404
+ };
303
405
  // Shadows at the pinned columns while the others scroll under them: set on the frame, with no render.
406
+ // The width the rows can show goes on the frame too, so a message across a wide table (no rows, a hint, a failed
407
+ // load) stays inside what is on screen instead of centring on the whole scroll width.
304
408
  useLayoutEffect(() => {
305
409
  const scroller = scrollRef.current;
306
410
  const frameElement = scroller?.parentElement;
@@ -311,9 +415,14 @@ export function DataTable(props) {
311
415
  frameElement.toggleAttribute("data-scroll-start", left > 1);
312
416
  frameElement.toggleAttribute("data-scroll-end", left < scroller.scrollWidth - scroller.clientWidth - 1);
313
417
  };
314
- update();
418
+ const measure = () => {
419
+ frameElement.style.setProperty("--nx-table-view-w", `${scroller.clientWidth}px`);
420
+ update();
421
+ settlePins.current();
422
+ };
423
+ measure();
315
424
  scroller.addEventListener("scroll", update, { passive: true });
316
- const observer = new ResizeObserver(update);
425
+ const observer = new ResizeObserver(measure);
317
426
  observer.observe(scroller);
318
427
  if (scroller.firstElementChild)
319
428
  observer.observe(scroller.firstElementChild);
@@ -439,6 +548,10 @@ export function DataTable(props) {
439
548
  const barRef = useRef(null);
440
549
  const reloadRef = useRef(null);
441
550
  const [reloadHidden, setReloadHidden] = useState(false);
551
+ // An Add that cannot add yet (`add.disabled` with a `description`) says why on a press, under it.
552
+ const addNotice = useReadOnlyNotice(Boolean(add?.disabled && add.description), barRef, add?.description, { anchor: target => target.closest(".nx-btn"), typing: false, lock: "label" });
553
+ // The spreadsheet's other ways to add sit under the Add's arrow only while it can add.
554
+ const addable = Boolean(add && !add.disabled);
442
555
  const addPart = Boolean(add || actions);
443
556
  const listsPart = Boolean(toolbar);
444
557
  useLayoutEffect(() => {
@@ -463,28 +576,44 @@ export function DataTable(props) {
463
576
  cancelAnimationFrame(frame);
464
577
  };
465
578
  }, [addPart, listsPart, searchable, reloadable]);
579
+ // A person's Reload is a refresh of what the table shows, the names it looked up included: those are marked old - still
580
+ // drawn, asked again with the rows - and nothing else the page keeps is touched.
466
581
  const reload = () => {
582
+ for (const join of joins)
583
+ tableJoins.expire(joinSourceName(join.source));
467
584
  onReload?.();
468
585
  void table.reload({ quiet: true });
469
586
  };
470
587
  // 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.
588
+ // screen. With an Add, they are other ways to add, under its arrow; without one, they are in the options menu. Each is
589
+ // offered only to a person the app's permissions let call every endpoint it needs, the settings included.
590
+ const sheet = excel?.endpoints;
591
+ const mayUseSheet = (...endpoints) => [sheet.settings, ...endpoints].every(endpoint => canCall(endpoint));
472
592
  const sheetItems = excel
473
593
  ? [
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
594
+ ...(mayUseSheet(sheet.template) ? [{ id: "excel-template", label: "Download template", description: `A spreadsheet to fill in`, icon: _jsx(InlineIcon, { icon: iconDownload }), onSelect: () => void downloadExcelTemplate(excel.endpoints, { params: excel.params }) }] : []),
595
+ ...(mayUseSheet(sheet.verify, sheet.import)
596
+ ? [
597
+ { 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 }) },
598
+ // A bulk insert is an Add of many rows, so it is offered only beside an Add that can add: a CRUD shows its Add
599
+ // only to a person who may create, and nobody types fifty rows to be refused at the end.
600
+ ...(excel.bulk === false || !addable
601
+ ? []
602
+ : [{ 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" }) }]),
603
+ ]
604
+ : []),
605
+ ...(sheet.export && mayUseSheet(sheet.export)
482
606
  ? [{ 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
607
  : []),
484
- ]
608
+ ].map((item, index) => (index === 0 ? { ...item, separator: true } : item))
485
609
  : [];
486
610
  // 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 }));
611
+ const addButton = (block = false) => add && (_jsx(Button, { variant: "primary", size: "sm", block: block, icon: _jsx(InlineIcon, { icon: iconPlus }), "aria-disabled": add.disabled || undefined, title: add.description, onKeyDown: addNotice.onKeyDown, onClick: event => {
612
+ if (!add.disabled)
613
+ add.onClick();
614
+ else if (!add.onDisabledPress?.())
615
+ addNotice.show(event.currentTarget);
616
+ }, children: add.label }));
488
617
  const optionItems = [
489
618
  ...(filterInputs?.length
490
619
  ? [{ id: "filters", label: "Filters", description: filtersOn.join(", ") || undefined, icon: _jsx(InlineIcon, { icon: iconFilter }), onSelect: () => filtersRef.current?.open() }]
@@ -499,7 +628,7 @@ export function DataTable(props) {
499
628
  : []),
500
629
  // The sheet's own Import replaces the page's `onImport` hook.
501
630
  ...(excel
502
- ? add
631
+ ? addable
503
632
  ? []
504
633
  : sheetItems
505
634
  : onImport
@@ -513,7 +642,9 @@ export function DataTable(props) {
513
642
  ...(reloadable && reloadHidden ? [{ id: "reload", label: "Reload", icon: _jsx(InlineIcon, { icon: iconRefresh }), separator: true, onSelect: reload }] : []),
514
643
  ];
515
644
  const optionEntries = menuEntries(optionItems);
516
- const pageSizePicker = _jsx(TablePageSize, { pageSize: query.pageSize, pageSizes: pageSizes, compact: searchable, onChange: value => table.setPageSize(value) });
645
+ // Rows per page: inside the search box, in the bar without one, or beside the pager (`pager`); none for a table read whole.
646
+ const pageSizePicker = whole ? null : _jsx(TablePageSize, { pageSize: query.pageSize, pageSizes: pageSizes, compact: searchable && pager === "bar", onChange: value => table.setPageSize(value) });
647
+ const barPageSize = pager === "bar" ? pageSizePicker : null;
517
648
  let body;
518
649
  if (status === "loading") {
519
650
  body = _jsx(TableSkeletonRows, { count: Math.min(query.pageSize, 8), cells: cellCount, frame: frame, sized: sized });
@@ -544,24 +675,33 @@ export function DataTable(props) {
544
675
  body = rows.map((row, index) => {
545
676
  const key = pageKeys[index];
546
677
  const selected = state.selected.has(key);
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));
678
+ 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, onPick: pickable ? pickRow : undefined, picked: key === pickedKey, pickName: pickable ? pickName : undefined }, key || index));
548
679
  });
549
680
  }
550
- /* The pinned section above the page: the records the user pinned, folded open or shut, remembered on this device */
681
+ /* The sections above the page: the records pinned, the favourites, each folded open or shut, remembered on this device */
682
+ const pinsSection = pinnedRows ? { key: PINS_SECTION, label: pinnedLabel, icon: iconPin, rows: pinnedRows, loading: pinnedLoading, max: pinnedMax } : null;
551
683
  // A section with nothing in it and nothing on its way would be an empty heading, so it does not show at all.
552
- const pins = pinnedRows && (pinnedRows.length > 0 || pinnedLoading) ? pinnedRows : null;
553
- const togglePins = () => {
554
- const next = !pinsOpen;
555
- setPinsOpen(next);
684
+ const sections = [...(pinsSection ? [pinsSection] : []), ...(ownSections ?? [])].filter(section => section.rows.length > 0 || section.loading);
685
+ const toggleSection = (key) => {
686
+ const next = new Set(openSections);
687
+ if (!next.delete(key))
688
+ next.add(key);
689
+ setOpenSections(next);
556
690
  if (stateKey)
557
- writeSaved(stateKey, { pinsOpen: next });
691
+ writeSaved(stateKey, { openSections: [...next], pinsOpen: undefined });
558
692
  };
559
- const pinnedBody = pinsOpen &&
560
- pins?.map((row, index) => {
693
+ const sectionRows = (section) => {
694
+ // A row with no marks of its own still wears the section's icon.
695
+ const own = [{ icon: section.icon, label: section.label }];
696
+ return section.rows.map((row, index) => {
561
697
  const key = table.keyOf(row);
562
698
  const selected = state.selected.has(key);
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}`));
699
+ const marks = rowMarks?.(row);
700
+ 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,
701
+ // A click picks a section's row too; its radio button is the one in the list below, where the arrow keys move.
702
+ onPick: pickable ? pickRow : undefined, picked: key === pickedKey }, key || index));
564
703
  });
704
+ };
565
705
  const selectedRows = selecting ? [...state.selected.values()] : [];
566
706
  // Select again by what the rows are: exactly the rows on the page that pass, or the whole page.
567
707
  const pick = (label, test) => {
@@ -580,8 +720,8 @@ export function DataTable(props) {
580
720
  const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
581
721
  // 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
722
  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 => {
723
+ 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: [caption && _jsx("div", { className: "nx-table__caption", children: caption }), _jsxs("div", { ref: barRef, className: "nx-table__bar", children: [addPart && (_jsxs("div", { className: "nx-table__bar-add", children: [add &&
724
+ (addable && 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 && barPageSize] }), searchable && (_jsx(TextBox, { ref: searchRef, type: "search", size: "sm", className: "nx-table__search", actionsStart: barPageSize, icon: _jsx(InlineIcon, { icon: iconSearch }), placeholder: searchPlaceholder, "aria-label": searchPlaceholder.replace(/…$/, ""), "aria-controls": tableId, defaultValue: query.search, clearable: true, onValueChange: onSearch, onKeyDown: event => {
585
725
  if (event.key === "Enter") {
586
726
  clearTimeout(searchTimer.current);
587
727
  table.setSearch(event.currentTarget.value);
@@ -590,13 +730,16 @@ export function DataTable(props) {
590
730
  lastToggled.current = null;
591
731
  if (table.selectPage(event.currentTarget.checked) > 0)
592
732
  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) })] }));
733
+ } }) })) })), 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 => {
734
+ const open = openSections.has(section.key);
735
+ 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));
736
+ }), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, whole: whole, pageSize: pager === "footer" ? pageSizePicker : null, onPage: page => table.setPage(page) }), addNotice.notice] }));
594
737
  }
595
738
  /** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
596
739
  function moveTargets(views, key) {
597
740
  // A column moves among the columns on its own side, past the ones that stay put.
598
- const pin = views.find(view => view.column.key === key)?.pin ?? null;
599
- const group = views.filter(view => view.pin === pin && view.movable);
741
+ const side = views.find(view => view.column.key === key)?.side ?? null;
742
+ const group = views.filter(view => view.side === side && view.movable);
600
743
  const at = group.findIndex(view => view.column.key === key);
601
744
  if (at < 0)
602
745
  return { left: undefined, right: undefined };
@@ -637,11 +780,12 @@ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, can
637
780
  if (canPin) {
638
781
  if (entries.length)
639
782
  entries.push({ separator: true });
640
- if (view.pin !== "left")
783
+ // The side chosen, also while a narrow table lets it scroll.
784
+ if (view.side !== "left")
641
785
  entries.push({ value: "pin-left", label: "Pin left", icon: _jsx(InlineIcon, { icon: iconArrowBarToLeft }) });
642
- if (view.pin !== "right")
786
+ if (view.side !== "right")
643
787
  entries.push({ value: "pin-right", label: "Pin right", icon: _jsx(InlineIcon, { icon: iconArrowBarToRight }) });
644
- if (view.pin)
788
+ if (view.side)
645
789
  entries.push({ value: "unpin", label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) });
646
790
  }
647
791
  if (view.movable && (neighbours.left !== undefined || neighbours.right !== undefined)) {
@@ -679,7 +823,7 @@ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, can
679
823
  onHide();
680
824
  }
681
825
  const label = _jsx("span", { className: "nx-table__heading-label", children: column.label });
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 }))] }));
826
+ 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-side": view.side ?? 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
827
  }
684
828
  /** The steps a bar is squeezed into when its parts do not fit, each one keeping the steps before it. */
685
829
  const BAR_SQUEEZES = ["search", "search reload", "search reload add"];
@@ -1,6 +1,6 @@
1
1
  import { type Ref, type RefObject } from "react";
2
2
  import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
3
- import type { InputParams } from "../../Interfaces/FormInterfaces.ts";
3
+ import { type InputParams } from "../../Interfaces/FormInterfaces.ts";
4
4
  import type { TableFilters } from "../../Interfaces/TableInterfaces.ts";
5
5
  export interface TableFiltersPanelProps {
6
6
  /** The filter inputs, as the table prepared them (`asFilterInput`). */
@@ -4,6 +4,7 @@ import { iconFilterOff, InlineIcon } from "nexus-icons";
4
4
  import { useEffect, useId, useRef } from "react";
5
5
  import { usePopoverPanel } from "../../Helpers/PopoverHooks.js";
6
6
  import { activeFilters } from "../../Helpers/TableFilters.js";
7
+ import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
7
8
  import { Button } from "../Documents/Button.js";
8
9
  import { Form, useForm } from "../Forms/Form.js";
9
10
  import { formatCount } from "./DataTableParts.js";
@@ -14,7 +15,21 @@ import { formatCount } from "./DataTableParts.js";
14
15
  * never clears a filter on its way out.
15
16
  */
16
17
  /** Inputs typed into or dragged: their value waits until the person stops. A date box reads a date from part of one. */
17
- const TYPED = new Set(["text", "email", "search", "tel", "url", "textarea", "number", "slider", "range-slider", "date", "date-range", "time", "date-time"]);
18
+ const TYPED = new Set([
19
+ EInputTypes.Text,
20
+ EInputTypes.Email,
21
+ EInputTypes.Search,
22
+ EInputTypes.Tel,
23
+ EInputTypes.Url,
24
+ EInputTypes.TextArea,
25
+ EInputTypes.Number,
26
+ EInputTypes.Slider,
27
+ EInputTypes.RangeSlider,
28
+ EInputTypes.Date,
29
+ EInputTypes.DateRange,
30
+ EInputTypes.Time,
31
+ EInputTypes.DateTime,
32
+ ]);
18
33
  /** The Filters panel, opened through its `open()` handle: a `Form` of the filter inputs, the count, and Clear all. */
19
34
  export function TableFiltersPanel({ inputs, value, delay, matches, total, onChange, anchor, ref }) {
20
35
  const titleId = useId();
@@ -57,7 +72,7 @@ export function TableFiltersPanel({ inputs, value, delay, matches, total, onChan
57
72
  }, children: [_jsxs("div", { className: "nx-table-panel__head", children: [_jsx("span", { className: "nx-table-panel__title", id: titleId, children: "Filters" }), _jsx("span", { className: "nx-table-panel__count", children: count ? `${count} on` : "None" })] }), _jsx("div", { className: "nx-popover__scroll", children: shown && (_jsx(Form, { form: form, inputs: inputs, size: "sm", defaultValue: value, className: "nx-table-filters__form", onValueChange: (_, name) => {
58
73
  if (name === null)
59
74
  return;
60
- if (delay > 0 && TYPED.has(inputs.find(params => params.name === name)?.type ?? ""))
75
+ if (delay > 0 && TYPED.has(inputs.find(params => params.name === name)?.type ?? 0))
61
76
  wait();
62
77
  else
63
78
  apply();