nexus-shared 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +1564 -0
  2. package/README.md +1705 -1123
  3. package/dist/Client.Index.d.ts +14 -0
  4. package/dist/Client.Index.js +14 -0
  5. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  6. package/dist/Components/Documents/DocumentFrame.js +83 -0
  7. package/dist/Components/Documents/Menu.d.ts +4 -0
  8. package/dist/Components/Documents/Menu.js +40 -24
  9. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  10. package/dist/Components/Documents/PrintDocument.js +62 -0
  11. package/dist/Components/Documents/Tabs.d.ts +59 -0
  12. package/dist/Components/Documents/Tabs.js +70 -0
  13. package/dist/Components/Forms/ApiForm.js +19 -2
  14. package/dist/Components/Forms/Crud.d.ts +2 -2
  15. package/dist/Components/Forms/Crud.js +576 -243
  16. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  17. package/dist/Components/Forms/CrudActions.js +184 -0
  18. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  19. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  21. package/dist/Components/Forms/CrudParts.js +55 -5
  22. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  23. package/dist/Components/Forms/CrudRowForms.js +114 -0
  24. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  25. package/dist/Components/Forms/CrudScopes.js +76 -0
  26. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  27. package/dist/Components/Forms/CrudTree.js +363 -0
  28. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  29. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  30. package/dist/Components/Forms/ExcelImport.js +3 -3
  31. package/dist/Components/Forms/Form.d.ts +4 -4
  32. package/dist/Components/Forms/Form.js +123 -17
  33. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  34. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  35. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  36. package/dist/Components/Forms/RecordFlows.js +282 -0
  37. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  38. package/dist/Components/Forms/SubmitForm.js +195 -10
  39. package/dist/Components/Inputs/CheckBox.js +1 -1
  40. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  41. package/dist/Components/Inputs/ChoiceField.js +28 -2
  42. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  43. package/dist/Components/Inputs/Dropdown.js +5 -5
  44. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  45. package/dist/Components/Inputs/DropdownParts.js +16 -4
  46. package/dist/Components/Inputs/Field.d.ts +7 -1
  47. package/dist/Components/Inputs/Field.js +2 -2
  48. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  49. package/dist/Components/Inputs/GroupForm.js +10 -11
  50. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  51. package/dist/Components/Inputs/InputRenderer.js +37 -32
  52. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  53. package/dist/Components/Inputs/PictureInput.js +70 -0
  54. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  55. package/dist/Components/Inputs/RadioGroup.js +8 -3
  56. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  57. package/dist/Components/Inputs/RowsInput.js +18 -3
  58. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  59. package/dist/Components/Inputs/TabularForm.js +29 -29
  60. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  61. package/dist/Components/Inputs/TextArea.js +11 -2
  62. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  63. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  64. package/dist/Components/Inputs/TreeView.d.ts +53 -2
  65. package/dist/Components/Inputs/TreeView.js +147 -13
  66. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  67. package/dist/Components/Layouts/MessageBox.js +56 -7
  68. package/dist/Components/Layouts/Popup.d.ts +13 -6
  69. package/dist/Components/Layouts/Popup.js +37 -9
  70. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  71. package/dist/Components/Layouts/PopupDock.js +2 -1
  72. package/dist/Components/Layouts/PopupHost.js +5 -4
  73. package/dist/Components/Viewers/DataTable.js +150 -48
  74. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  75. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  76. package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
  77. package/dist/Components/Viewers/DataTableParts.js +26 -14
  78. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  79. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  80. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  81. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  82. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  83. package/dist/Components/Viewers/RecordCards.js +509 -0
  84. package/dist/Helpers/BusyHooks.d.ts +6 -0
  85. package/dist/Helpers/BusyHooks.js +39 -0
  86. package/dist/Helpers/CrudBackend.d.ts +20 -2
  87. package/dist/Helpers/CrudBackend.js +46 -2
  88. package/dist/Helpers/CrudColumns.d.ts +53 -6
  89. package/dist/Helpers/CrudColumns.js +98 -7
  90. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  91. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  92. package/dist/Helpers/CrudHelpers.d.ts +81 -2
  93. package/dist/Helpers/CrudHelpers.js +195 -0
  94. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  95. package/dist/Helpers/CrudNarrowPick.js +157 -0
  96. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  97. package/dist/Helpers/CrudRowForms.js +43 -0
  98. package/dist/Helpers/CrudScopes.d.ts +32 -0
  99. package/dist/Helpers/CrudScopes.js +79 -0
  100. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  101. package/dist/Helpers/DropdownHelpers.js +6 -2
  102. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  103. package/dist/Helpers/ExcelHelpers.js +12 -11
  104. package/dist/Helpers/FileAdapter.d.ts +78 -0
  105. package/dist/Helpers/FileAdapter.js +102 -0
  106. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  107. package/dist/Helpers/FilePrimaries.js +138 -0
  108. package/dist/Helpers/FormConfirm.d.ts +32 -0
  109. package/dist/Helpers/FormConfirm.js +40 -0
  110. package/dist/Helpers/FormStore.d.ts +90 -5
  111. package/dist/Helpers/FormStore.js +318 -22
  112. package/dist/Helpers/InputConditions.d.ts +50 -0
  113. package/dist/Helpers/InputConditions.js +168 -0
  114. package/dist/Helpers/InputMemory.d.ts +106 -0
  115. package/dist/Helpers/InputMemory.js +385 -0
  116. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  117. package/dist/Helpers/InputNarrowing.js +67 -0
  118. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  119. package/dist/Helpers/InputParamsHelpers.js +157 -74
  120. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  121. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  122. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  123. package/dist/Helpers/NumberHelpers.js +126 -0
  124. package/dist/Helpers/Permissions.js +2 -1
  125. package/dist/Helpers/QrCode.d.ts +26 -0
  126. package/dist/Helpers/QrCode.js +463 -0
  127. package/dist/Helpers/RecordCards.d.ts +205 -0
  128. package/dist/Helpers/RecordCards.js +378 -0
  129. package/dist/Helpers/RequestCache.d.ts +14 -2
  130. package/dist/Helpers/RequestCache.js +22 -3
  131. package/dist/Helpers/RowsStore.d.ts +37 -2
  132. package/dist/Helpers/RowsStore.js +128 -10
  133. package/dist/Helpers/ServerPreview.d.ts +69 -0
  134. package/dist/Helpers/ServerPreview.js +99 -0
  135. package/dist/Helpers/TableColumns.js +4 -3
  136. package/dist/Helpers/TableExport.d.ts +7 -1
  137. package/dist/Helpers/TableExport.js +9 -3
  138. package/dist/Helpers/TableFilters.d.ts +1 -1
  139. package/dist/Helpers/TableFilters.js +24 -23
  140. package/dist/Helpers/TableHelpers.d.ts +23 -2
  141. package/dist/Helpers/TableHelpers.js +51 -10
  142. package/dist/Helpers/TableJoins.d.ts +13 -0
  143. package/dist/Helpers/TableJoins.js +34 -0
  144. package/dist/Helpers/TableStore.d.ts +21 -1
  145. package/dist/Helpers/TableStore.js +64 -7
  146. package/dist/Helpers/TreeStore.d.ts +81 -0
  147. package/dist/Helpers/TreeStore.js +365 -0
  148. package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
  149. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  150. package/dist/Interfaces/FileInterfaces.js +4 -0
  151. package/dist/Interfaces/FormInterfaces.d.ts +391 -30
  152. package/dist/Interfaces/FormInterfaces.js +79 -1
  153. package/dist/Interfaces/InputInterfaces.d.ts +15 -3
  154. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  155. package/dist/Interfaces/RecordInterfaces.js +1 -0
  156. package/dist/Interfaces/TableInterfaces.d.ts +74 -1
  157. package/dist/Services/ExcelApi.js +5 -0
  158. package/dist/Services/FileApi.d.ts +13 -0
  159. package/dist/Services/FileApi.js +29 -0
  160. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  161. package/dist/Services/ImageEditorLoader.js +51 -0
  162. package/dist/Services/JoinSources.d.ts +37 -5
  163. package/dist/Services/JoinSources.js +90 -14
  164. package/dist/Services/ModuleImport.d.ts +7 -0
  165. package/dist/Services/ModuleImport.js +22 -0
  166. package/dist/Shared.Index.d.ts +16 -0
  167. package/dist/Shared.Index.js +16 -0
  168. package/package.json +7 -2
  169. package/src/Styles/Nexus.Avatar.css +6 -5
  170. package/src/Styles/Nexus.Button.css +10 -4
  171. package/src/Styles/Nexus.Choice.css +16 -0
  172. package/src/Styles/Nexus.Crud.css +383 -9
  173. package/src/Styles/Nexus.Dropdown.css +8 -0
  174. package/src/Styles/Nexus.Files.css +410 -0
  175. package/src/Styles/Nexus.Form.css +5 -2
  176. package/src/Styles/Nexus.Index.css +4 -0
  177. package/src/Styles/Nexus.Input.css +42 -0
  178. package/src/Styles/Nexus.Menu.css +10 -1
  179. package/src/Styles/Nexus.Popup.css +77 -1
  180. package/src/Styles/Nexus.Print.css +288 -0
  181. package/src/Styles/Nexus.QrCode.css +12 -0
  182. package/src/Styles/Nexus.Record.css +430 -0
  183. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  184. package/src/Styles/Nexus.Table.css +67 -5
  185. package/src/Styles/Nexus.Themes.css +12 -6
  186. package/src/Styles/Nexus.Tokens.css +8 -3
  187. package/src/Styles/Nexus.Tree.css +31 -1
@@ -17,17 +17,20 @@ function popupPosition(dialog) {
17
17
  }
18
18
  // Each popup's opener and the popup that held it, so focus can find its way back through popups that have gone.
19
19
  const openers = new WeakMap();
20
+ // How each popup puts the focus inside itself (its `initialFocus`), for a popup closed over it that left the focus nowhere.
21
+ const homes = new WeakMap();
20
22
  /**
21
- * A modal window on the native <dialog>, with the buttons of a desktop window: minimize, maximize, and close. It sits
22
- * in the top layer above everything, dims and locks the page behind it, traps Tab, closes with Esc or a click outside,
23
- * and returns focus to where it came from. The title bar moves it; a double-click there maximizes it. Minimized, it
23
+ * A modal window on the native <dialog>, with the buttons of a desktop window: maximize and close, and minimize when
24
+ * it asks (`minimizable`: a CRUD page's Add and Edit). It sits in the top layer above everything, dims and locks the
25
+ * page behind it, traps Tab, closes with Esc or a click outside, and returns focus to where it came from. The title
26
+ * bar moves it; a double-click there maximizes it. Minimized, it
24
27
  * waits as a bar at the bottom right with its content kept, while the page can be used. The body scrolls while the
25
28
  * title and the footer stay. The content mounts while the popup is open, so a form inside starts fresh every time.
26
29
  *
27
30
  * @example
28
31
  * <Popup open={open} onClose={() => setOpen(false)} title="Edit booking" footer={<Button variant="primary">Save</Button>}>…</Popup>
29
32
  */
30
- export function Popup({ open, onClose, onClosed, title, description, icon, variant = "window", size = "md", footer, closable = true, closeOnEscape = true, closeOnBackdrop = true, minimizable, maximizable = true, movable = true, reopen = "replace", windowState: windowStateProp, defaultWindowState = "normal", onWindowStateChange, flush = false, initialFocus, "aria-label": ariaLabel, className, children, }) {
33
+ export function Popup({ open, onClose, onClosed, title, description, icon, variant = "window", size = "md", footer, closable = true, closeOnEscape = true, closeOnBackdrop = true, minimizable, maximizable = true, movable = true, reopen = "replace", windowState: windowStateProp, defaultWindowState = "normal", onWindowStateChange, flush = false, initialFocus, "aria-label": ariaLabel, "aria-describedby": ariaDescribedBy, className, children, }) {
31
34
  const id = useId();
32
35
  const dialogRef = useRef(null);
33
36
  // Content stays mounted through the exit animation, and while minimized.
@@ -41,7 +44,8 @@ export function Popup({ open, onClose, onClosed, title, description, icon, varia
41
44
  const maximized = windowState === "maximized";
42
45
  const dockReady = useDockReady();
43
46
  const hosted = useContext(HostedPopupContext);
44
- const canMinimize = (minimizable ?? closable) && dockReady;
47
+ // Only a popup that asks: a CRUD page's Add and Edit, and its row forms.
48
+ const canMinimize = minimizable === true && dockReady;
45
49
  const downOnBackdropRef = useRef(false);
46
50
  // The element that opened the popup, where focus goes back when the browser has nowhere better.
47
51
  const openerRef = useRef(null);
@@ -65,19 +69,22 @@ export function Popup({ open, onClose, onClosed, title, description, icon, varia
65
69
  onWindowStateChange?.(next);
66
70
  }
67
71
  // The latest props and callbacks, for the dock bar and the drag, which outlive a render.
68
- const latest = useRef({ windowState, defaultWindowState, reopen, title, icon, closable, ariaLabel, onClose, changeWindowState });
72
+ const latest = useRef({ windowState, defaultWindowState, reopen, title, description, icon, closable, ariaLabel, onClose, changeWindowState });
69
73
  useLayoutEffect(() => {
70
- latest.current = { windowState, defaultWindowState, reopen, title, icon, closable, ariaLabel, onClose, changeWindowState };
74
+ latest.current = { windowState, defaultWindowState, reopen, title, description, icon, closable, ariaLabel, onClose, changeWindowState };
71
75
  if (!minimized)
72
76
  restoreToRef.current = windowState;
73
77
  });
74
78
  function dock() {
75
79
  const current = latest.current;
76
80
  dockedRef.current = true;
81
+ const label = typeof current.title === "string" ? current.title : current.ariaLabel;
77
82
  dockWindow({
78
83
  id: dockId,
79
84
  title: current.title ?? current.ariaLabel ?? "Window",
80
- label: typeof current.title === "string" ? current.title : current.ariaLabel,
85
+ // Under the title, so two bars of one kind (two Edit forms, two Adds) read apart: the record, the place.
86
+ description: current.description,
87
+ label: label && typeof current.description === "string" ? `${label} · ${current.description}` : label,
81
88
  icon: current.icon,
82
89
  restore: () => latest.current.changeWindowState(restoreToRef.current),
83
90
  close: current.closable ? () => latest.current.onClose("close") : undefined,
@@ -102,11 +109,26 @@ export function Popup({ open, onClose, onClosed, title, description, icon, varia
102
109
  link = openers.get(link.within);
103
110
  if (link?.opener?.isConnected)
104
111
  link.opener.focus({ preventScroll: true });
112
+ // An opener that cannot take the focus - hidden in a menu that closed, disabled while its action runs - or none at
113
+ // all never leaves it on the page while another popup is still open: the popup now on top takes it, where it puts
114
+ // it on opening (a record's workspace: its chosen tab).
115
+ const now = document.activeElement;
116
+ if (now && now !== document.body && !dialog.contains(now))
117
+ return;
118
+ const below = Array.from(document.querySelectorAll("dialog.nx-popup")).filter(other => other !== dialog && other.open);
119
+ const top = below[below.length - 1];
120
+ if (top)
121
+ (homes.get(top) ?? (() => top.focus()))();
105
122
  }
106
123
  function focusInside(dialog) {
107
124
  const target = initialFocus ? dialog.querySelector(initialFocus) : dialog.querySelector(`:is(.nx-popup__body, .nx-popup__foot) :is(${FOCUSABLE})`);
108
125
  (target ?? dialog).focus();
109
126
  }
127
+ useLayoutEffect(() => {
128
+ const dialog = dialogRef.current;
129
+ if (dialog)
130
+ homes.set(dialog, () => focusInside(dialog));
131
+ });
110
132
  function showDialog(dialog) {
111
133
  // The first modal locks the page; the space of its scrollbar is kept as padding, so nothing shifts.
112
134
  if (!document.querySelector(".nx-popup:modal"))
@@ -317,6 +339,12 @@ export function Popup({ open, onClose, onClosed, title, description, icon, varia
317
339
  function onPointerDown(event) {
318
340
  downOnBackdropRef.current = event.target === event.currentTarget;
319
341
  }
342
+ // A press outside a popup that stays open through it (`closeOnBackdrop: false`) leaves the focus where it was, as
343
+ // the press does nothing: a question's Confirm keeps it, so Enter still answers, and a field keeps its caret.
344
+ function onMouseDown(event) {
345
+ if (event.target === event.currentTarget && !closeOnBackdrop)
346
+ event.preventDefault();
347
+ }
320
348
  function onClick(event) {
321
349
  const onBackdrop = event.target === event.currentTarget && downOnBackdropRef.current;
322
350
  downOnBackdropRef.current = false;
@@ -369,5 +397,5 @@ export function Popup({ open, onClose, onClosed, title, description, icon, varia
369
397
  const titleId = `${id}-title`;
370
398
  const descriptionId = `${id}-description`;
371
399
  const hasControls = canMinimize || maximizable || closable;
372
- return (_jsx("dialog", { ref: dialogRef, className: cx("nx-popup", `nx-popup--${variant}`, `nx-popup--${size}`, flush && "nx-popup--flush", maximized && "nx-popup--maximized", movable && "nx-popup--movable", closing && "nx-popup--closing", className), "data-window-state": windowState, "aria-labelledby": title ? titleId : undefined, "aria-label": title ? undefined : ariaLabel, "aria-describedby": description ? descriptionId : undefined, onCancel: onCancel, onClose: onNativeClose, onPointerDown: onPointerDown, onClick: onClick, children: shown && (_jsxs(HostedPopupContext, { value: false, children: [(title || icon || hasControls) && (_jsxs("header", { className: "nx-popup__head", onPointerDown: onHeadPointerDown, onDoubleClick: onHeadDoubleClick, children: [icon && _jsx("span", { className: "nx-popup__icon", children: icon }), _jsxs("div", { className: "nx-popup__heading", children: [title && (_jsx("h2", { id: titleId, className: "nx-popup__title", children: title })), description && (_jsx("p", { id: descriptionId, className: "nx-popup__description", children: description }))] }), hasControls && (_jsxs("div", { className: "nx-popup__controls", children: [canMinimize && (_jsx("button", { type: "button", className: "nx-popup__control nx-popup__minimize", "aria-label": "Minimize", title: "Minimize", onMouseDown: keepFocus, onClick: () => changeWindowState("minimized"), children: _jsx(InlineIcon, { icon: iconMinus }) })), maximizable && (_jsx("button", { type: "button", className: "nx-popup__control nx-popup__maximize", "aria-label": maximized ? "Restore down" : "Maximize", title: maximized ? "Restore down" : "Maximize", onMouseDown: keepFocus, onClick: toggleMaximize, children: _jsx(InlineIcon, { icon: maximized ? iconSquares : iconSquare }) })), closable && (_jsx("button", { type: "button", className: "nx-popup__control nx-popup__close", "aria-label": "Close", title: "Close", onClick: () => onClose("close"), children: _jsx(InlineIcon, { icon: iconX }) }))] }))] })), _jsx("div", { className: "nx-popup__body", children: children }), footer && _jsx("footer", { className: "nx-popup__foot", children: footer })] }, round)) }));
400
+ return (_jsx("dialog", { ref: dialogRef, className: cx("nx-popup", `nx-popup--${variant}`, `nx-popup--${size}`, flush && "nx-popup--flush", maximized && "nx-popup--maximized", movable && "nx-popup--movable", closing && "nx-popup--closing", className), "data-window-state": windowState, "aria-labelledby": title ? titleId : undefined, "aria-label": title ? undefined : ariaLabel, "aria-describedby": cx(description ? descriptionId : undefined, ariaDescribedBy) || undefined, onCancel: onCancel, onClose: onNativeClose, onPointerDown: onPointerDown, onMouseDown: onMouseDown, onClick: onClick, children: shown && (_jsxs(HostedPopupContext, { value: false, children: [(title || icon || hasControls) && (_jsxs("header", { className: "nx-popup__head", onPointerDown: onHeadPointerDown, onDoubleClick: onHeadDoubleClick, children: [icon && _jsx("span", { className: "nx-popup__icon", children: icon }), _jsxs("div", { className: "nx-popup__heading", children: [title && (_jsx("h2", { id: titleId, className: "nx-popup__title", children: title })), description && (_jsx("p", { id: descriptionId, className: "nx-popup__description", children: description }))] }), hasControls && (_jsxs("div", { className: "nx-popup__controls", children: [canMinimize && (_jsx("button", { type: "button", className: "nx-popup__control nx-popup__minimize", "aria-label": "Minimize", title: "Minimize", onMouseDown: keepFocus, onClick: () => changeWindowState("minimized"), children: _jsx(InlineIcon, { icon: iconMinus }) })), maximizable && (_jsx("button", { type: "button", className: "nx-popup__control nx-popup__maximize", "aria-label": maximized ? "Restore down" : "Maximize", title: maximized ? "Restore down" : "Maximize", onMouseDown: keepFocus, onClick: toggleMaximize, children: _jsx(InlineIcon, { icon: maximized ? iconSquares : iconSquare }) })), closable && (_jsx("button", { type: "button", className: "nx-popup__control nx-popup__close", "aria-label": "Close", title: "Close", onClick: () => onClose("close"), children: _jsx(InlineIcon, { icon: iconX }) }))] }))] })), _jsx("div", { className: "nx-popup__body", children: children }), footer && _jsx("footer", { className: "nx-popup__foot", children: footer })] }, round)) }));
373
401
  }
@@ -3,7 +3,9 @@ import { type ReactNode } from "react";
3
3
  export interface DockedWindow {
4
4
  id: string;
5
5
  title: ReactNode;
6
- /** Plain text of the title, for the tooltip. */
6
+ /** A second line under the title: the record or the place a window is about, so two of one kind read apart. */
7
+ description?: ReactNode;
8
+ /** Plain text of the title, and of the description, for the tooltip. */
7
9
  label?: string;
8
10
  /** The window's icon; a window icon when left out. */
9
11
  icon?: ReactNode;
@@ -113,9 +113,10 @@ export function PopupDock() {
113
113
  function DockBar({ item, onRestore }) {
114
114
  const id = useId();
115
115
  const titleId = `${id}-title`;
116
+ const descriptionId = `${id}-description`;
116
117
  const closeId = `${id}-close`;
117
118
  return (_jsxs("li", { className: "nx-dock__item", children: [_jsxs("button", { type: "button", className: "nx-dock__window", title: item.label ? `Restore: ${item.label}` : "Restore", onClick: () => {
118
119
  onRestore();
119
120
  item.restore();
120
- }, children: [_jsx("span", { className: "nx-dock__icon", "aria-hidden": "true", children: item.icon ?? _jsx(InlineIcon, { icon: iconAppWindow }) }), _jsx("span", { className: "nx-visually-hidden", children: "Restore " }), _jsx("span", { id: titleId, className: "nx-dock__title", children: item.title }), _jsx(InlineIcon, { className: "nx-dock__restore", icon: iconChevronUp })] }), item.close && (_jsxs("button", { type: "button", className: "nx-dock__close", "aria-labelledby": `${closeId} ${titleId}`, title: "Close", onClick: item.close, children: [_jsx("span", { id: closeId, hidden: true, children: "Close" }), _jsx(InlineIcon, { icon: iconX })] }))] }));
121
+ }, children: [_jsx("span", { className: "nx-dock__icon", "aria-hidden": "true", children: item.icon ?? _jsx(InlineIcon, { icon: iconAppWindow }) }), _jsx("span", { className: "nx-visually-hidden", children: "Restore " }), _jsxs("span", { className: "nx-dock__text", children: [_jsx("span", { id: titleId, className: "nx-dock__title", children: item.title }), item.description ? (_jsx("span", { id: descriptionId, className: "nx-dock__description", children: item.description })) : null] }), _jsx(InlineIcon, { className: "nx-dock__restore", icon: iconChevronUp })] }), item.close && (_jsxs("button", { type: "button", className: "nx-dock__close", "aria-labelledby": item.description ? `${closeId} ${titleId} ${descriptionId}` : `${closeId} ${titleId}`, title: "Close", onClick: item.close, children: [_jsx("span", { id: closeId, hidden: true, children: "Close" }), _jsx(InlineIcon, { icon: iconX })] }))] }));
121
122
  }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useLayoutEffect, useSyncExternalStore } from "react";
4
4
  import { Popup } from "./Popup.js";
5
5
  import { HostedPopupContext } from "./PopupContext.js";
6
+ import { PageLeaveGuardContext } from "../Forms/FormLeaveGuard.js";
6
7
  import { PopupDock } from "./PopupDock.js";
7
8
  import { withPopupRule } from "./PopupRules.js";
8
9
  const EMPTY = [];
@@ -156,8 +157,8 @@ export function PopupHost() {
156
157
  for (const entry of list)
157
158
  rendered.add(entry.id);
158
159
  });
159
- return (_jsxs(HostedPopupContext, { value: true, children: [list.map(entry => {
160
- const { content, footer, onClose: _onClose, beforeClose: _beforeClose, onWindowStateChange: _onWindowStateChange, key: _key, ...rest } = entry.options;
161
- return (_jsx(Popup, { ...rest, open: entry.open, windowState: entry.windowState, onWindowStateChange: state => setWindowState(entry.id, state), footer: typeof footer === "function" ? footer(entry.handle) : footer, onClose: reason => requestClose(entry.id, reason), onClosed: () => removeEntry(entry.id), children: typeof content === "function" ? content(entry.handle) : content }, entry.id));
162
- }), _jsx(PopupDock, {})] }));
160
+ return (_jsx(PageLeaveGuardContext, { value: null, children: _jsxs(HostedPopupContext, { value: true, children: [list.map(entry => {
161
+ const { content, footer, onClose: _onClose, beforeClose: _beforeClose, onWindowStateChange: _onWindowStateChange, key: _key, ...rest } = entry.options;
162
+ return (_jsx(Popup, { ...rest, open: entry.open, windowState: entry.windowState, onWindowStateChange: state => setWindowState(entry.id, state), footer: typeof footer === "function" ? footer(entry.handle) : footer, onClose: reason => requestClose(entry.id, reason), onClosed: () => removeEntry(entry.id), children: typeof content === "function" ? content(entry.handle) : content }, entry.id));
163
+ }), _jsx(PopupDock, {})] }) }));
163
164
  }
@@ -5,16 +5,18 @@ import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, us
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
12
  import { joinKeys, joinSourceName, tableJoins } from "../../Helpers/TableJoins.js";
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";
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";
13
14
  import { TableStore } from "../../Helpers/TableStore.js";
14
15
  import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
15
16
  import { Button } from "../Documents/Button.js";
16
17
  import { Menu } from "../Documents/Menu.js";
17
18
  import { SplitButton } from "../Documents/SplitButton.js";
19
+ import { useReadOnlyNotice } from "../Inputs/ReadOnlyNotice.js";
18
20
  import { TextBox } from "../Inputs/TextBox.js";
19
21
  import { toast } from "../Layouts/Toaster.js";
20
22
  import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
@@ -41,8 +43,10 @@ export function useTable() {
41
43
  const STATE_PREFIX = "nx-table:";
42
44
  /** The key of the section `pinnedRows` makes. */
43
45
  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;
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;
46
50
  function readSaved(key) {
47
51
  if (!key || typeof localStorage === "undefined")
48
52
  return null;
@@ -77,6 +81,9 @@ function usePerRow(make) {
77
81
  return value;
78
82
  }, [cache, make]);
79
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));
80
87
  const sameNumbers = (a, b) => {
81
88
  const keys = Object.keys(a);
82
89
  return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key]);
@@ -99,17 +106,24 @@ const sameNumbers = (a, b) => {
99
106
  * />
100
107
  */
101
108
  export function DataTable(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;
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;
103
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";
104
114
  const reloadable = props.reloadable ?? Boolean(load);
105
115
  const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
106
- // A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer.
107
- 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);
108
120
  // The person's choices in the quick preference panel, once made, go over what the table names itself.
109
121
  const preferences = useUserPreferences();
110
122
  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. */
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. */
112
124
  const openingPageSize = () => {
125
+ if (whole)
126
+ return WHOLE_PAGE;
113
127
  const preferred = readUserPreferences().pageSize;
114
128
  return preferred !== null && pageSizes.includes(preferred) ? preferred : (ownPageSize ?? DEFAULT_PAGE_SIZE);
115
129
  };
@@ -117,20 +131,24 @@ export function DataTable(props) {
117
131
  const table = givenTable ?? own;
118
132
  // Prepared once for the store and the panel: the same params without what would make them filter on their own.
119
133
  const filterInputs = useMemo(() => props.filterInputs?.map(asFilterInput), [props.filterInputs]);
120
- 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 });
121
135
  useImperativeHandle(ref, () => table, [table]);
122
136
  const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
123
137
  // The columns whose values are other records', looked up by key (`join`). Only a table with one listens to the page's
124
138
  // lookups, and it draws again - its rows too, through `cellOptions` - each time names come in.
125
139
  const joins = useMemo(() => columns.flatMap(column => (column.join ? [column.join] : [])), [columns]);
126
140
  const subscribeJoins = useCallback((listener) => (joins.length ? tableJoins.subscribe(listener) : () => { }), [joins.length]);
127
- const joinsVersion = useSyncExternalStore(subscribeJoins, joins.length ? tableJoins.getVersion : NO_JOINS, NO_JOINS);
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);
128
146
  const dates = useDatePreferences();
129
147
  const timeZone = useBrowserTimeZone();
130
148
  const numbers = useNumberLocale();
131
149
  // `joinsVersion` is read by no cell option: a new object is what tells the memoized rows that their names came in.
132
150
  // eslint-disable-next-line react-hooks/exhaustive-deps
133
- const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers, joinsVersion]);
151
+ const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers, currency }), [dates, timeZone, numbers, currency, joinsVersion]);
134
152
  const tableId = useId();
135
153
  // The search box is uncontrolled: a new key clears it when a new list starts over.
136
154
  const [searchGeneration, setSearchGeneration] = useState(0);
@@ -147,7 +165,7 @@ export function DataTable(props) {
147
165
  started.current = true;
148
166
  const saved = readSaved(stateKey);
149
167
  table.restore({
150
- pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
168
+ pageSize: !whole && saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
151
169
  sort: saved?.sort !== undefined ? saved.sort : defaultSort,
152
170
  hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
153
171
  order: saved?.order,
@@ -173,6 +191,26 @@ export function DataTable(props) {
173
191
  if (started.current && givenRows)
174
192
  table.setRows(givenRows);
175
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]);
176
214
  // Another list behind the same table: start over from the first page, with no search and no selection.
177
215
  useLayoutEffect(() => {
178
216
  if (loadedKey.current === loadKey)
@@ -183,20 +221,24 @@ export function DataTable(props) {
183
221
  void table.reload({ reset: true });
184
222
  }, [table, loadKey]);
185
223
  useEffect(() => () => clearTimeout(searchTimer.current), []);
186
- // 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.
187
227
  const hiddenList = useMemo(() => [...state.hidden].sort(), [state.hidden]);
188
228
  useEffect(() => {
189
229
  if (!stateKey || !started.current)
190
230
  return;
231
+ const now = table.getState();
191
232
  writeSaved(stateKey, {
192
233
  // 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,
194
- sort: state.query.sort,
195
- hidden: hiddenList,
196
- order: state.order.length ? [...state.order] : undefined,
197
- widths: Object.keys(state.widths).length ? state.widths : undefined,
198
- 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,
199
240
  });
241
+ // eslint-disable-next-line react-hooks/exhaustive-deps
200
242
  }, [stateKey, state.query.pageSize, state.query.sort, hiddenList, state.order, state.widths, state.pinned]);
201
243
  // A new default page size from the panel reaches an open table where no size was picked. Only a change counts: on the
202
244
  // first effects the save above still holds the store's opening size and has not written the restored one yet, so a
@@ -207,7 +249,7 @@ export function DataTable(props) {
207
249
  if (preferredSize.current === preferences.pageSize)
208
250
  return;
209
251
  preferredSize.current = preferences.pageSize;
210
- if (!started.current || readSaved(stateKey)?.pageSize)
252
+ if (whole || !started.current || readSaved(stateKey)?.pageSize)
211
253
  return;
212
254
  table.setPageSize(openingPageSize());
213
255
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -233,7 +275,7 @@ export function DataTable(props) {
233
275
  if (rowLabel)
234
276
  return rowLabel(row);
235
277
  const first = columns[0];
236
- const text = first ? formatCellText(first, columnValue(first, row), cellOptions) : "";
278
+ const text = first ? formatCellText(first, columnValue(first, row), cellOptions, row) : "";
237
279
  return text || `row ${table.keyOf(row)}`;
238
280
  }, [rowLabel, columns, cellOptions, table]);
239
281
  const actionsOf = usePerRow(rowActions);
@@ -260,6 +302,10 @@ export function DataTable(props) {
260
302
  void result.finally(() => table.setBusy([key], false)).catch(() => undefined);
261
303
  }
262
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;
263
309
  const onSearch = (text) => {
264
310
  clearTimeout(searchTimer.current);
265
311
  if (searchDelay > 0 && text.trim())
@@ -280,9 +326,11 @@ export function DataTable(props) {
280
326
  const headRef = useRef(null);
281
327
  const colRefs = useMemo(() => new Map(), []);
282
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);
283
331
  const sized = Object.keys(state.widths).length > 0;
284
- 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]);
285
- 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;
286
334
  // The bands above the headings. With no group anywhere the header is the one row it always was.
287
335
  const bands = useMemo(() => headBands(views), [views]);
288
336
  const grouped = bands.some(band => band.group !== null);
@@ -327,8 +375,36 @@ export function DataTable(props) {
327
375
  // The bar of the selected rows starts after the checkbox column, whatever width it took.
328
376
  root?.style.setProperty("--nx-table-index-w", `${Math.round(left)}px`);
329
377
  setOffsets(previous => (sameNumbers(previous, next) ? previous : next));
378
+ settlePins.current();
330
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
+ };
331
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.
332
408
  useLayoutEffect(() => {
333
409
  const scroller = scrollRef.current;
334
410
  const frameElement = scroller?.parentElement;
@@ -339,9 +415,14 @@ export function DataTable(props) {
339
415
  frameElement.toggleAttribute("data-scroll-start", left > 1);
340
416
  frameElement.toggleAttribute("data-scroll-end", left < scroller.scrollWidth - scroller.clientWidth - 1);
341
417
  };
342
- update();
418
+ const measure = () => {
419
+ frameElement.style.setProperty("--nx-table-view-w", `${scroller.clientWidth}px`);
420
+ update();
421
+ settlePins.current();
422
+ };
423
+ measure();
343
424
  scroller.addEventListener("scroll", update, { passive: true });
344
- const observer = new ResizeObserver(update);
425
+ const observer = new ResizeObserver(measure);
345
426
  observer.observe(scroller);
346
427
  if (scroller.firstElementChild)
347
428
  observer.observe(scroller.firstElementChild);
@@ -467,6 +548,10 @@ export function DataTable(props) {
467
548
  const barRef = useRef(null);
468
549
  const reloadRef = useRef(null);
469
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);
470
555
  const addPart = Boolean(add || actions);
471
556
  const listsPart = Boolean(toolbar);
472
557
  useLayoutEffect(() => {
@@ -500,23 +585,35 @@ export function DataTable(props) {
500
585
  void table.reload({ quiet: true });
501
586
  };
502
587
  // 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.
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));
504
592
  const sheetItems = excel
505
593
  ? [
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
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)
514
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()) }]
515
607
  : []),
516
- ]
608
+ ].map((item, index) => (index === 0 ? { ...item, separator: true } : item))
517
609
  : [];
518
610
  // 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 }));
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 }));
520
617
  const optionItems = [
521
618
  ...(filterInputs?.length
522
619
  ? [{ id: "filters", label: "Filters", description: filtersOn.join(", ") || undefined, icon: _jsx(InlineIcon, { icon: iconFilter }), onSelect: () => filtersRef.current?.open() }]
@@ -531,7 +628,7 @@ export function DataTable(props) {
531
628
  : []),
532
629
  // The sheet's own Import replaces the page's `onImport` hook.
533
630
  ...(excel
534
- ? add
631
+ ? addable
535
632
  ? []
536
633
  : sheetItems
537
634
  : onImport
@@ -545,7 +642,9 @@ export function DataTable(props) {
545
642
  ...(reloadable && reloadHidden ? [{ id: "reload", label: "Reload", icon: _jsx(InlineIcon, { icon: iconRefresh }), separator: true, onSelect: reload }] : []),
546
643
  ];
547
644
  const optionEntries = menuEntries(optionItems);
548
- 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;
549
648
  let body;
550
649
  if (status === "loading") {
551
650
  body = _jsx(TableSkeletonRows, { count: Math.min(query.pageSize, 8), cells: cellCount, frame: frame, sized: sized });
@@ -576,7 +675,7 @@ export function DataTable(props) {
576
675
  body = rows.map((row, index) => {
577
676
  const key = pageKeys[index];
578
677
  const selected = state.selected.has(key);
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));
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));
580
679
  });
581
680
  }
582
681
  /* The sections above the page: the records pinned, the favourites, each folded open or shut, remembered on this device */
@@ -598,7 +697,9 @@ export function DataTable(props) {
598
697
  const key = table.keyOf(row);
599
698
  const selected = state.selected.has(key);
600
699
  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));
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));
602
703
  });
603
704
  };
604
705
  const selectedRows = selecting ? [...state.selected.values()] : [];
@@ -619,8 +720,8 @@ export function DataTable(props) {
619
720
  const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
620
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.
621
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));
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 => {
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 => {
624
725
  if (event.key === "Enter") {
625
726
  clearTimeout(searchTimer.current);
626
727
  table.setSearch(event.currentTarget.value);
@@ -632,13 +733,13 @@ export function DataTable(props) {
632
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 => {
633
734
  const open = openSections.has(section.key);
634
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));
635
- }), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
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] }));
636
737
  }
637
738
  /** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
638
739
  function moveTargets(views, key) {
639
740
  // A column moves among the columns on its own side, past the ones that stay put.
640
- const pin = views.find(view => view.column.key === key)?.pin ?? null;
641
- 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);
642
743
  const at = group.findIndex(view => view.column.key === key);
643
744
  if (at < 0)
644
745
  return { left: undefined, right: undefined };
@@ -679,11 +780,12 @@ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, can
679
780
  if (canPin) {
680
781
  if (entries.length)
681
782
  entries.push({ separator: true });
682
- if (view.pin !== "left")
783
+ // The side chosen, also while a narrow table lets it scroll.
784
+ if (view.side !== "left")
683
785
  entries.push({ value: "pin-left", label: "Pin left", icon: _jsx(InlineIcon, { icon: iconArrowBarToLeft }) });
684
- if (view.pin !== "right")
786
+ if (view.side !== "right")
685
787
  entries.push({ value: "pin-right", label: "Pin right", icon: _jsx(InlineIcon, { icon: iconArrowBarToRight }) });
686
- if (view.pin)
788
+ if (view.side)
687
789
  entries.push({ value: "unpin", label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) });
688
790
  }
689
791
  if (view.movable && (neighbours.left !== undefined || neighbours.right !== undefined)) {
@@ -721,7 +823,7 @@ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, can
721
823
  onHide();
722
824
  }
723
825
  const label = _jsx("span", { className: "nx-table__heading-label", children: column.label });
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 }))] }));
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 }))] }));
725
827
  }
726
828
  /** The steps a bar is squeezed into when its parts do not fit, each one keeping the steps before it. */
727
829
  const BAR_SQUEEZES = ["search", "search reload", "search reload add"];