nexus-shared 2.0.0 → 3.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 (116) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +122 -25
  3. package/dist/Client.Index.d.ts +11 -0
  4. package/dist/Client.Index.js +11 -0
  5. package/dist/Components/Chats/Chat.d.ts +28 -0
  6. package/dist/Components/Chats/Chat.js +19 -0
  7. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  8. package/dist/Components/Chats/ChatButton.js +41 -0
  9. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  10. package/dist/Components/Chats/ChatComposer.js +177 -0
  11. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  12. package/dist/Components/Chats/ChatConversations.js +38 -0
  13. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  14. package/dist/Components/Chats/ChatPanel.js +94 -0
  15. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  16. package/dist/Components/Chats/ChatParts.js +100 -0
  17. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  18. package/dist/Components/Chats/ChatThread.js +96 -0
  19. package/dist/Components/Documents/Menu.js +24 -20
  20. package/dist/Components/Documents/SplitButton.js +5 -3
  21. package/dist/Components/Documents/TabButtons.d.ts +24 -6
  22. package/dist/Components/Documents/TabButtons.js +23 -4
  23. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  24. package/dist/Components/Forms/ApiForm.js +15 -14
  25. package/dist/Components/Forms/Crud.js +202 -50
  26. package/dist/Components/Forms/ExcelImport.d.ts +42 -0
  27. package/dist/Components/Forms/ExcelImport.js +190 -0
  28. package/dist/Components/Forms/Form.js +5 -1
  29. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  30. package/dist/Components/Inputs/GroupForm.js +4 -2
  31. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  32. package/dist/Components/Inputs/InputRenderer.js +1 -1
  33. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  34. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  35. package/dist/Components/Inputs/RowsInput.js +53 -4
  36. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  37. package/dist/Components/Inputs/TabularForm.js +85 -20
  38. package/dist/Components/Inputs/TimePicker.js +2 -1
  39. package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
  40. package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
  41. package/dist/Components/Viewers/DataTable.js +88 -34
  42. package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
  43. package/dist/Components/Viewers/DataTableColumns.js +243 -0
  44. package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
  45. package/dist/Components/Viewers/DataTableParts.js +21 -9
  46. package/dist/Helpers/ApiClient.d.ts +22 -0
  47. package/dist/Helpers/ApiClient.js +4 -1
  48. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  49. package/dist/Helpers/ApiFormHelpers.js +73 -9
  50. package/dist/Helpers/ApiModules.d.ts +0 -4
  51. package/dist/Helpers/ApiModules.js +1 -0
  52. package/dist/Helpers/ApiResponses.d.ts +14 -4
  53. package/dist/Helpers/ApiResponses.js +116 -25
  54. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  55. package/dist/Helpers/ApiRoutes.js +2 -10
  56. package/dist/Helpers/ChatBackend.d.ts +134 -0
  57. package/dist/Helpers/ChatBackend.js +332 -0
  58. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  59. package/dist/Helpers/ChatHelpers.js +486 -0
  60. package/dist/Helpers/ChatHooks.d.ts +81 -0
  61. package/dist/Helpers/ChatHooks.js +175 -0
  62. package/dist/Helpers/ChatStore.d.ts +132 -0
  63. package/dist/Helpers/ChatStore.js +394 -0
  64. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  65. package/dist/Helpers/ChatSummaries.js +118 -0
  66. package/dist/Helpers/CrudBackend.d.ts +47 -5
  67. package/dist/Helpers/CrudBackend.js +99 -4
  68. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  69. package/dist/Helpers/CrudHelpers.js +76 -11
  70. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  71. package/dist/Helpers/ExcelBackend.js +290 -0
  72. package/dist/Helpers/ExcelHelpers.d.ts +98 -0
  73. package/dist/Helpers/ExcelHelpers.js +302 -0
  74. package/dist/Helpers/FormStore.d.ts +2 -0
  75. package/dist/Helpers/FormStore.js +19 -1
  76. package/dist/Helpers/MessageBuilder.js +0 -2
  77. package/dist/Helpers/PopoverHelpers.d.ts +31 -6
  78. package/dist/Helpers/PopoverHelpers.js +116 -12
  79. package/dist/Helpers/RowsStore.d.ts +7 -1
  80. package/dist/Helpers/RowsStore.js +22 -0
  81. package/dist/Helpers/TableColumns.d.ts +7 -0
  82. package/dist/Helpers/TableColumns.js +41 -13
  83. package/dist/Helpers/TableHelpers.d.ts +9 -1
  84. package/dist/Helpers/TableHelpers.js +20 -0
  85. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  86. package/dist/Interfaces/ApiInterfaces.js +16 -10
  87. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  88. package/dist/Interfaces/ChatInterfaces.js +1 -0
  89. package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
  90. package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
  91. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  92. package/dist/Interfaces/FormInterfaces.d.ts +52 -2
  93. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  94. package/dist/Interfaces/TableInterfaces.d.ts +62 -2
  95. package/dist/Services/BrowserApi.d.ts +18 -1
  96. package/dist/Services/BrowserApi.js +18 -0
  97. package/dist/Services/ChatApi.d.ts +37 -0
  98. package/dist/Services/ChatApi.js +98 -0
  99. package/dist/Services/ExcelApi.d.ts +46 -0
  100. package/dist/Services/ExcelApi.js +196 -0
  101. package/dist/Services/ServerApi.d.ts +6 -1
  102. package/dist/Services/ServerApi.js +3 -0
  103. package/dist/Shared.Index.d.ts +8 -0
  104. package/dist/Shared.Index.js +8 -0
  105. package/package.json +6 -3
  106. package/src/Styles/Nexus.Button.css +2 -0
  107. package/src/Styles/Nexus.Chat.css +1489 -0
  108. package/src/Styles/Nexus.Crud.css +77 -0
  109. package/src/Styles/Nexus.Excel.css +370 -0
  110. package/src/Styles/Nexus.Form.css +23 -0
  111. package/src/Styles/Nexus.Index.css +2 -0
  112. package/src/Styles/Nexus.Menu.css +12 -2
  113. package/src/Styles/Nexus.Rows.css +78 -48
  114. package/src/Styles/Nexus.Tab.Buttons.css +55 -0
  115. package/src/Styles/Nexus.Table.css +286 -4
  116. package/src/Styles/Nexus.Theme.Picker.css +75 -2
@@ -1,11 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconArrowDown, iconArrowUp, iconCopy, iconDotsVertical, iconRowInsertBottom, iconRowInsertTop, iconTrash, iconX, InlineIcon } from "nexus-icons";
3
+ import { iconArrowDown, iconArrowUp, iconCopy, iconDotsVertical, iconLock, iconRowInsertBottom, iconRowInsertTop, iconTrash, InlineIcon } from "nexus-icons";
4
4
  import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
- import { Button } from "../Documents/Button.js";
7
6
  import { Menu } from "../Documents/Menu.js";
8
- import { firstFocusable, labelText, RowCell, rowName, RowsFooter, RowsLegend, useRowsFrame, useRowsInput, useRowVersion } from "./RowsInput.js";
7
+ import { firstFocusable, labelText, RowCell, RowsFooter, RowsLegend, useRowsFrame, useRowsInput, useRowVersion } from "./RowsInput.js";
9
8
  /** Height of a row: the input's box and the line under it. Measured from the first row once it renders. */
10
9
  const ROW_HEIGHT = { sm: 29, md: 35, lg: 41 };
11
10
  /** Rows rendered above and below the ones in view, so Tab and Enter always find the next row on the page. */
@@ -52,19 +51,20 @@ function columnWidth(params) {
52
51
  /** One row of inputs. It renders again only when its position, its messages, or a value set from code change. */
53
52
  const TabularRow = memo(function TabularRow({ store, rowKey, index, count, columns, context, onAction }) {
54
53
  useRowVersion(store, rowKey);
55
- const name = rowName(store, index);
56
54
  const title = `${store.names.title} ${index + 1}`;
57
55
  const invalid = context.showAll && store.rowProblems(rowKey) > 0;
58
56
  const items = [];
59
- if (context.canAdd) {
57
+ // A row nobody may change (an inherited field) has nothing to offer: no copy of it, no move, no removal.
58
+ const fixed = Boolean(context.rowLock(rowKey, index));
59
+ if (context.canAdd && !fixed) {
60
60
  items.push({ value: "insert-above", label: "Insert above", icon: _jsx(InlineIcon, { icon: iconRowInsertTop }) }, { value: "insert-below", label: "Insert below", icon: _jsx(InlineIcon, { icon: iconRowInsertBottom }) }, { value: "duplicate", label: "Duplicate", icon: _jsx(InlineIcon, { icon: iconCopy }) });
61
61
  }
62
- if (context.sortable) {
62
+ if (context.sortable && !fixed) {
63
63
  if (items.length)
64
64
  items.push({ separator: true });
65
- items.push({ value: "move-up", label: "Move up", icon: _jsx(InlineIcon, { icon: iconArrowUp }), disabled: index === 0 }, { value: "move-down", label: "Move down", icon: _jsx(InlineIcon, { icon: iconArrowDown }), disabled: index === count - 1 });
65
+ items.push({ value: "move-up", label: "Move up", icon: _jsx(InlineIcon, { icon: iconArrowUp }), disabled: index === 0 || Boolean(context.rowLock(store.keyAt(index - 1), index - 1)) }, { value: "move-down", label: "Move down", icon: _jsx(InlineIcon, { icon: iconArrowDown }), disabled: index === count - 1 });
66
66
  }
67
- if (context.removable) {
67
+ if (context.removable && !fixed) {
68
68
  if (items.length)
69
69
  items.push({ separator: true });
70
70
  items.push({ value: "remove", label: "Remove", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" });
@@ -76,7 +76,7 @@ const TabularRow = memo(function TabularRow({ store, rowKey, index, count, colum
76
76
  return;
77
77
  store.touch(rowKey, cell.dataset.nxCell ?? "");
78
78
  }
79
- return (_jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": index + 2, "data-nx-row": rowKey, "data-problem": invalid || undefined, onBlur: onBlur, children: [context.numbered && (_jsx("div", { role: "cell", className: "nx-tabular__num", children: items.length > (context.removable ? 1 : 0) ? (_jsx(Menu, { items: items, onSelect: value => onAction(value, rowKey), "aria-label": `${title} actions`, children: _jsxs("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${title} actions`, title: `${title} actions`, "data-nx-row-actions": "", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), _jsx(InlineIcon, { icon: iconDotsVertical, className: "nx-tabular__num-icon" })] }) })) : (_jsx("span", { className: "nx-tabular__num-text", children: index + 1 })) })), columns.map(params => (_jsx("div", { role: "cell", className: cx("nx-tabular__cell", `nx-tabular__cell--${cellKind(params)}`), "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: params, index: index, error: store.shownError(rowKey, params.name), layout: "tabular", idPrefix: context.idPrefix, size: context.size, readOnly: context.readOnly, disabled: context.disabled }, store.revision(rowKey, params.name)) }, params.name))), context.removable && (_jsx("div", { role: "cell", className: "nx-tabular__end", children: _jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", className: "nx-tabular__remove", icon: _jsx(InlineIcon, { icon: iconX }), "aria-label": `Remove ${name}`, title: `Remove ${name}`, onClick: () => onAction("remove", rowKey) }) }))] }));
79
+ return (_jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": index + 2, "data-nx-row": rowKey, "data-problem": invalid || undefined, onBlur: onBlur, children: [context.numbered && (_jsxs("div", { role: "cell", className: "nx-tabular__num", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), items.length > 0 && (_jsx(Menu, { items: items, onSelect: value => onAction(value, rowKey), "aria-label": `${title} actions`, children: _jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${title} actions`, title: `${title} actions`, "data-nx-row-actions": "", children: _jsx(InlineIcon, { icon: iconDotsVertical }) }) }))] })), columns.map(params => (_jsx("div", { role: "cell", className: cx("nx-tabular__cell", `nx-tabular__cell--${cellKind(params)}`), "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: context.cellParams(rowKey, params, index), index: index, error: store.shownError(rowKey, params.name), layout: "tabular", idPrefix: context.idPrefix, size: context.size, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index) }, store.revision(rowKey, params.name)) }, params.name)))] }));
80
80
  });
81
81
  /**
82
82
  * Rows of inputs in a table, one column per input: a person's emails or addresses, invoice lines, a salary sheet. The
@@ -84,9 +84,13 @@ const TabularRow = memo(function TabularRow({ store, rowKey, index, count, colum
84
84
  * come as params, the same objects a form takes, so the same list can show as sections in a `GroupForm`.
85
85
  *
86
86
  * Only the rows in view are on the page, so a sheet of thousands of rows opens and types as fast as a short one; the
87
- * values live in a store outside React, and typing renders nothing. The header, the row numbers, and the remove buttons
88
- * stay in place while the table scrolls. A row number opens the row's menu (insert, duplicate, move, remove). Enter in
89
- * a text or number cell moves down the column, and on the last row adds a row.
87
+ * values live in a store outside React, and typing renders nothing. The header and the numbered column stay in place
88
+ * while the table scrolls. Each row has one control, beside its number and never in its place: a menu of insert,
89
+ * duplicate, move and remove. Enter in a text or number cell moves down the column, and on the last row adds a row.
90
+ *
91
+ * Every row is one line high, whatever it holds, so nothing shifts as the table is used; a column generated for every
92
+ * row (`cellReadOnly`) says so once on its header rather than on each of its cells; and `maxHeight` is brought down
93
+ * to whole rows, because a scroller that cuts the last one in half reads as broken rather than as scrollable.
90
94
  *
91
95
  * Inside a `<form>`, the rows are sent as JSON under `name`; a row with a problem blocks submit, and the first one
92
96
  * scrolls into view with focus.
@@ -115,6 +119,7 @@ export function TabularForm(props) {
115
119
  const headRef = useRef(null);
116
120
  const bodyRef = useRef(null);
117
121
  const [rowHeight, setRowHeight] = useState(ROW_HEIGHT[size]);
122
+ const [viewportMax, setViewportMax] = useState(null);
118
123
  const pageRows = Math.ceil((typeof maxHeight === "number" ? maxHeight : 600) / rowHeight);
119
124
  const [range, setRange] = useState({ start: 0, end: pageRows + OVERSCAN });
120
125
  const [status, setStatus] = useState(null);
@@ -148,6 +153,27 @@ export function TabularForm(props) {
148
153
  if (height && height !== rowHeight)
149
154
  setRowHeight(height);
150
155
  });
156
+ /**
157
+ * `maxHeight`, brought down to whole rows. A scroller that cuts the last row in half reads as broken rather than as
158
+ * scrollable, and the header is not a row, so the arithmetic has to leave it out — as it does the frame and the
159
+ * horizontal scroll bar, neither of which is a row either.
160
+ */
161
+ useLayoutEffect(() => {
162
+ const viewport = viewportRef.current;
163
+ const wanted = typeof maxHeight === "number" ? maxHeight : null;
164
+ if (!viewport || wanted === null || count === 0) {
165
+ if (viewportMax !== null)
166
+ setViewportMax(null);
167
+ return;
168
+ }
169
+ const head = headRef.current?.offsetHeight ?? 0;
170
+ const chrome = viewport.offsetHeight - viewport.clientHeight;
171
+ const room = wanted - chrome - head;
172
+ // Not even one row fits: leave what was asked for rather than showing a header and a sliver.
173
+ const next = room < rowHeight ? null : chrome + head + Math.floor(room / rowHeight) * rowHeight;
174
+ if (next !== viewportMax)
175
+ setViewportMax(next);
176
+ });
151
177
  // A resize, a new row height, or fewer rows change which rows are in view.
152
178
  useLayoutEffect(() => {
153
179
  const viewport = viewportRef.current;
@@ -260,8 +286,46 @@ export function TabularForm(props) {
260
286
  layoutRef.current?.focusRow(key, name);
261
287
  }
262
288
  }
263
- const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll]);
264
- const numberWidth = `calc(${Math.max(2, String(count).length)}ch + 24px)`;
289
+ const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll, cellLock: rows.cellLock, cellParams: rows.cellParams, rowLock: rows.rowLock }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll, rows.cellLock, rows.cellParams, rows.rowLock]);
290
+ /**
291
+ * Which columns are generated for **every** row on the page. The lock is then said once, on the column header,
292
+ * instead of on each of its cells: eighteen locks down one column is eighteen times the ink for one fact. The cells
293
+ * keep their read-only tint and their reason, which shows under the table when one is used.
294
+ *
295
+ * Only the rows in view are asked, which is what a column-wide rule (`cellReadOnly` on a name) answers the same way
296
+ * however the table is scrolled. A lock that really is per row leaves the column unlocked, which is what it means.
297
+ */
298
+ const lockedColumns = useMemo(() => {
299
+ const locked = new Map();
300
+ for (const params of columns) {
301
+ if (readOnly || params.readOnly === true) {
302
+ locked.set(params.name, true);
303
+ continue;
304
+ }
305
+ if (!shownKeys.length)
306
+ continue;
307
+ let reason;
308
+ for (const [offset, key] of shownKeys.entries()) {
309
+ // A locked row says nothing about its columns: the rows around it decide whether a column is generated.
310
+ if (rows.rowLock(key, start + offset))
311
+ continue;
312
+ const lock = rows.cellLock(key, params.name, start + offset);
313
+ if (!lock) {
314
+ reason = undefined;
315
+ break;
316
+ }
317
+ if (reason === undefined)
318
+ reason = typeof lock === "string" ? lock : true;
319
+ }
320
+ if (reason !== undefined)
321
+ locked.set(params.name, reason);
322
+ }
323
+ return locked;
324
+ // `shownKeys` is a fresh array on every render; the work is a call per visible cell, and nothing memoised is
325
+ // handed to the rows, so recomputing it costs a map and never a re-render of a row.
326
+ }, [columns, readOnly, rows.cellLock, rows.rowLock, shownKeys, start]);
327
+ // The number and the row's menu, side by side, plus the frame around them. Both are always there, so nothing moves.
328
+ const numberWidth = `calc(${Math.max(2, String(count).length)}ch + 18px + var(--nx-tabular-menu))`;
265
329
  const widths = columns.map(columnWidth);
266
330
  const template = [
267
331
  numbered ? numberWidth : null,
@@ -269,15 +333,16 @@ export function TabularForm(props) {
269
333
  const grow = params.grow ?? (FIXED_TYPES.has(params.type) ? 0 : 1);
270
334
  return grow > 0 ? `minmax(${widths[index]}, ${grow}fr)` : widths[index];
271
335
  }),
272
- removable ? "var(--nx-tabular-end)" : null,
273
336
  ]
274
337
  .filter(Boolean)
275
338
  .join(" ");
276
- const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths, removable ? "var(--nx-tabular-end)" : "0px"].join(" + ")})`;
277
- const tableStyle = { "--nx-tabular-columns": template, minWidth };
339
+ const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths].join(" + ")})`;
340
+ const tableStyle = { "--nx-tabular-columns": template, "--nx-tabular-num": numbered ? numberWidth : "0px", minWidth };
278
341
  const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
279
- return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-tabular", `nx-tabular--${size}`, className), style: style, disabled: disabled, "data-problem": invalid || undefined, "data-readonly": readOnly || undefined, "aria-label": props["aria-label"], "aria-describedby": frame.messageId, children: [_jsx(RowsLegend, { id: frame.legendId, label: label, required: required || (props.min ?? 0) > 0, lock: frame.lock }), _jsx("input", { ref: rows.proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", form: props.form, onInvalid: rows.onInvalid }), _jsx("div", { ref: viewportRef, className: "nx-tabular__viewport", tabIndex: -1, style: { maxHeight: cssLength(maxHeight) }, onScroll: updateRange, children: _jsxs("div", { role: "table", className: "nx-tabular__table", style: tableStyle, "aria-labelledby": label ? frame.legendId : undefined, "aria-label": label ? undefined : props["aria-label"], "aria-rowcount": count + 1, children: [_jsx("div", { ref: headRef, role: "rowgroup", className: "nx-tabular__head", children: _jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": 1, children: [numbered && (_jsxs("div", { role: "columnheader", className: "nx-tabular__num", children: [_jsx("span", { "aria-hidden": "true", children: "#" }), _jsx("span", { className: "nx-visually-hidden", children: store.names.title })] })), columns.map(params => {
342
+ return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-tabular", `nx-tabular--${size}`, className), style: style, disabled: disabled, "data-problem": invalid || undefined, "data-readonly": readOnly || undefined, "aria-label": props["aria-label"], "aria-describedby": frame.messageId, children: [_jsx(RowsLegend, { id: frame.legendId, label: label, required: required || (props.min ?? 0) > 0, lock: frame.lock }), _jsx("input", { ref: rows.proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", form: props.form, onInvalid: rows.onInvalid }), _jsx("div", { ref: viewportRef, className: "nx-tabular__viewport", tabIndex: -1, style: { maxHeight: viewportMax ?? cssLength(maxHeight) }, onScroll: updateRange, children: _jsxs("div", { role: "table", className: "nx-tabular__table", style: tableStyle, "aria-labelledby": label ? frame.legendId : undefined, "aria-label": label ? undefined : props["aria-label"], "aria-rowcount": count + 1, children: [_jsx("div", { ref: headRef, role: "rowgroup", className: "nx-tabular__head", children: _jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": 1, children: [numbered && (_jsxs("div", { role: "columnheader", className: "nx-tabular__num", children: [_jsx("span", { "aria-hidden": "true", children: "#" }), _jsx("span", { className: "nx-visually-hidden", children: store.names.title })] })), columns.map(params => {
280
343
  const columnHint = params.hint;
281
- return (_jsxs("div", { role: "columnheader", className: cx("nx-tabular__head-cell", `nx-tabular__cell--${cellKind(params)}`), title: typeof columnHint === "string" ? columnHint : undefined, children: [_jsx("span", { className: "nx-tabular__head-label", children: params.label ?? params.name }), params.required && (_jsx("span", { className: "nx-field__required", "aria-hidden": "true", children: "*" }))] }, params.name));
282
- }), removable && (_jsx("div", { role: "columnheader", className: "nx-tabular__end", children: _jsx("span", { className: "nx-visually-hidden", children: "Remove" }) }))] }) }), _jsx("div", { ref: bodyRef, role: "rowgroup", className: "nx-tabular__body", style: { paddingTop: start * rowHeight, paddingBottom: Math.max(0, count - end) * rowHeight }, onKeyDown: onBodyKeyDown, onFocus: readStatus, onBlur: readStatusSoon, onInput: readStatusSoon, children: count === 0 ? (_jsx("div", { role: "row", className: "nx-tabular__row nx-tabular__row--empty", children: _jsx("div", { role: "cell", className: "nx-tabular__empty", children: frame.emptyMessage }) })) : (shownKeys.map((key, offset) => (_jsx(TabularRow, { store: store, rowKey: key, index: start + offset, count: count, columns: columns, context: context, onAction: onAction }, `${snapshot.generation}:${key}`)))) })] }) }), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, status: status, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
344
+ const locked = lockedColumns.get(params.name);
345
+ const reason = typeof locked === "string" ? locked : locked ? "Read-only" : undefined;
346
+ return (_jsxs("div", { role: "columnheader", className: cx("nx-tabular__head-cell", `nx-tabular__cell--${cellKind(params)}`), title: reason ?? (typeof columnHint === "string" ? columnHint : undefined), "data-nx-locked": locked ? "" : undefined, children: [_jsx("span", { className: "nx-tabular__head-label", children: params.label ?? params.name }), params.required && !locked && (_jsx("span", { className: "nx-field__required", "aria-hidden": "true", children: "*" })), locked && _jsx(InlineIcon, { icon: iconLock, className: "nx-tabular__head-lock", "aria-hidden": "true" })] }, params.name));
347
+ })] }) }), _jsx("div", { ref: bodyRef, role: "rowgroup", className: "nx-tabular__body", style: { paddingTop: start * rowHeight, paddingBottom: Math.max(0, count - end) * rowHeight }, onKeyDown: onBodyKeyDown, onFocus: readStatus, onBlur: readStatusSoon, onInput: readStatusSoon, children: count === 0 ? (_jsx("div", { role: "row", className: "nx-tabular__row nx-tabular__row--empty", children: _jsx("div", { role: "cell", className: "nx-tabular__empty", children: frame.emptyMessage }) })) : (shownKeys.map((key, offset) => (_jsx(TabularRow, { store: store, rowKey: key, index: start + offset, count: count, columns: columns, context: context, onAction: onAction }, `${snapshot.generation}:${key}`)))) })] }) }), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, status: status, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
283
348
  }
@@ -111,7 +111,8 @@ export function TimePicker(props) {
111
111
  return;
112
112
  if (!isPopoverOpen(panel))
113
113
  panel.showPopover();
114
- const place = () => placeUnderAnchor(panel, box.getBoundingClientRect());
114
+ // No arrow: the panel opens flush under the box and is read as the field's own list.
115
+ const place = () => placeUnderAnchor(panel, box.getBoundingClientRect(), { arrow: false });
115
116
  place();
116
117
  const stopFollowing = followAnchor(place);
117
118
  const onPointerDown = (event) => {
@@ -7,7 +7,7 @@ export interface ThemeSwitcherProps extends ThemePickerProps {
7
7
  variant?: ButtonVariant;
8
8
  }
9
9
  /**
10
- * A palette button that opens the theme picker below it. The panel is a native popover:
11
- * it closes with Esc or a click outside, and needs no library.
10
+ * A palette button that opens the theme picker below it, with the panel's arrow on the button. The panel is a native
11
+ * popover: it closes with Esc or a click outside, and needs no library.
12
12
  */
13
13
  export declare function ThemeSwitcher({ label, variant, className, ...pickerProps }: ThemeSwitcherProps): import("react").JSX.Element;
@@ -1,29 +1,49 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { iconPalette, InlineIcon } from "nexus-icons";
4
- import { useId, useRef } from "react";
4
+ import { useId, useLayoutEffect, useRef, useState } from "react";
5
+ import { followAnchor, isPopoverOpen, placeUnderAnchor } from "../../Helpers/PopoverHelpers.js";
5
6
  import { Button } from "../Documents/Button.js";
6
7
  import { ThemePicker } from "./ThemePicker.js";
7
- // Keep in step with the width of .nx-theme-switcher__panel.
8
- const PANEL_WIDTH = 340;
9
- const GAP = 8;
10
8
  /**
11
- * A palette button that opens the theme picker below it. The panel is a native popover:
12
- * it closes with Esc or a click outside, and needs no library.
9
+ * A palette button that opens the theme picker below it, with the panel's arrow on the button. The panel is a native
10
+ * popover: it closes with Esc or a click outside, and needs no library.
13
11
  */
14
12
  export function ThemeSwitcher({ label = "Theme", variant = "ghost", className, ...pickerProps }) {
15
13
  const panelId = useId();
16
14
  const buttonRef = useRef(null);
17
- // Opens under the button, aligned to its right edge, and kept inside the window.
18
- function place(event) {
15
+ const panelRef = useRef(null);
16
+ const [open, setOpen] = useState(false);
17
+ // Set on pointer down while the panel is open: that press closes it (light dismiss), and its click must not reopen it.
18
+ const dismissingRef = useRef(false);
19
+ // The panel is shown from here rather than by the button's popovertarget, because it can be measured only once it is
20
+ // shown: placing it needs its size. It opens under the button, lined up with its right edge and inside the window.
21
+ useLayoutEffect(() => {
22
+ const panel = panelRef.current;
19
23
  const button = buttonRef.current;
20
- if (event.newState !== "open" || !button)
24
+ if (!open || !panel || !button)
21
25
  return;
22
- const box = button.getBoundingClientRect();
23
- const width = Math.min(PANEL_WIDTH, window.innerWidth - GAP * 2);
24
- const left = Math.min(Math.max(GAP, box.right - width), window.innerWidth - width - GAP);
25
- event.currentTarget.style.top = `${box.bottom + GAP}px`;
26
- event.currentTarget.style.left = `${left}px`;
26
+ const place = () => placeUnderAnchor(panel, button.getBoundingClientRect(), { align: "end" });
27
+ place();
28
+ return followAnchor(place);
29
+ }, [open]);
30
+ // Opening sets the state in the click itself, so the panel is placed before the frame it first paints in; closing
31
+ // can also come from Esc or a click outside, which only the event reports.
32
+ function onToggle(event) {
33
+ if (event.newState === "closed")
34
+ setOpen(false);
27
35
  }
28
- return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: buttonRef, variant: variant, className: className, "aria-label": label, title: label, popoverTarget: panelId, icon: _jsx(InlineIcon, { icon: iconPalette }) }), _jsx("div", { id: panelId, popover: "auto", role: "dialog", "aria-label": label, className: "nx-popover nx-theme-switcher__panel", onBeforeToggle: place, children: _jsx(ThemePicker, { ...pickerProps }) })] }));
36
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: buttonRef, variant: variant, className: className, "aria-label": label, title: label, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, icon: _jsx(InlineIcon, { icon: iconPalette }), onPointerDown: () => {
37
+ dismissingRef.current = isPopoverOpen(panelRef.current);
38
+ }, onClick: () => {
39
+ const panel = panelRef.current;
40
+ if (dismissingRef.current)
41
+ dismissingRef.current = false;
42
+ else if (isPopoverOpen(panel))
43
+ panel?.hidePopover();
44
+ else if (panel && typeof panel.showPopover === "function") {
45
+ panel.showPopover();
46
+ setOpen(true);
47
+ }
48
+ } }), _jsx("div", { ref: panelRef, id: panelId, popover: "auto", role: "dialog", "aria-label": label, className: "nx-popover nx-theme-switcher__panel", onToggle: onToggle, children: _jsx(ThemePicker, { ...pickerProps }) })] }));
29
49
  }
@@ -1,18 +1,21 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconAlertCircle, iconArrowAutofitWidth, iconArrowBarToLeft, iconArrowBarToRight, iconArrowDown, iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowsSort, iconChevronDown, iconColumns3, iconDatabase, iconDotsVertical, iconEyeOff, iconFileExport, iconFileImport, iconPinnedOff, iconPrinter, iconRefresh, iconRestore, iconSearch, iconSearchOff, iconSortAscending, iconSortDescending, iconX, InlineIcon, } from "nexus-icons";
3
+ import { iconAlertCircle, iconArrowAutofitWidth, iconArrowBarToLeft, iconArrowBarToRight, iconArrowDown, iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowsSort, iconChevronDown, iconDatabase, iconDotsVertical, iconEyeOff, iconDownload, iconFileExport, iconFileImport, iconPinnedOff, iconPrinter, iconRefresh, iconRestore, 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 { cx } from "../../Helpers/ClassHelpers.js";
6
6
  import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
7
- import { fitColumnWidth, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
7
+ import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
8
8
  import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
9
- import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, MIN_COLUMN_WIDTH, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
9
+ import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, MIN_COLUMN_WIDTH, orderColumns, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
10
10
  import { TableStore } from "../../Helpers/TableStore.js";
11
11
  import { Button } from "../Documents/Button.js";
12
12
  import { Menu } from "../Documents/Menu.js";
13
13
  import { SplitButton } from "../Documents/SplitButton.js";
14
14
  import { TextBox } from "../Inputs/TextBox.js";
15
15
  import { toast } from "../Layouts/Toaster.js";
16
+ import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
17
+ import { openExcelImport } from "../Forms/ExcelImport.js";
18
+ import { TableColumnsPanel } from "./DataTableColumns.js";
16
19
  import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
17
20
  /*
18
21
  * DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
@@ -86,7 +89,7 @@ const sameNumbers = (a, b) => {
86
89
  * />
87
90
  */
88
91
  export function DataTable(props) {
89
- const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, rowActions, onRowOpen, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, pageSize = DEFAULT_PAGE_SIZE, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, size = "md", id, className, style, ref, } = props;
92
+ const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, rowActions, onRowOpen, openOnClick = false, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize = DEFAULT_PAGE_SIZE, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
90
93
  const paging = givenRows ? "client" : (props.paging ?? "client");
91
94
  const reloadable = props.reloadable ?? Boolean(load);
92
95
  const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
@@ -165,7 +168,8 @@ export function DataTable(props) {
165
168
  });
166
169
  }, [stateKey, state.query.pageSize, state.query.sort, hiddenList, state.order, state.widths, state.pinned]);
167
170
  const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
168
- const hideable = columns.filter(column => column.hideable !== false);
171
+ // Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
172
+ const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
169
173
  const labelOf = useCallback((row) => {
170
174
  if (rowLabel)
171
175
  return rowLabel(row);
@@ -220,6 +224,11 @@ export function DataTable(props) {
220
224
  const sized = Object.keys(state.widths).length > 0;
221
225
  const views = useMemo(() => viewColumns(visible, { order: state.order, pinned: state.pinned, widths: state.widths, offsets, reorderable, resizable }), [visible, state.order, state.pinned, state.widths, offsets, reorderable, resizable]);
222
226
  const indexOn = selectable || numbered;
227
+ // The bands above the headings. With no group anywhere the header is the one row it always was.
228
+ const bands = useMemo(() => headBands(views), [views]);
229
+ const grouped = bands.some(band => band.group !== null);
230
+ /** How many rows the header has: a heading with no band above it is as tall as both. */
231
+ const headRows = grouped ? 2 : undefined;
223
232
  // The shadow while columns scroll under a pinned side belongs to its last column, which may be one of the table's own.
224
233
  const pinnedLeft = views.some(view => view.pin === "left");
225
234
  const pinnedRight = views.some(view => view.pin === "right");
@@ -232,13 +241,13 @@ export function DataTable(props) {
232
241
  // How far the pinned cells sit from their edge, measured from the headings before them. It runs after every render
233
242
  // (before the browser paints) and settles at once, since nothing changes when the numbers are the same.
234
243
  useLayoutEffect(() => {
235
- const row = headRef.current;
244
+ const cells = headLeafCells(headRef.current);
236
245
  const root = rootRef.current;
237
- if (!row)
246
+ if (cells.length === 0)
238
247
  return;
239
248
  const next = {};
240
249
  let left = 0;
241
- for (const cell of row.children) {
250
+ for (const cell of cells) {
242
251
  if (cell.getAttribute("data-pin") !== "left")
243
252
  break;
244
253
  const key = cell.getAttribute("data-col");
@@ -247,8 +256,8 @@ export function DataTable(props) {
247
256
  left += cell.getBoundingClientRect().width;
248
257
  }
249
258
  let right = 0;
250
- for (let index = row.children.length - 1; index >= 0; index--) {
251
- const cell = row.children[index];
259
+ for (let index = cells.length - 1; index >= 0; index--) {
260
+ const cell = cells[index];
252
261
  if (cell.getAttribute("data-pin") !== "right")
253
262
  break;
254
263
  const key = cell.getAttribute("data-col");
@@ -289,9 +298,8 @@ export function DataTable(props) {
289
298
  });
290
299
  /* Resizing a column: the table freezes the widths it has, then only the dragged edge moves */
291
300
  const measureWidths = useCallback(() => {
292
- const row = headRef.current;
293
301
  const widths = {};
294
- for (const cell of row?.children ?? []) {
302
+ for (const cell of headLeafCells(headRef.current)) {
295
303
  const key = cell.getAttribute("data-col");
296
304
  if (key)
297
305
  widths[key] = Math.round(cell.getBoundingClientRect().width);
@@ -360,6 +368,26 @@ export function DataTable(props) {
360
368
  return { columns: shown, rows: rowsOut, cellOptions, name: outputName, note };
361
369
  };
362
370
  const serverPage = paging === "server";
371
+ /** What an export to Excel asks the server for: the search and the sort on screen, and the page's own filters. */
372
+ const exportQuery = () => {
373
+ const column = query.sort ? columns.find(candidate => candidate.key === query.sort.key) : undefined;
374
+ return {
375
+ params: excel?.params,
376
+ subject: excel?.subject,
377
+ rows: state.matches,
378
+ query: {
379
+ search: query.search || null,
380
+ sort: column ? (column.sortKey ?? column.key) : null,
381
+ direction: query.sort?.direction,
382
+ filters: excel?.filters?.(),
383
+ },
384
+ };
385
+ };
386
+ /** After an import: the rows it wrote are the server's, so the table reads its page again. */
387
+ const onImported = () => {
388
+ void table.reload({ quiet: true });
389
+ excel?.onImported?.();
390
+ };
363
391
  const optionItems = [
364
392
  ...(exportable && status === "ready" && rows.length > 0
365
393
  ? [{ id: "export", label: serverPage ? "Export this page (CSV)" : "Export as CSV", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => exportTableCsv(output()) }]
@@ -367,9 +395,24 @@ export function DataTable(props) {
367
395
  ...(printable && status === "ready" && rows.length > 0
368
396
  ? [{ id: "print", label: serverPage ? "Print this page" : "Print", icon: _jsx(InlineIcon, { icon: iconPrinter }), onSelect: () => printTable(output()) }]
369
397
  : []),
370
- ...(onImport ? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }] : []),
398
+ // A spreadsheet: the template to fill, the file to bring back, and an export the server writes from what is on
399
+ // screen. Its own Import replaces the page's `onImport` hook.
400
+ ...(excel
401
+ ? [
402
+ { id: "excel-template", label: "Download template", description: `A spreadsheet to fill in`, icon: _jsx(InlineIcon, { icon: iconDownload }), separator: true, onSelect: () => void downloadExcelTemplate(excel.endpoints, { params: excel.params }) },
403
+ { id: "excel-import", label: "Import from a spreadsheet", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: () => openExcelImport({ endpoints: excel.endpoints, subject: excel.subject, params: excel.params, onImported: onImported }) },
404
+ ...(excel.endpoints.export
405
+ ? [{ id: "excel-export", label: "Export to Excel", description: "Everything the search leaves, from the server", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => void exportExcel(excel.endpoints, exportQuery()) }]
406
+ : []),
407
+ ]
408
+ : onImport
409
+ ? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }]
410
+ : []),
371
411
  ...(moreActions ?? []),
372
- ...(table.hasColumnChanges() ? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }] : []),
412
+ // Reset belongs in the Columns panel, beside what it resets; it is offered here only for a table without one.
413
+ ...(!columnsMenu && table.hasColumnChanges()
414
+ ? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }]
415
+ : []),
373
416
  ];
374
417
  const optionEntries = optionItems.flatMap((action, index) => [
375
418
  ...(action.separator && index > 0 ? [{ separator: true }] : []),
@@ -397,7 +440,7 @@ export function DataTable(props) {
397
440
  body = rows.map((row, index) => {
398
441
  const key = pageKeys[index];
399
442
  const selected = state.selected.has(key);
400
- return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen }, key || index));
443
+ return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
401
444
  });
402
445
  }
403
446
  /* The pinned section above the page: the records the user pinned, folded open or shut, remembered on this device */
@@ -413,30 +456,25 @@ export function DataTable(props) {
413
456
  pins?.map((row, index) => {
414
457
  const key = table.keyOf(row);
415
458
  const selected = state.selected.has(key);
416
- return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, pinned: true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen }, `pin:${key || index}`));
459
+ return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, pinned: true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, `pin:${key || index}`));
417
460
  });
418
461
  const selectedRows = selecting ? [...state.selected.values()] : [];
419
462
  const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
420
- const columnItems = hideable.map(column => ({ value: column.key, label: column.label, checked: !state.hidden.has(column.key) }));
421
- return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-table", `nx-table--${size}`, className), style: style, "data-status": status, "data-refreshing": refreshing || undefined, "data-selecting": selecting || undefined, "data-sized": sized || undefined, children: [_jsxs("div", { className: "nx-table__bar", children: [toolbar && _jsx("div", { className: "nx-table__bar-start", children: toolbar }), _jsxs("div", { className: "nx-table__bar-end", children: [searchable ? (_jsx(TextBox, { type: "search", size: "sm", className: "nx-table__search", actionsStart: pageSizePicker, icon: _jsx(InlineIcon, { icon: iconSearch }), placeholder: searchPlaceholder, "aria-label": searchPlaceholder.replace(/…$/, ""), "aria-controls": tableId, defaultValue: query.search, clearable: true, onValueChange: onSearch, onKeyDown: event => {
463
+ // A column's own heading, wherever it stands: across both rows when no band is above it, in the row under its band when one is.
464
+ const headingCell = (view, rowSpan) => (_jsx(HeaderCell, { view: view, sized: sized, rowSpan: rowSpan, sort: query.sort, disabled: status !== "ready" || rows.length < 2, covered: selectedRows.length > 0, canPin: canPinColumn(view.column, { pinnable }), canHide: view.column.hideable !== false && visible.length > 1, neighbours: moveTargets(views, view.column.key), onSort: direction => table.setSort(direction ? { key: view.column.key, direction } : null), onPin: side => table.setPinned(view.column.key, side), onMove: beforeKey => table.moveColumn(view.column.key, beforeKey), onFit: () => fitColumn(view.column.key), onHide: () => table.setHidden(view.column.key, true), onPointerDown: event => onColumnPointerDown(event, view), onResizerDown: event => onResizerPointerDown(event, view.column.key) }, view.column.key));
465
+ return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-table", `nx-table--${size}`, fill && "nx-table--fill", className), style: style, "data-head-rows": headRows, "data-status": status, "data-refreshing": refreshing || undefined, "data-selecting": selecting || undefined, "data-sized": sized || undefined, children: [_jsxs("div", { className: "nx-table__bar", children: [toolbar && _jsx("div", { className: "nx-table__bar-start", children: toolbar }), _jsxs("div", { className: "nx-table__bar-end", children: [searchable ? (_jsx(TextBox, { type: "search", size: "sm", className: "nx-table__search", actionsStart: pageSizePicker, icon: _jsx(InlineIcon, { icon: iconSearch }), placeholder: searchPlaceholder, "aria-label": searchPlaceholder.replace(/…$/, ""), "aria-controls": tableId, defaultValue: query.search, clearable: true, onValueChange: onSearch, onKeyDown: event => {
422
466
  if (event.key === "Enter") {
423
467
  clearTimeout(searchTimer.current);
424
468
  table.setSearch(event.currentTarget.value);
425
469
  }
426
- } }, searchGeneration)) : (pageSizePicker), columnsMenu && hideable.length > 1 && (_jsx(Menu, { items: columnItems, kind: "checkbox", size: "sm", align: "end", closeOnSelect: false, "aria-label": "Columns", onSelect: value => {
427
- const hide = !state.hidden.has(value);
428
- // At least one column stays.
429
- if (hide && visible.length <= 1)
430
- return;
431
- table.setHidden(value, hide);
432
- }, children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconColumns3 }), "aria-label": "Columns", title: "Show or hide columns" }) })), reloadable && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh, className: refreshing ? "nx-spin" : undefined }), "aria-label": "Reload", title: "Reload", disabled: status === "loading", onClick: () => {
470
+ } }, searchGeneration)) : (pageSizePicker), columnsMenu && columns.length > 1 && (_jsx(TableColumnsPanel, { columns: ordered, hidden: state.hidden, pinned: state.pinned, reorderable: reorderable, size: "sm", changed: table.hasColumnChanges(), onToggle: (key, hide) => table.setHidden(key, hide), onOrder: keys => table.setOrder(keys), onReset: () => table.resetColumns() })), reloadable && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh, className: refreshing ? "nx-spin" : undefined }), "aria-label": "Reload", title: "Reload", disabled: status === "loading", onClick: () => {
433
471
  onReload?.();
434
472
  void table.reload({ quiet: true });
435
- } })), optionEntries.length > 0 && (_jsx(Menu, { items: optionEntries, size: "sm", align: "end", "aria-label": "More options", onSelect: value => optionItems.find(action => action.id === value)?.onSelect(), children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), "aria-label": "More options", title: "More options", "data-table-options": true }) })), actions] })] }), _jsxs("div", { className: "nx-table__frame", children: [selectedRows.length > 0 && (_jsx(SelectionBar, { count: selectedRows.length, actions: mass, rows: selectedRows, onClear: () => table.clearSelection(), controls: tableId })), _jsx("div", { className: "nx-table__progress", "aria-hidden": "true" }), _jsx("div", { ref: scrollRef, className: "nx-table__scroll", style: { maxHeight: cssLength(maxHeight) }, children: _jsxs("table", { ref: tableRef, id: tableId, className: "nx-table__table", "aria-label": props["aria-label"], "aria-labelledby": props["aria-labelledby"], "aria-busy": status === "loading" || refreshing || undefined, children: [sized && _jsx(TableColumnGroup, { columns: views, widths: state.widths, frame: frame, colRefs: colRefs }), _jsx("thead", { children: _jsxs("tr", { ref: headRef, className: "nx-table__head", children: [frame.index && (_jsx(TableIndexCell, { heading: true, own: frame.index, number: "#", numbered: numbered, selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", children: _jsx("input", { ref: headerCheck, type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: allOnPage, disabled: rows.length === 0 || status !== "ready", "aria-label": "Select every row on this page", onChange: event => {
436
- lastToggled.current = null;
437
- if (table.selectPage(event.currentTarget.checked) > 0)
438
- warnFull();
439
- } }) })) })), views.map(view => (_jsx(HeaderCell, { view: view, sized: sized, sort: query.sort, disabled: status !== "ready" || rows.length < 2, covered: selectedRows.length > 0, canPin: canPinColumn(view.column, { pinnable }), canHide: view.column.hideable !== false && visible.length > 1, neighbours: moveTargets(views, view.column.key), onSort: direction => table.setSort(direction ? { key: view.column.key, direction } : null), onPin: side => table.setPinned(view.column.key, side), onMove: beforeKey => table.moveColumn(view.column.key, beforeKey), onFit: () => fitColumn(view.column.key), onHide: () => table.setHidden(view.column.key, true), onPointerDown: event => onColumnPointerDown(event, view), onResizerDown: event => onResizerPointerDown(event, view.column.key) }, view.column.key))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler" }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }) }), pins && (_jsxs("tbody", { className: "nx-table__pins", "data-open": pinsOpen || undefined, children: [_jsx(TablePinnedHead, { span: span, label: pinnedLabel, count: pins.length, max: pinnedMax, loading: pinnedLoading, open: pinsOpen, onToggle: togglePins }), pinnedBody] })), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
473
+ } })), optionEntries.length > 0 && (_jsx(Menu, { items: optionEntries, size: "sm", align: "end", "aria-label": "More options", onSelect: value => optionItems.find(action => action.id === value)?.onSelect(), children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), "aria-label": "More options", title: "More options", "data-table-options": true }) })), actions] })] }), _jsxs("div", { className: "nx-table__frame", children: [selectedRows.length > 0 && (_jsx(SelectionBar, { count: selectedRows.length, actions: mass, rows: selectedRows, onClear: () => table.clearSelection(), controls: tableId })), _jsx("div", { className: "nx-table__progress", "aria-hidden": "true" }), _jsx("div", { ref: scrollRef, className: "nx-table__scroll", style: fill ? undefined : { maxHeight: cssLength(maxHeight) }, children: _jsxs("table", { ref: tableRef, id: tableId, className: "nx-table__table", "aria-label": props["aria-label"], "aria-labelledby": props["aria-labelledby"], "aria-busy": status === "loading" || refreshing || undefined, children: [sized && _jsx(TableColumnGroup, { columns: views, widths: state.widths, frame: frame, colRefs: colRefs }), _jsxs("thead", { ref: headRef, className: "nx-table__thead", children: [_jsxs("tr", { className: "nx-table__head", children: [frame.index && (_jsx(TableIndexCell, { heading: true, rowSpan: headRows, own: frame.index, number: "#", numbered: numbered, selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", children: _jsx("input", { ref: headerCheck, type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: allOnPage, disabled: rows.length === 0 || status !== "ready", "aria-label": "Select every row on this page", onChange: event => {
474
+ lastToggled.current = null;
475
+ if (table.selectPage(event.currentTarget.checked) > 0)
476
+ warnFull();
477
+ } }) })) })), bands.map(band => band.group === null ? (headingCell(band.columns[0], headRows)) : (_jsx(GroupHeading, { band: band }, `group:${band.group}:${band.columns[0].column.key}`))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler", rowSpan: headRows }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", rowSpan: headRows, "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }), grouped && _jsx("tr", { className: "nx-table__head nx-table__head--leaves", children: bands.filter(band => band.group !== null).flatMap(band => band.columns.map(view => headingCell(view))) })] }), pins && (_jsxs("tbody", { className: "nx-table__pins", "data-open": pinsOpen || undefined, children: [_jsx(TablePinnedHead, { span: span, label: pinnedLabel, count: pins.length, max: pinnedMax, loading: pinnedLoading, open: pinsOpen, onToggle: togglePins }), pinnedBody] })), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
440
478
  }
441
479
  /** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
442
480
  function moveTargets(views, key) {
@@ -451,12 +489,23 @@ function moveTargets(views, key) {
451
489
  right: at < group.length - 1 ? (group[at + 2]?.column.key ?? null) : undefined,
452
490
  };
453
491
  }
492
+ /**
493
+ * The heading of a band: one cell centred over the columns of its run, which reads as a heading and not as a column —
494
+ * it sorts nothing, has no menu, and has no edge to drag. A band over pinned columns stays with them.
495
+ */
496
+ function GroupHeading({ band }) {
497
+ const { columns } = band;
498
+ const pin = columns[0].pin;
499
+ // Pinned: the band sits where the outermost column of its own side sits.
500
+ const style = pin === "left" ? { left: columns[0].offset } : pin === "right" ? { right: columns[columns.length - 1].offset } : undefined;
501
+ return (_jsx("th", { scope: "colgroup", className: "nx-table__heading nx-table__heading--group", colSpan: columns.length, "data-group": band.group, "data-pin": pin ?? undefined, "data-pin-edge": (pin && columns.some(view => view.edge)) || undefined, style: style, children: _jsx("span", { className: "nx-table__group", children: band.group }) }));
502
+ }
454
503
  /**
455
504
  * A column's heading: a button that sorts (the direction as an arrow and in `aria-sort`), a menu that pins, moves,
456
505
  * fits, and hides the column, and an edge to drag for its width. The heading itself drags into another place.
457
506
  * While the selection bar covers the headings (`covered`), Tab skips what is under it.
458
507
  */
459
- function HeaderCell({ view, sized, sort, disabled, covered, canPin, canHide, neighbours, onSort, onPin, onMove, onFit, onHide, onPointerDown, onResizerDown }) {
508
+ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, canHide, neighbours, onSort, onPin, onMove, onFit, onHide, onPointerDown, onResizerDown }) {
460
509
  const { column } = view;
461
510
  const direction = sort?.key === column.key ? sort.direction : null;
462
511
  const align = columnAlign(column);
@@ -514,15 +563,20 @@ function HeaderCell({ view, sized, sort, disabled, covered, canPin, canHide, nei
514
563
  onHide();
515
564
  }
516
565
  const label = _jsx("span", { className: "nx-table__heading-label", children: column.label });
517
- return (_jsxs("th", { scope: "col", className: cx("nx-table__heading", `nx-table__cell--${align}`), "data-col": column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, "data-movable": view.movable || undefined, style: columnStyle(view, sized), "aria-sort": direction === "asc" ? "ascending" : direction === "desc" ? "descending" : undefined, onPointerDown: onPointerDown, children: [_jsxs("div", { className: "nx-table__heading-inner", children: [sortable ? (_jsxs("button", { type: "button", className: "nx-table__sort", "data-sorted": direction ?? undefined, disabled: disabled && !direction, tabIndex: covered ? -1 : undefined, onClick: () => onSort(direction === "asc" ? "desc" : direction === "desc" ? null : "asc"), title: `Sort by ${column.label}`, children: [label, _jsx(InlineIcon, { icon: direction === "asc" ? iconArrowUp : direction === "desc" ? iconArrowDown : iconArrowsSort, className: "nx-table__sort-icon" })] })) : (label), entries.length > 0 && (_jsx(Menu, { items: entries, size: "sm", align: "end", "aria-label": `${column.label}: column`, onSelect: choose, children: _jsx(Button, { variant: "ghost", size: "sm", className: "nx-table__col-menu", icon: _jsx(InlineIcon, { icon: iconChevronDown }), tabIndex: covered ? -1 : undefined, "aria-label": `${column.label}: column options`, title: "Column options" }) }))] }), view.resizable && (_jsx("span", { className: "nx-table__resizer", "aria-hidden": "true", "data-resize": column.key, title: `Drag to resize ${column.label}, double-click to fit`, onPointerDown: onResizerDown, onDoubleClick: onFit }))] }));
566
+ 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 }))] }));
518
567
  }
519
568
  /** The bar over the column headings while rows are selected: the count, one button for the actions, and Clear. */
520
569
  function SelectionBar({ count, actions, rows, onClear, controls }) {
521
570
  const [running, setRunning] = useState(null);
522
571
  const [chosen, setChosen] = useState(undefined);
523
572
  const alive = useRef(true);
524
- useEffect(() => () => {
525
- alive.current = false;
573
+ // The bar is alive again on every mount, not only the first: React runs this twice in development, and the second
574
+ // mount would otherwise keep the `false` the first one left, so an action's spinner would never stop.
575
+ useEffect(() => {
576
+ alive.current = true;
577
+ return () => {
578
+ alive.current = false;
579
+ };
526
580
  }, []);
527
581
  const usable = actions.filter(action => !action.disabled);
528
582
  const current = actions.find(action => action.id === chosen && !action.disabled) ?? usable[0] ?? actions[0];
@@ -0,0 +1,28 @@
1
+ import type { TableColumn, TablePinSide, TableRow } from "../../Interfaces/TableInterfaces.ts";
2
+ export interface TableColumnsPanelProps<TRow extends TableRow> {
3
+ /** Every column the table has, in the order they are in now - hidden ones among them. */
4
+ columns: readonly TableColumn<TRow>[];
5
+ hidden: ReadonlySet<string>;
6
+ pinned: Readonly<Record<string, TablePinSide>>;
7
+ /** False while the table is not reorderable: the panel then only shows and hides. */
8
+ reorderable?: boolean;
9
+ /** Default `md`. */
10
+ size?: "sm" | "md";
11
+ /** Whether the user has arranged anything, so Reset has something to undo. */
12
+ changed?: boolean;
13
+ onToggle: (key: string, hidden: boolean) => void;
14
+ /** The whole order, by key, after a move. */
15
+ onOrder: (keys: readonly string[]) => void;
16
+ onReset?: () => void;
17
+ "aria-label"?: string;
18
+ }
19
+ /**
20
+ * The button that opens the Columns panel, and the panel itself: a checkbox per column, bands with their columns under
21
+ * them, and a handle to drag a row into another place. The panel is a popover under the button, so it closes on Esc,
22
+ * on a click outside, and on Tab.
23
+ *
24
+ * @example
25
+ * <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
26
+ * onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
27
+ */
28
+ export declare function TableColumnsPanel<TRow extends TableRow>({ columns, hidden, pinned, reorderable, size, changed, onToggle, onOrder, onReset, "aria-label": ariaLabel, }: TableColumnsPanelProps<TRow>): import("react").JSX.Element;