nexus-shared 2.0.0 → 3.1.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 +446 -0
- package/LICENSE +21 -21
- package/README.md +1084 -903
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +96 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +44 -28
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +12 -8
- package/dist/Components/Documents/TabButtons.d.ts +29 -8
- package/dist/Components/Documents/TabButtons.js +24 -5
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +342 -71
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +58 -0
- package/dist/Components/Forms/ExcelImport.js +319 -0
- package/dist/Components/Forms/Form.js +9 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/NumberBox.js +24 -2
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +55 -6
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +117 -20
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
- package/dist/Components/Layouts/ThemePicker.js +77 -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 +39 -18
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +262 -60
- package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
- package/dist/Components/Viewers/DataTableColumns.js +200 -0
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
- package/dist/Components/Viewers/DataTableParts.js +24 -12
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +54 -9
- package/dist/Helpers/CrudBackend.js +103 -6
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +296 -0
- package/dist/Helpers/ExcelHelpers.d.ts +126 -0
- package/dist/Helpers/ExcelHelpers.js +344 -0
- package/dist/Helpers/FormStore.d.ts +11 -5
- package/dist/Helpers/FormStore.js +44 -6
- package/dist/Helpers/MessageBuilder.js +1 -2
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +46 -9
- package/dist/Helpers/PopoverHelpers.js +163 -24
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RowsStore.d.ts +14 -3
- package/dist/Helpers/RowsStore.js +37 -3
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +15 -4
- package/dist/Helpers/TableHelpers.js +28 -4
- package/dist/Helpers/TableStore.d.ts +11 -3
- package/dist/Helpers/TableStore.js +16 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/UserPreferences.d.ts +14 -0
- package/dist/Helpers/UserPreferences.js +99 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +85 -7
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +116 -9
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +48 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +10 -0
- package/dist/Shared.Index.js +10 -0
- package/package.json +85 -82
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -151
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -0
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -84
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -774
- package/src/Styles/Nexus.Editor.css +295 -297
- package/src/Styles/Nexus.Excel.css +389 -0
- package/src/Styles/Nexus.Form.css +279 -298
- package/src/Styles/Nexus.Index.css +37 -34
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -249
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -566
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -364
- package/src/Styles/Nexus.Table.css +1386 -1014
- package/src/Styles/Nexus.Theme.Picker.css +286 -159
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -147
- package/src/Styles/Nexus.Toast.css +263 -265
- package/src/Styles/Nexus.Tokens.css +89 -72
- package/src/Styles/Nexus.Tree.css +431 -435
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Icon } from "nexus-icons";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
|
|
7
|
+
/**
|
|
8
|
+
* A person: a round picture, or their initials on a soft tone when there is none or it fails to load. The chat,
|
|
9
|
+
* the dropdown's avatar column and the admin bar all draw this one.
|
|
10
|
+
*/
|
|
11
|
+
export function Avatar({ name, src, size = "md", className, onChangePicture, changeLabel = "Change picture", role, tabIndex }) {
|
|
12
|
+
const url = typeof src === "string" ? src.trim() : "";
|
|
13
|
+
const [failed, setFailed] = useState(null);
|
|
14
|
+
const photo = url !== "" && failed !== url;
|
|
15
|
+
const face = photo ? _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setFailed(url) }) : initials(name);
|
|
16
|
+
const shared = { className: cx("nx-avatar", size !== "md" && `nx-avatar--${size}`, className), "data-tone": photo ? undefined : avatarTone(name) };
|
|
17
|
+
if (!onChangePicture) {
|
|
18
|
+
return (_jsx("span", { ...shared, "aria-hidden": "true", children: face }));
|
|
19
|
+
}
|
|
20
|
+
return (_jsxs("button", { ...shared, type: "button", role: role, tabIndex: tabIndex, "aria-label": changeLabel, title: changeLabel, onClick: onChangePicture, children: [face, _jsx("span", { className: "nx-avatar__veil", "aria-hidden": "true", children: _jsx(Icon, { name: "camera", size: "40%" }) })] }));
|
|
21
|
+
}
|
|
@@ -1,18 +1,25 @@
|
|
|
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, iconChecks, iconChevronDown, iconColumns3, iconDatabase, iconDotsVertical, iconEyeOff, iconDownload, iconFileExport, iconFileImport, iconFilter, iconFilterOff, iconPinnedOff, iconPlus, iconPrinter, iconRefresh, iconRestore, iconRowInsertBottom, iconSearch, iconSearchOff, iconSortAscending, iconSortDescending, iconX, InlineIcon, } from "nexus-icons";
|
|
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
|
-
import { fitColumnWidth, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
|
|
8
|
+
import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
|
|
8
9
|
import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
|
|
9
|
-
import {
|
|
10
|
+
import { asFilterInput, filterNames, filterRequest } from "../../Helpers/TableFilters.js";
|
|
11
|
+
import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, MIN_COLUMN_WIDTH, orderColumns, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
|
|
10
12
|
import { TableStore } from "../../Helpers/TableStore.js";
|
|
13
|
+
import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
|
|
11
14
|
import { Button } from "../Documents/Button.js";
|
|
12
15
|
import { Menu } from "../Documents/Menu.js";
|
|
13
16
|
import { SplitButton } from "../Documents/SplitButton.js";
|
|
14
17
|
import { TextBox } from "../Inputs/TextBox.js";
|
|
15
18
|
import { toast } from "../Layouts/Toaster.js";
|
|
19
|
+
import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
|
|
20
|
+
import { openExcelImport } from "../Forms/ExcelImport.js";
|
|
21
|
+
import { TableColumnsPanel } from "./DataTableColumns.js";
|
|
22
|
+
import { TableFiltersPanel } from "./DataTableFilters.js";
|
|
16
23
|
import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
|
|
17
24
|
/*
|
|
18
25
|
* DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
|
|
@@ -86,26 +93,38 @@ const sameNumbers = (a, b) => {
|
|
|
86
93
|
* />
|
|
87
94
|
*/
|
|
88
95
|
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
|
|
96
|
+
const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize: ownPageSize, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
|
|
90
97
|
const paging = givenRows ? "client" : (props.paging ?? "client");
|
|
91
98
|
const reloadable = props.reloadable ?? Boolean(load);
|
|
92
99
|
const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
|
|
93
100
|
// A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer.
|
|
94
101
|
const numbered = props.numbered ?? selectable;
|
|
95
|
-
|
|
102
|
+
// The person's choices in the quick preference panel, once made, go over what the table names itself.
|
|
103
|
+
const preferences = useUserPreferences();
|
|
104
|
+
const openOnClick = preferences.rowClick ?? ownOpenOnClick ?? false;
|
|
105
|
+
/** The page size the table opens with: the person's default when this table offers it, else the table's own. */
|
|
106
|
+
const openingPageSize = () => {
|
|
107
|
+
const preferred = readUserPreferences().pageSize;
|
|
108
|
+
return preferred !== null && pageSizes.includes(preferred) ? preferred : (ownPageSize ?? DEFAULT_PAGE_SIZE);
|
|
109
|
+
};
|
|
110
|
+
const [own] = useState(() => (givenTable ? null : new TableStore(ownPageSize ?? DEFAULT_PAGE_SIZE)));
|
|
96
111
|
const table = givenTable ?? own;
|
|
97
|
-
|
|
112
|
+
// Prepared once for the store and the panel: the same params without what would make them filter on their own.
|
|
113
|
+
const filterInputs = useMemo(() => props.filterInputs?.map(asFilterInput), [props.filterInputs]);
|
|
114
|
+
table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, filterInputs });
|
|
98
115
|
useImperativeHandle(ref, () => table, [table]);
|
|
99
116
|
const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
|
|
100
|
-
const
|
|
117
|
+
const dates = useDatePreferences();
|
|
101
118
|
const timeZone = useBrowserTimeZone();
|
|
102
|
-
const
|
|
119
|
+
const numbers = useNumberLocale();
|
|
120
|
+
const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers]);
|
|
103
121
|
const tableId = useId();
|
|
104
122
|
// The search box is uncontrolled: a new key clears it when a new list starts over.
|
|
105
123
|
const [searchGeneration, setSearchGeneration] = useState(0);
|
|
106
124
|
// The pinned section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
|
|
107
125
|
const [pinsOpen, setPinsOpen] = useState(false);
|
|
108
126
|
const searchTimer = useRef(undefined);
|
|
127
|
+
const searchRef = useRef(null);
|
|
109
128
|
const started = useRef(false);
|
|
110
129
|
const loadedKey = useRef(loadKey);
|
|
111
130
|
// First rows: saved settings first, then the rows given or the first load. A layout effect, so rows that are at hand
|
|
@@ -115,7 +134,7 @@ export function DataTable(props) {
|
|
|
115
134
|
started.current = true;
|
|
116
135
|
const saved = readSaved(stateKey);
|
|
117
136
|
table.restore({
|
|
118
|
-
pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize :
|
|
137
|
+
pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
|
|
119
138
|
sort: saved?.sort !== undefined ? saved.sort : defaultSort,
|
|
120
139
|
hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
|
|
121
140
|
order: saved?.order,
|
|
@@ -156,7 +175,8 @@ export function DataTable(props) {
|
|
|
156
175
|
if (!stateKey || !started.current)
|
|
157
176
|
return;
|
|
158
177
|
writeSaved(stateKey, {
|
|
159
|
-
|
|
178
|
+
// Only a size the person picked here: one equal to the default is not kept, so a new default still reaches it.
|
|
179
|
+
pageSize: state.query.pageSize === openingPageSize() ? undefined : state.query.pageSize,
|
|
160
180
|
sort: state.query.sort,
|
|
161
181
|
hidden: hiddenList,
|
|
162
182
|
order: state.order.length ? [...state.order] : undefined,
|
|
@@ -164,8 +184,23 @@ export function DataTable(props) {
|
|
|
164
184
|
pinned: Object.keys(state.pinned).length ? state.pinned : undefined,
|
|
165
185
|
});
|
|
166
186
|
}, [stateKey, state.query.pageSize, state.query.sort, hiddenList, state.order, state.widths, state.pinned]);
|
|
187
|
+
// A new default page size from the panel reaches an open table where no size was picked. Only a change counts: on the
|
|
188
|
+
// first effects the save above still holds the store's opening size and has not written the restored one yet, so a
|
|
189
|
+
// check then would find nothing saved and undo a size picked earlier. The size is read from storage, not from the
|
|
190
|
+
// hook's value, which is the server's default while the page hydrates.
|
|
191
|
+
const preferredSize = useRef(preferences.pageSize);
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (preferredSize.current === preferences.pageSize)
|
|
194
|
+
return;
|
|
195
|
+
preferredSize.current = preferences.pageSize;
|
|
196
|
+
if (!started.current || readSaved(stateKey)?.pageSize)
|
|
197
|
+
return;
|
|
198
|
+
table.setPageSize(openingPageSize());
|
|
199
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
200
|
+
}, [preferences.pageSize]);
|
|
167
201
|
const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
|
|
168
|
-
|
|
202
|
+
// Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
|
|
203
|
+
const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
|
|
169
204
|
const labelOf = useCallback((row) => {
|
|
170
205
|
if (rowLabel)
|
|
171
206
|
return rowLabel(row);
|
|
@@ -220,6 +255,11 @@ export function DataTable(props) {
|
|
|
220
255
|
const sized = Object.keys(state.widths).length > 0;
|
|
221
256
|
const views = useMemo(() => viewColumns(visible, { order: state.order, pinned: state.pinned, widths: state.widths, offsets, reorderable, resizable }), [visible, state.order, state.pinned, state.widths, offsets, reorderable, resizable]);
|
|
222
257
|
const indexOn = selectable || numbered;
|
|
258
|
+
// The bands above the headings. With no group anywhere the header is the one row it always was.
|
|
259
|
+
const bands = useMemo(() => headBands(views), [views]);
|
|
260
|
+
const grouped = bands.some(band => band.group !== null);
|
|
261
|
+
/** How many rows the header has: a heading with no band above it is as tall as both. */
|
|
262
|
+
const headRows = grouped ? 2 : undefined;
|
|
223
263
|
// The shadow while columns scroll under a pinned side belongs to its last column, which may be one of the table's own.
|
|
224
264
|
const pinnedLeft = views.some(view => view.pin === "left");
|
|
225
265
|
const pinnedRight = views.some(view => view.pin === "right");
|
|
@@ -232,13 +272,13 @@ export function DataTable(props) {
|
|
|
232
272
|
// How far the pinned cells sit from their edge, measured from the headings before them. It runs after every render
|
|
233
273
|
// (before the browser paints) and settles at once, since nothing changes when the numbers are the same.
|
|
234
274
|
useLayoutEffect(() => {
|
|
235
|
-
const
|
|
275
|
+
const cells = headLeafCells(headRef.current);
|
|
236
276
|
const root = rootRef.current;
|
|
237
|
-
if (
|
|
277
|
+
if (cells.length === 0)
|
|
238
278
|
return;
|
|
239
279
|
const next = {};
|
|
240
280
|
let left = 0;
|
|
241
|
-
for (const cell of
|
|
281
|
+
for (const cell of cells) {
|
|
242
282
|
if (cell.getAttribute("data-pin") !== "left")
|
|
243
283
|
break;
|
|
244
284
|
const key = cell.getAttribute("data-col");
|
|
@@ -247,8 +287,8 @@ export function DataTable(props) {
|
|
|
247
287
|
left += cell.getBoundingClientRect().width;
|
|
248
288
|
}
|
|
249
289
|
let right = 0;
|
|
250
|
-
for (let index =
|
|
251
|
-
const cell =
|
|
290
|
+
for (let index = cells.length - 1; index >= 0; index--) {
|
|
291
|
+
const cell = cells[index];
|
|
252
292
|
if (cell.getAttribute("data-pin") !== "right")
|
|
253
293
|
break;
|
|
254
294
|
const key = cell.getAttribute("data-col");
|
|
@@ -289,9 +329,8 @@ export function DataTable(props) {
|
|
|
289
329
|
});
|
|
290
330
|
/* Resizing a column: the table freezes the widths it has, then only the dragged edge moves */
|
|
291
331
|
const measureWidths = useCallback(() => {
|
|
292
|
-
const row = headRef.current;
|
|
293
332
|
const widths = {};
|
|
294
|
-
for (const cell of
|
|
333
|
+
for (const cell of headLeafCells(headRef.current)) {
|
|
295
334
|
const key = cell.getAttribute("data-col");
|
|
296
335
|
if (key)
|
|
297
336
|
widths[key] = Math.round(cell.getBoundingClientRect().width);
|
|
@@ -352,29 +391,128 @@ export function DataTable(props) {
|
|
|
352
391
|
});
|
|
353
392
|
};
|
|
354
393
|
/* The bar above the table, and the options menu */
|
|
394
|
+
// The labels of the filters that are on: the menu's second line, the dot's title, and an export's note.
|
|
395
|
+
const filtersOn = filterNames(filterInputs ?? [], query.filters);
|
|
355
396
|
const outputName = exportName ?? props["aria-label"] ?? "Table";
|
|
356
397
|
const output = () => {
|
|
357
398
|
const shown = views.map(view => view.column);
|
|
358
399
|
const rowsOut = table.getViewRows();
|
|
359
|
-
const note = [`${formatCount(rowsOut.length)} ${rowsOut.length === 1 ? "row" : "rows"}`, query.search && `search: ${query.search}`
|
|
400
|
+
const note = [`${formatCount(rowsOut.length)} ${rowsOut.length === 1 ? "row" : "rows"}`, query.search && `search: ${query.search}`, filtersOn.length && `filters: ${filtersOn.join(", ")}`]
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.join(" · ");
|
|
360
403
|
return { columns: shown, rows: rowsOut, cellOptions, name: outputName, note };
|
|
361
404
|
};
|
|
362
405
|
const serverPage = paging === "server";
|
|
406
|
+
/** What an export to Excel asks the server for: the search, the filters and the sort on screen, and the page's own filters. */
|
|
407
|
+
const exportQuery = () => {
|
|
408
|
+
const column = query.sort ? columns.find(candidate => candidate.key === query.sort.key) : undefined;
|
|
409
|
+
return {
|
|
410
|
+
params: excel?.params,
|
|
411
|
+
subject: excel?.subject,
|
|
412
|
+
rows: state.matches,
|
|
413
|
+
query: {
|
|
414
|
+
search: query.search || null,
|
|
415
|
+
sort: column ? (column.sortKey ?? column.key) : null,
|
|
416
|
+
direction: query.sort?.direction,
|
|
417
|
+
filters: { ...filterRequest(query.filters, filterInputs ?? [], columns), ...excel?.filters?.() },
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* After an import: the rows it wrote are the server's, so the table reads its page again - after the page has heard
|
|
423
|
+
* of it. A `Crud` drops the lists it keeps in `onImported`, and a reload asked first was answered from that cache: on a
|
|
424
|
+
* page just opened, the list stayed as it was until the person reloaded it themselves (the owner, 2026-10-07).
|
|
425
|
+
*/
|
|
426
|
+
const onImported = () => {
|
|
427
|
+
excel?.onImported?.();
|
|
428
|
+
void table.reload({ quiet: true });
|
|
429
|
+
};
|
|
430
|
+
// The Filters and Columns panels open from the options menu, under its button.
|
|
431
|
+
const optionsRef = useRef(null);
|
|
432
|
+
const filtersRef = useRef(null);
|
|
433
|
+
const columnsRef = useRef(null);
|
|
434
|
+
const hasColumnsPanel = columnsMenu && columns.length > 1;
|
|
435
|
+
// The stylesheet takes the bar's steps by the table's width. A bar wider than those steps expect (a long Add label,
|
|
436
|
+
// the page's own buttons, cozy density) is squeezed into the next step as soon as a line would break, a frame after
|
|
437
|
+
// its size changed, so the ResizeObserver never loops. Reload waits in the options menu whenever its button is hidden:
|
|
438
|
+
// the menu asks the button, so what decides it is written once, in the stylesheet.
|
|
439
|
+
const barRef = useRef(null);
|
|
440
|
+
const reloadRef = useRef(null);
|
|
441
|
+
const [reloadHidden, setReloadHidden] = useState(false);
|
|
442
|
+
const addPart = Boolean(add || actions);
|
|
443
|
+
const listsPart = Boolean(toolbar);
|
|
444
|
+
useLayoutEffect(() => {
|
|
445
|
+
const root = rootRef.current;
|
|
446
|
+
const bar = barRef.current;
|
|
447
|
+
if (!root || !bar)
|
|
448
|
+
return;
|
|
449
|
+
let frame = 0;
|
|
450
|
+
const fit = () => {
|
|
451
|
+
squeezeBar(bar);
|
|
452
|
+
setReloadHidden(reloadRef.current ? getComputedStyle(reloadRef.current).display === "none" : false);
|
|
453
|
+
};
|
|
454
|
+
const observer = new ResizeObserver(() => {
|
|
455
|
+
cancelAnimationFrame(frame);
|
|
456
|
+
frame = requestAnimationFrame(fit);
|
|
457
|
+
});
|
|
458
|
+
observer.observe(root);
|
|
459
|
+
for (const part of Array.from(bar.children))
|
|
460
|
+
observer.observe(part);
|
|
461
|
+
return () => {
|
|
462
|
+
observer.disconnect();
|
|
463
|
+
cancelAnimationFrame(frame);
|
|
464
|
+
};
|
|
465
|
+
}, [addPart, listsPart, searchable, reloadable]);
|
|
466
|
+
const reload = () => {
|
|
467
|
+
onReload?.();
|
|
468
|
+
void table.reload({ quiet: true });
|
|
469
|
+
};
|
|
470
|
+
// A spreadsheet: the template to fill, the file to bring back, and an export the server writes from what is on
|
|
471
|
+
// screen. With an Add, they are other ways to add, under its arrow; without one, they are in the options menu.
|
|
472
|
+
const sheetItems = excel
|
|
473
|
+
? [
|
|
474
|
+
{ id: "excel-template", label: "Download template", description: `A spreadsheet to fill in`, icon: _jsx(InlineIcon, { icon: iconDownload }), separator: true, onSelect: () => void downloadExcelTemplate(excel.endpoints, { params: excel.params }) },
|
|
475
|
+
{ id: "excel-import", label: "Import from a spreadsheet", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: () => openExcelImport({ endpoints: excel.endpoints, subject: excel.subject, params: excel.params, onImported: onImported }) },
|
|
476
|
+
// A bulk insert is an Add of many rows, so it is offered only beside an Add: a CRUD shows its Add only to a person
|
|
477
|
+
// who may create, and nobody types fifty rows to be refused at the end.
|
|
478
|
+
...(excel.bulk === false || !add
|
|
479
|
+
? []
|
|
480
|
+
: [{ id: "excel-bulk", label: "Bulk insert", description: "Type the rows here, with no file", icon: _jsx(InlineIcon, { icon: iconRowInsertBottom }), onSelect: () => openExcelImport({ endpoints: excel.endpoints, subject: excel.subject, params: excel.params, onImported: onImported, mode: "bulk" }) }]),
|
|
481
|
+
...(excel.endpoints.export
|
|
482
|
+
? [{ id: "excel-export", label: "Export to Excel", description: "Everything the search and filters leave, from the server", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => void exportExcel(excel.endpoints, exportQuery()) }]
|
|
483
|
+
: []),
|
|
484
|
+
]
|
|
485
|
+
: [];
|
|
486
|
+
// In the bar it is `block`: as wide as its own label there, and the whole line once the Add section takes one.
|
|
487
|
+
const addButton = (block = false) => add && (_jsx(Button, { variant: "primary", size: "sm", block: block, icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: add.onClick, children: add.label }));
|
|
363
488
|
const optionItems = [
|
|
489
|
+
...(filterInputs?.length
|
|
490
|
+
? [{ id: "filters", label: "Filters", description: filtersOn.join(", ") || undefined, icon: _jsx(InlineIcon, { icon: iconFilter }), onSelect: () => filtersRef.current?.open() }]
|
|
491
|
+
: []),
|
|
492
|
+
...(filtersOn.length ? [{ id: "clear-filters", label: "Clear filters", icon: _jsx(InlineIcon, { icon: iconFilterOff }), onSelect: () => table.setFilters({}) }] : []),
|
|
493
|
+
...(hasColumnsPanel ? [{ id: "columns", label: "Columns", icon: _jsx(InlineIcon, { icon: iconColumns3 }), onSelect: () => columnsRef.current?.open() }] : []),
|
|
364
494
|
...(exportable && status === "ready" && rows.length > 0
|
|
365
495
|
? [{ id: "export", label: serverPage ? "Export this page (CSV)" : "Export as CSV", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => exportTableCsv(output()) }]
|
|
366
496
|
: []),
|
|
367
497
|
...(printable && status === "ready" && rows.length > 0
|
|
368
498
|
? [{ id: "print", label: serverPage ? "Print this page" : "Print", icon: _jsx(InlineIcon, { icon: iconPrinter }), onSelect: () => printTable(output()) }]
|
|
369
499
|
: []),
|
|
370
|
-
|
|
500
|
+
// The sheet's own Import replaces the page's `onImport` hook.
|
|
501
|
+
...(excel
|
|
502
|
+
? add
|
|
503
|
+
? []
|
|
504
|
+
: sheetItems
|
|
505
|
+
: onImport
|
|
506
|
+
? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }]
|
|
507
|
+
: []),
|
|
371
508
|
...(moreActions ?? []),
|
|
372
|
-
|
|
509
|
+
// Reset belongs in the Columns panel, beside what it resets; it is offered here only for a table without one.
|
|
510
|
+
...(!columnsMenu && table.hasColumnChanges()
|
|
511
|
+
? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }]
|
|
512
|
+
: []),
|
|
513
|
+
...(reloadable && reloadHidden ? [{ id: "reload", label: "Reload", icon: _jsx(InlineIcon, { icon: iconRefresh }), separator: true, onSelect: reload }] : []),
|
|
373
514
|
];
|
|
374
|
-
const optionEntries = optionItems
|
|
375
|
-
...(action.separator && index > 0 ? [{ separator: true }] : []),
|
|
376
|
-
{ value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled },
|
|
377
|
-
]);
|
|
515
|
+
const optionEntries = menuEntries(optionItems);
|
|
378
516
|
const pageSizePicker = _jsx(TablePageSize, { pageSize: query.pageSize, pageSizes: pageSizes, compact: searchable, onChange: value => table.setPageSize(value) });
|
|
379
517
|
let body;
|
|
380
518
|
if (status === "loading") {
|
|
@@ -383,21 +521,30 @@ export function DataTable(props) {
|
|
|
383
521
|
else if (status === "error") {
|
|
384
522
|
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" }) }));
|
|
385
523
|
}
|
|
386
|
-
else if (rows.length === 0 && query.search) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
524
|
+
else if (rows.length === 0 && (query.search || query.filters)) {
|
|
525
|
+
// What left no rows, the search, the filters or both, and a way to clear each.
|
|
526
|
+
const title = !query.filters ? `Nothing matches “${query.search}”.` : query.search ? `Nothing matches “${query.search}” with these filters.` : "No rows match the filters.";
|
|
527
|
+
const detail = !query.filters ? "Check the spelling, or search for something shorter." : query.search ? undefined : "Change them, or clear them.";
|
|
528
|
+
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: () => {
|
|
529
|
+
clearTimeout(searchTimer.current);
|
|
530
|
+
flushSync(() => {
|
|
531
|
+
setSearchGeneration(count => count + 1);
|
|
532
|
+
table.setSearch("");
|
|
533
|
+
});
|
|
534
|
+
searchRef.current?.focus();
|
|
535
|
+
}, children: "Clear search" })), query.filters && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconFilterOff }), onClick: () => {
|
|
536
|
+
optionsRef.current?.focus();
|
|
537
|
+
table.setFilters({});
|
|
538
|
+
}, children: "Clear filters" }))] }));
|
|
392
539
|
}
|
|
393
540
|
else if (rows.length === 0) {
|
|
394
|
-
body = (_jsx(TableMessageRow, { span: span, icon: iconDatabase, title: emptyMessage, children: emptyAction }));
|
|
541
|
+
body = (_jsx(TableMessageRow, { span: span, icon: iconDatabase, title: emptyMessage, children: emptyAction === undefined ? addButton() : emptyAction }));
|
|
395
542
|
}
|
|
396
543
|
else {
|
|
397
544
|
body = rows.map((row, index) => {
|
|
398
545
|
const key = pageKeys[index];
|
|
399
546
|
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));
|
|
547
|
+
return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
|
|
401
548
|
});
|
|
402
549
|
}
|
|
403
550
|
/* The pinned section above the page: the records the user pinned, folded open or shut, remembered on this device */
|
|
@@ -413,30 +560,37 @@ export function DataTable(props) {
|
|
|
413
560
|
pins?.map((row, index) => {
|
|
414
561
|
const key = table.keyOf(row);
|
|
415
562
|
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}`));
|
|
563
|
+
return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, pinned: true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, `pin:${key || index}`));
|
|
417
564
|
});
|
|
418
565
|
const selectedRows = selecting ? [...state.selected.values()] : [];
|
|
566
|
+
// Select again by what the rows are: exactly the rows on the page that pass, or the whole page.
|
|
567
|
+
const pick = (label, test) => {
|
|
568
|
+
const matching = test ? rows.filter(test) : rows;
|
|
569
|
+
if (matching.length === 0)
|
|
570
|
+
return toast(`No row on this page is ${label.toLowerCase()}.`);
|
|
571
|
+
table.clearSelection();
|
|
572
|
+
table.select(matching, true);
|
|
573
|
+
};
|
|
574
|
+
const picks = selectBy?.length
|
|
575
|
+
? [
|
|
576
|
+
{ id: "page", label: "All on this page", icon: _jsx(InlineIcon, { icon: iconChecks }), onSelect: () => pick("on this page") },
|
|
577
|
+
...selectBy.map((rule, index) => ({ id: `by-${index}`, label: rule.label, icon: rule.icon, separator: index === 0, onSelect: () => pick(rule.label, rule.test) })),
|
|
578
|
+
]
|
|
579
|
+
: [];
|
|
419
580
|
const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
onReload?.();
|
|
434
|
-
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) })] }));
|
|
581
|
+
// A column's own heading, wherever it stands: across both rows when no band is above it, in the row under its band when one is.
|
|
582
|
+
const headingCell = (view, rowSpan) => (_jsx(HeaderCell, { view: view, sized: sized, rowSpan: rowSpan, sort: query.sort, disabled: status !== "ready" || rows.length < 2, covered: selectedRows.length > 0, canPin: canPinColumn(view.column, { pinnable }), canHide: view.column.hideable !== false && visible.length > 1, neighbours: moveTargets(views, view.column.key), onSort: direction => table.setSort(direction ? { key: view.column.key, direction } : null), onPin: side => table.setPinned(view.column.key, side), onMove: beforeKey => table.moveColumn(view.column.key, beforeKey), onFit: () => fitColumn(view.column.key), onHide: () => table.setHidden(view.column.key, true), onPointerDown: event => onColumnPointerDown(event, view), onResizerDown: event => onResizerPointerDown(event, view.column.key) }, view.column.key));
|
|
583
|
+
return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-table", `nx-table--${size}`, fill && "nx-table--fill", className), style: style, "data-head-rows": headRows, "data-status": status, "data-refreshing": refreshing || undefined, "data-selecting": selecting || undefined, "data-sized": sized || undefined, children: [_jsxs("div", { ref: barRef, className: "nx-table__bar", children: [addPart && (_jsxs("div", { className: "nx-table__bar-add", children: [add &&
|
|
584
|
+
(sheetItems.length > 0 ? (_jsx(SplitButton, { fixed: true, block: true, size: "sm", menuLabel: "More ways to add", options: [{ value: "add", label: add.label, icon: _jsx(InlineIcon, { icon: iconPlus }) }, ...sheetItems.map(item => ({ value: item.id, label: item.label, icon: item.icon, description: item.description }))], onAction: value => (value === "add" ? add.onClick() : sheetItems.find(item => item.id === value)?.onSelect()) })) : (addButton(true))), actions] })), toolbar && _jsx("div", { className: "nx-table__bar-start", children: toolbar }), _jsxs("div", { className: "nx-table__bar-end", children: [filterInputs?.length ? (_jsx(TableFiltersPanel, { ref: filtersRef, anchor: optionsRef, inputs: filterInputs, value: query.filters, delay: searchDelay, matches: state.matches, total: state.total, onChange: values => table.setFilters(values) })) : null, hasColumnsPanel && (_jsx(TableColumnsPanel, { ref: columnsRef, anchor: optionsRef, columns: ordered, hidden: state.hidden, pinned: state.pinned, reorderable: reorderable, size: "sm", changed: table.hasColumnChanges(), onToggle: (key, hide) => table.setHidden(key, hide), onOrder: keys => table.setOrder(keys), onReset: () => table.resetColumns() })), reloadable && (_jsx(Button, { ref: reloadRef, size: "sm", variant: "ghost", className: "nx-table__reload", icon: _jsx(InlineIcon, { icon: iconRefresh, className: refreshing ? "nx-spin" : undefined }), "aria-label": "Reload", title: "Reload", disabled: status === "loading", onClick: reload })), optionEntries.length > 0 && (_jsx(Menu, { items: optionEntries, size: "sm", align: "end", "aria-label": "More options", onSelect: value => optionItems.find(action => action.id === value)?.onSelect(), children: _jsx(Button, { ref: optionsRef, size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), dot: filtersOn.length > 0 && "primary", "aria-label": "More options", title: filtersOn.length ? `More options · ${filtersOn.length} ${filtersOn.length === 1 ? "filter" : "filters"} on` : "More options", "data-table-options": true }) })), !searchable && pageSizePicker] }), searchable && (_jsx(TextBox, { ref: searchRef, type: "search", size: "sm", className: "nx-table__search", actionsStart: pageSizePicker, icon: _jsx(InlineIcon, { icon: iconSearch }), placeholder: searchPlaceholder, "aria-label": searchPlaceholder.replace(/…$/, ""), "aria-controls": tableId, defaultValue: query.search, clearable: true, onValueChange: onSearch, onKeyDown: event => {
|
|
585
|
+
if (event.key === "Enter") {
|
|
586
|
+
clearTimeout(searchTimer.current);
|
|
587
|
+
table.setSearch(event.currentTarget.value);
|
|
588
|
+
}
|
|
589
|
+
} }, 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 => {
|
|
590
|
+
lastToggled.current = null;
|
|
591
|
+
if (table.selectPage(event.currentTarget.checked) > 0)
|
|
592
|
+
warnFull();
|
|
593
|
+
} }) })) })), bands.map(band => band.group === null ? (headingCell(band.columns[0], headRows)) : (_jsx(GroupHeading, { band: band }, `group:${band.group}:${band.columns[0].column.key}`))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler", rowSpan: headRows }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", rowSpan: headRows, "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }), grouped && _jsx("tr", { className: "nx-table__head nx-table__head--leaves", children: bands.filter(band => band.group !== null).flatMap(band => band.columns.map(view => headingCell(view))) })] }), pins && (_jsxs("tbody", { className: "nx-table__pins", "data-open": pinsOpen || undefined, children: [_jsx(TablePinnedHead, { span: span, label: pinnedLabel, count: pins.length, max: pinnedMax, loading: pinnedLoading, open: pinsOpen, onToggle: togglePins }), pinnedBody] })), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
|
|
440
594
|
}
|
|
441
595
|
/** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
|
|
442
596
|
function moveTargets(views, key) {
|
|
@@ -451,12 +605,23 @@ function moveTargets(views, key) {
|
|
|
451
605
|
right: at < group.length - 1 ? (group[at + 2]?.column.key ?? null) : undefined,
|
|
452
606
|
};
|
|
453
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* The heading of a band: one cell centred over the columns of its run, which reads as a heading and not as a column —
|
|
610
|
+
* it sorts nothing, has no menu, and has no edge to drag. A band over pinned columns stays with them.
|
|
611
|
+
*/
|
|
612
|
+
function GroupHeading({ band }) {
|
|
613
|
+
const { columns } = band;
|
|
614
|
+
const pin = columns[0].pin;
|
|
615
|
+
// Pinned: the band sits where the outermost column of its own side sits.
|
|
616
|
+
const style = pin === "left" ? { left: columns[0].offset } : pin === "right" ? { right: columns[columns.length - 1].offset } : undefined;
|
|
617
|
+
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 }) }));
|
|
618
|
+
}
|
|
454
619
|
/**
|
|
455
620
|
* A column's heading: a button that sorts (the direction as an arrow and in `aria-sort`), a menu that pins, moves,
|
|
456
621
|
* fits, and hides the column, and an edge to drag for its width. The heading itself drags into another place.
|
|
457
622
|
* While the selection bar covers the headings (`covered`), Tab skips what is under it.
|
|
458
623
|
*/
|
|
459
|
-
function HeaderCell({ view, sized, sort, disabled, covered, canPin, canHide, neighbours, onSort, onPin, onMove, onFit, onHide, onPointerDown, onResizerDown }) {
|
|
624
|
+
function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, canHide, neighbours, onSort, onPin, onMove, onFit, onHide, onPointerDown, onResizerDown }) {
|
|
460
625
|
const { column } = view;
|
|
461
626
|
const direction = sort?.key === column.key ? sort.direction : null;
|
|
462
627
|
const align = columnAlign(column);
|
|
@@ -514,15 +679,52 @@ function HeaderCell({ view, sized, sort, disabled, covered, canPin, canHide, nei
|
|
|
514
679
|
onHide();
|
|
515
680
|
}
|
|
516
681
|
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 }))] }));
|
|
682
|
+
return (_jsxs("th", { scope: "col", className: cx("nx-table__heading", `nx-table__cell--${align}`), rowSpan: rowSpan, "data-col": column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, "data-movable": view.movable || undefined, style: columnStyle(view, sized), "aria-sort": direction === "asc" ? "ascending" : direction === "desc" ? "descending" : undefined, onPointerDown: onPointerDown, children: [_jsxs("div", { className: "nx-table__heading-inner", children: [sortable ? (_jsxs("button", { type: "button", className: "nx-table__sort", "data-sorted": direction ?? undefined, disabled: disabled && !direction, tabIndex: covered ? -1 : undefined, onClick: () => onSort(direction === "asc" ? "desc" : direction === "desc" ? null : "asc"), title: `Sort by ${column.label}`, children: [label, _jsx(InlineIcon, { icon: direction === "asc" ? iconArrowUp : direction === "desc" ? iconArrowDown : iconArrowsSort, className: "nx-table__sort-icon" })] })) : (label), entries.length > 0 && (_jsx(Menu, { items: entries, size: "sm", align: "end", "aria-label": `${column.label}: column`, onSelect: choose, children: _jsx(Button, { variant: "ghost", size: "sm", className: "nx-table__col-menu", icon: _jsx(InlineIcon, { icon: iconChevronDown }), tabIndex: covered ? -1 : undefined, "aria-label": `${column.label}: column options`, title: "Column options" }) }))] }), view.resizable && (_jsx("span", { className: "nx-table__resizer", "aria-hidden": "true", "data-resize": column.key, title: `Drag to resize ${column.label}, double-click to fit`, onPointerDown: onResizerDown, onDoubleClick: onFit }))] }));
|
|
683
|
+
}
|
|
684
|
+
/** The steps a bar is squeezed into when its parts do not fit, each one keeping the steps before it. */
|
|
685
|
+
const BAR_SQUEEZES = ["search", "search reload", "search reload add"];
|
|
686
|
+
/** Whether the parts of the bar that are not meant to take a line of their own share one line. */
|
|
687
|
+
function barFits(bar) {
|
|
688
|
+
let middle = null;
|
|
689
|
+
for (const part of Array.from(bar.children)) {
|
|
690
|
+
if (part.offsetWidth === 0 || getComputedStyle(part).flexBasis === "100%")
|
|
691
|
+
continue;
|
|
692
|
+
const at = part.offsetTop + part.offsetHeight / 2;
|
|
693
|
+
if (middle === null)
|
|
694
|
+
middle = at;
|
|
695
|
+
else if (Math.abs(at - middle) > 4)
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
/** Takes the squeeze off, then squeezes the bar one step at a time until it fits (or runs out of steps). */
|
|
701
|
+
function squeezeBar(bar) {
|
|
702
|
+
bar.removeAttribute("data-squeeze");
|
|
703
|
+
for (const squeeze of BAR_SQUEEZES) {
|
|
704
|
+
if (barFits(bar))
|
|
705
|
+
return;
|
|
706
|
+
bar.setAttribute("data-squeeze", squeeze);
|
|
707
|
+
}
|
|
518
708
|
}
|
|
519
709
|
/** The bar over the column headings while rows are selected: the count, one button for the actions, and Clear. */
|
|
520
|
-
|
|
710
|
+
/** Menu actions as a menu's entries: a separator wherever an action starts a group. */
|
|
711
|
+
function menuEntries(actions) {
|
|
712
|
+
return actions.flatMap((action, index) => [
|
|
713
|
+
...(action.separator && index > 0 ? [{ separator: true }] : []),
|
|
714
|
+
{ value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled },
|
|
715
|
+
]);
|
|
716
|
+
}
|
|
717
|
+
function SelectionBar({ count, actions, picks, rows, onClear, controls }) {
|
|
521
718
|
const [running, setRunning] = useState(null);
|
|
522
719
|
const [chosen, setChosen] = useState(undefined);
|
|
523
720
|
const alive = useRef(true);
|
|
524
|
-
|
|
525
|
-
|
|
721
|
+
// The bar is alive again on every mount, not only the first: React runs this twice in development, and the second
|
|
722
|
+
// mount would otherwise keep the `false` the first one left, so an action's spinner would never stop.
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
alive.current = true;
|
|
725
|
+
return () => {
|
|
726
|
+
alive.current = false;
|
|
727
|
+
};
|
|
526
728
|
}, []);
|
|
527
729
|
const usable = actions.filter(action => !action.disabled);
|
|
528
730
|
const current = actions.find(action => action.id === chosen && !action.disabled) ?? usable[0] ?? actions[0];
|
|
@@ -538,5 +740,5 @@ function SelectionBar({ count, actions, rows, onClear, controls }) {
|
|
|
538
740
|
// Every action on the selected rows lives in one button: the one shown runs on a click, the menu holds the rest and
|
|
539
741
|
// makes the one picked the button, the way a split button works everywhere else.
|
|
540
742
|
const options = actions.map(action => ({ value: action.id, label: action.label, icon: action.icon, disabled: action.disabled }));
|
|
541
|
-
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" })] }));
|
|
743
|
+
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" })] }));
|
|
542
744
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Ref, type RefObject } from "react";
|
|
2
|
+
import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
|
|
3
|
+
import type { TableColumn, TablePinSide, TableRow } from "../../Interfaces/TableInterfaces.ts";
|
|
4
|
+
export interface TableColumnsPanelProps<TRow extends TableRow> {
|
|
5
|
+
/** Every column the table has, in the order they are in now - hidden ones among them. */
|
|
6
|
+
columns: readonly TableColumn<TRow>[];
|
|
7
|
+
hidden: ReadonlySet<string>;
|
|
8
|
+
pinned: Readonly<Record<string, TablePinSide>>;
|
|
9
|
+
/** False while the table is not reorderable: the panel then only shows and hides. */
|
|
10
|
+
reorderable?: boolean;
|
|
11
|
+
/** Default `md`. */
|
|
12
|
+
size?: "sm" | "md";
|
|
13
|
+
/** Whether the user has arranged anything, so Reset has something to undo. */
|
|
14
|
+
changed?: boolean;
|
|
15
|
+
onToggle: (key: string, hidden: boolean) => void;
|
|
16
|
+
/** The whole order, by key, after a move. */
|
|
17
|
+
onOrder: (keys: readonly string[]) => void;
|
|
18
|
+
onReset?: () => void;
|
|
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>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The Columns panel, opened through its `open()` handle under `anchor`: a checkbox per column, bands with their columns under
|
|
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,
|
|
26
|
+
* on a click outside, and on Tab.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
|
|
30
|
+
* onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
|
|
31
|
+
*/
|
|
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;
|