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