nexus-shared 3.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 -262
- package/LICENSE +21 -21
- package/README.md +1084 -1000
- package/dist/Client.Index.d.ts +3 -0
- package/dist/Client.Index.js +3 -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 +177 -58
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +23 -7
- package/dist/Components/Forms/ExcelImport.js +189 -60
- package/dist/Components/Forms/Form.js +5 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/NumberBox.js +24 -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/TimePanel.js +4 -3
- 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 +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 +192 -44
- 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 +2 -2
- package/dist/Components/Viewers/DataTableParts.js +3 -3
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/CrudBackend.d.ts +7 -4
- package/dist/Helpers/CrudBackend.js +4 -2
- 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/MessageBuilder.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/RowsStore.d.ts +7 -2
- package/dist/Helpers/RowsStore.js +15 -3
- 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 +6 -3
- package/dist/Helpers/TableHelpers.js +8 -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/CrudInterfaces.d.ts +20 -1
- package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
- package/dist/Interfaces/FormInterfaces.d.ts +33 -5
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +59 -12
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/ExcelApi.d.ts +3 -1
- package/dist/Services/ExcelApi.js +1 -1
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +2 -0
- package/dist/Shared.Index.js +2 -0
- package/package.json +85 -85
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -153
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -1489
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -161
- 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 -370
- package/src/Styles/Nexus.Form.css +279 -321
- package/src/Styles/Nexus.Index.css +37 -36
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -259
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -596
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -419
- package/src/Styles/Nexus.Table.css +1386 -1296
- package/src/Styles/Nexus.Theme.Picker.css +286 -232
- 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
|
@@ -1,13 +1,16 @@
|
|
|
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, 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";
|
|
9
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";
|
|
@@ -16,6 +19,7 @@ import { toast } from "../Layouts/Toaster.js";
|
|
|
16
19
|
import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
|
|
17
20
|
import { openExcelImport } from "../Forms/ExcelImport.js";
|
|
18
21
|
import { TableColumnsPanel } from "./DataTableColumns.js";
|
|
22
|
+
import { TableFiltersPanel } from "./DataTableFilters.js";
|
|
19
23
|
import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
|
|
20
24
|
/*
|
|
21
25
|
* DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
|
|
@@ -89,26 +93,38 @@ const sameNumbers = (a, b) => {
|
|
|
89
93
|
* />
|
|
90
94
|
*/
|
|
91
95
|
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
|
|
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;
|
|
93
97
|
const paging = givenRows ? "client" : (props.paging ?? "client");
|
|
94
98
|
const reloadable = props.reloadable ?? Boolean(load);
|
|
95
99
|
const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
|
|
96
100
|
// A table with checkboxes numbers its rows: the number becomes the checkbox as soon as the row is under the pointer.
|
|
97
101
|
const numbered = props.numbered ?? selectable;
|
|
98
|
-
|
|
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)));
|
|
99
111
|
const table = givenTable ?? own;
|
|
100
|
-
|
|
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 });
|
|
101
115
|
useImperativeHandle(ref, () => table, [table]);
|
|
102
116
|
const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
|
|
103
|
-
const
|
|
117
|
+
const dates = useDatePreferences();
|
|
104
118
|
const timeZone = useBrowserTimeZone();
|
|
105
|
-
const
|
|
119
|
+
const numbers = useNumberLocale();
|
|
120
|
+
const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers]);
|
|
106
121
|
const tableId = useId();
|
|
107
122
|
// The search box is uncontrolled: a new key clears it when a new list starts over.
|
|
108
123
|
const [searchGeneration, setSearchGeneration] = useState(0);
|
|
109
124
|
// The pinned section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
|
|
110
125
|
const [pinsOpen, setPinsOpen] = useState(false);
|
|
111
126
|
const searchTimer = useRef(undefined);
|
|
127
|
+
const searchRef = useRef(null);
|
|
112
128
|
const started = useRef(false);
|
|
113
129
|
const loadedKey = useRef(loadKey);
|
|
114
130
|
// First rows: saved settings first, then the rows given or the first load. A layout effect, so rows that are at hand
|
|
@@ -118,7 +134,7 @@ export function DataTable(props) {
|
|
|
118
134
|
started.current = true;
|
|
119
135
|
const saved = readSaved(stateKey);
|
|
120
136
|
table.restore({
|
|
121
|
-
pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize :
|
|
137
|
+
pageSize: saved?.pageSize && pageSizes.includes(saved.pageSize) ? saved.pageSize : openingPageSize(),
|
|
122
138
|
sort: saved?.sort !== undefined ? saved.sort : defaultSort,
|
|
123
139
|
hidden: saved?.hidden ?? columns.filter(column => column.hidden).map(column => column.key),
|
|
124
140
|
order: saved?.order,
|
|
@@ -159,7 +175,8 @@ export function DataTable(props) {
|
|
|
159
175
|
if (!stateKey || !started.current)
|
|
160
176
|
return;
|
|
161
177
|
writeSaved(stateKey, {
|
|
162
|
-
|
|
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,
|
|
163
180
|
sort: state.query.sort,
|
|
164
181
|
hidden: hiddenList,
|
|
165
182
|
order: state.order.length ? [...state.order] : undefined,
|
|
@@ -167,6 +184,20 @@ export function DataTable(props) {
|
|
|
167
184
|
pinned: Object.keys(state.pinned).length ? state.pinned : undefined,
|
|
168
185
|
});
|
|
169
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]);
|
|
170
201
|
const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
|
|
171
202
|
// Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
|
|
172
203
|
const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
|
|
@@ -360,15 +391,19 @@ export function DataTable(props) {
|
|
|
360
391
|
});
|
|
361
392
|
};
|
|
362
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);
|
|
363
396
|
const outputName = exportName ?? props["aria-label"] ?? "Table";
|
|
364
397
|
const output = () => {
|
|
365
398
|
const shown = views.map(view => view.column);
|
|
366
399
|
const rowsOut = table.getViewRows();
|
|
367
|
-
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(" · ");
|
|
368
403
|
return { columns: shown, rows: rowsOut, cellOptions, name: outputName, note };
|
|
369
404
|
};
|
|
370
405
|
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. */
|
|
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. */
|
|
372
407
|
const exportQuery = () => {
|
|
373
408
|
const column = query.sort ? columns.find(candidate => candidate.key === query.sort.key) : undefined;
|
|
374
409
|
return {
|
|
@@ -379,32 +414,94 @@ export function DataTable(props) {
|
|
|
379
414
|
search: query.search || null,
|
|
380
415
|
sort: column ? (column.sortKey ?? column.key) : null,
|
|
381
416
|
direction: query.sort?.direction,
|
|
382
|
-
filters: excel?.filters?.(),
|
|
417
|
+
filters: { ...filterRequest(query.filters, filterInputs ?? [], columns), ...excel?.filters?.() },
|
|
383
418
|
},
|
|
384
419
|
};
|
|
385
420
|
};
|
|
386
|
-
/**
|
|
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
|
+
*/
|
|
387
426
|
const onImported = () => {
|
|
388
|
-
void table.reload({ quiet: true });
|
|
389
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 });
|
|
390
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 }));
|
|
391
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() }] : []),
|
|
392
494
|
...(exportable && status === "ready" && rows.length > 0
|
|
393
495
|
? [{ id: "export", label: serverPage ? "Export this page (CSV)" : "Export as CSV", icon: _jsx(InlineIcon, { icon: iconFileExport }), onSelect: () => exportTableCsv(output()) }]
|
|
394
496
|
: []),
|
|
395
497
|
...(printable && status === "ready" && rows.length > 0
|
|
396
498
|
? [{ id: "print", label: serverPage ? "Print this page" : "Print", icon: _jsx(InlineIcon, { icon: iconPrinter }), onSelect: () => printTable(output()) }]
|
|
397
499
|
: []),
|
|
398
|
-
//
|
|
399
|
-
// screen. Its own Import replaces the page's `onImport` hook.
|
|
500
|
+
// The sheet's own Import replaces the page's `onImport` hook.
|
|
400
501
|
...(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
|
-
]
|
|
502
|
+
? add
|
|
503
|
+
? []
|
|
504
|
+
: sheetItems
|
|
408
505
|
: onImport
|
|
409
506
|
? [{ id: "import", label: "Import", icon: _jsx(InlineIcon, { icon: iconFileImport }), onSelect: onImport }]
|
|
410
507
|
: []),
|
|
@@ -413,11 +510,9 @@ export function DataTable(props) {
|
|
|
413
510
|
...(!columnsMenu && table.hasColumnChanges()
|
|
414
511
|
? [{ id: "reset-columns", label: "Reset columns", description: "Their order, widths, and pins", icon: _jsx(InlineIcon, { icon: iconRestore }), separator: true, onSelect: () => table.resetColumns() }]
|
|
415
512
|
: []),
|
|
513
|
+
...(reloadable && reloadHidden ? [{ id: "reload", label: "Reload", icon: _jsx(InlineIcon, { icon: iconRefresh }), separator: true, onSelect: reload }] : []),
|
|
416
514
|
];
|
|
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
|
-
]);
|
|
515
|
+
const optionEntries = menuEntries(optionItems);
|
|
421
516
|
const pageSizePicker = _jsx(TablePageSize, { pageSize: query.pageSize, pageSizes: pageSizes, compact: searchable, onChange: value => table.setPageSize(value) });
|
|
422
517
|
let body;
|
|
423
518
|
if (status === "loading") {
|
|
@@ -426,15 +521,24 @@ export function DataTable(props) {
|
|
|
426
521
|
else if (status === "error") {
|
|
427
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" }) }));
|
|
428
523
|
}
|
|
429
|
-
else if (rows.length === 0 && query.search) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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" }))] }));
|
|
435
539
|
}
|
|
436
540
|
else if (rows.length === 0) {
|
|
437
|
-
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 }));
|
|
438
542
|
}
|
|
439
543
|
else {
|
|
440
544
|
body = rows.map((row, index) => {
|
|
@@ -459,18 +563,30 @@ export function DataTable(props) {
|
|
|
459
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}`));
|
|
460
564
|
});
|
|
461
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
|
+
: [];
|
|
462
580
|
const mass = selectedRows.length ? (typeof massActions === "function" ? massActions(selectedRows) : (massActions ?? [])) : [];
|
|
463
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.
|
|
464
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));
|
|
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 => {
|
|
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 => {
|
|
474
590
|
lastToggled.current = null;
|
|
475
591
|
if (table.selectPage(event.currentTarget.checked) > 0)
|
|
476
592
|
warnFull();
|
|
@@ -565,8 +681,40 @@ function HeaderCell({ view, sized, rowSpan, sort, disabled, covered, canPin, can
|
|
|
565
681
|
const label = _jsx("span", { className: "nx-table__heading-label", children: column.label });
|
|
566
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 }))] }));
|
|
567
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
|
+
}
|
|
708
|
+
}
|
|
568
709
|
/** The bar over the column headings while rows are selected: the count, one button for the actions, and Clear. */
|
|
569
|
-
|
|
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 }) {
|
|
570
718
|
const [running, setRunning] = useState(null);
|
|
571
719
|
const [chosen, setChosen] = useState(undefined);
|
|
572
720
|
const alive = useRef(true);
|
|
@@ -592,5 +740,5 @@ function SelectionBar({ count, actions, rows, onClear, controls }) {
|
|
|
592
740
|
// Every action on the selected rows lives in one button: the one shown runs on a click, the menu holds the rest and
|
|
593
741
|
// makes the one picked the button, the way a split button works everywhere else.
|
|
594
742
|
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" })] }));
|
|
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" })] }));
|
|
596
744
|
}
|
|
@@ -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;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { iconArrowBackUp,
|
|
4
|
-
import { useEffect, useId,
|
|
3
|
+
import { iconArrowBackUp, iconGripVertical, iconPin, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
5
5
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isPopoverOpen } from "../../Helpers/PopoverHelpers.js";
|
|
7
|
+
import { usePopoverPanel } from "../../Helpers/PopoverHooks.js";
|
|
7
8
|
import { Button } from "../Documents/Button.js";
|
|
8
9
|
/** The rows of the panel: a band for every run of neighbours that name the same group, as the header draws them. */
|
|
9
10
|
function panelRows(columns, groups) {
|
|
@@ -42,15 +43,12 @@ function dropIndex(boxes, y, from) {
|
|
|
42
43
|
}
|
|
43
44
|
return boxes.length - 1;
|
|
44
45
|
}
|
|
45
|
-
/** The room kept between the panel and the window's edge, and the least height it is ever given. */
|
|
46
|
-
const PANEL_MARGIN = 8;
|
|
47
|
-
const PANEL_MIN_HEIGHT = 160;
|
|
48
46
|
/** The text of a label that may be a node, for an accessible name. */
|
|
49
47
|
function textOf(label) {
|
|
50
48
|
return typeof label === "string" || typeof label === "number" ? String(label) : "the column";
|
|
51
49
|
}
|
|
52
50
|
/**
|
|
53
|
-
* The
|
|
51
|
+
* The Columns panel, opened through its `open()` handle under `anchor`: a checkbox per column, bands with their columns under
|
|
54
52
|
* them, and a handle to drag a row into another place. The panel is a popover under the button, so it closes on Esc,
|
|
55
53
|
* on a click outside, and on Tab.
|
|
56
54
|
*
|
|
@@ -58,15 +56,10 @@ function textOf(label) {
|
|
|
58
56
|
* <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
|
|
59
57
|
* onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
|
|
60
58
|
*/
|
|
61
|
-
export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true, size = "md", changed = false, onToggle, onOrder, onReset,
|
|
59
|
+
export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true, size = "md", changed = false, onToggle, onOrder, onReset, anchor, ref, }) {
|
|
62
60
|
const id = useId();
|
|
63
61
|
const panelId = `${id}-columns`;
|
|
64
|
-
const
|
|
65
|
-
const triggerRef = useRef(null);
|
|
66
|
-
const panelRef = useRef(null);
|
|
67
|
-
const [open, setOpen] = useState(false);
|
|
68
|
-
// Set on pointer down while the panel is open: that press closes it, and its click must not open it again.
|
|
69
|
-
const dismissing = useRef(false);
|
|
62
|
+
const { panelRef, open, onToggle: onPanelToggle } = usePopoverPanel(anchor, ref);
|
|
70
63
|
// While a row is held: its key, the list it may move in, and where it would land.
|
|
71
64
|
const [drag, setDrag] = useState(null);
|
|
72
65
|
const [said, setSaid] = useState("");
|
|
@@ -84,25 +77,6 @@ export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true,
|
|
|
84
77
|
const rows = panelRows(all, groups);
|
|
85
78
|
const shownCount = all.filter(column => column.shown).length;
|
|
86
79
|
const bands = rows.some(row => row.band !== null);
|
|
87
|
-
function place() {
|
|
88
|
-
const panel = panelRef.current;
|
|
89
|
-
const trigger = triggerRef.current;
|
|
90
|
-
if (!panel || !trigger)
|
|
91
|
-
return;
|
|
92
|
-
// Measured at its own height, then capped to the room on the side it landed on: a table with many columns keeps
|
|
93
|
-
// its heading, its note and Reset in the window, and the list of columns scrolls instead.
|
|
94
|
-
panel.style.maxHeight = "";
|
|
95
|
-
placeUnderAnchor(panel, trigger.getBoundingClientRect(), { align: "end" });
|
|
96
|
-
const top = panel.getBoundingClientRect().top;
|
|
97
|
-
panel.style.maxHeight = `${Math.max(PANEL_MIN_HEIGHT, window.innerHeight - top - PANEL_MARGIN)}px`;
|
|
98
|
-
}
|
|
99
|
-
// The panel is fixed, so it follows the button while the page or a scroller moves.
|
|
100
|
-
useLayoutEffect(() => {
|
|
101
|
-
if (!open)
|
|
102
|
-
return;
|
|
103
|
-
return followAnchor(place);
|
|
104
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
-
}, [open]);
|
|
106
80
|
// A panel opened again starts without the last move's message, so a screen reader is not told about an old move.
|
|
107
81
|
useEffect(() => {
|
|
108
82
|
if (!open)
|
|
@@ -205,39 +179,22 @@ export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true,
|
|
|
205
179
|
const lastShown = column.shown && shownCount <= 1;
|
|
206
180
|
return (_jsxs("li", { className: cx("nx-columns__row", inBand && "nx-columns__row--child"), "data-held": drag?.key === column.key ? "" : undefined, "data-drop": drag && drag.list === list && drag.key !== column.key && drag.to === index ? "" : undefined, children: [column.movable && !bandOff ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${textOf(column.label)}`, title: inBand ? `Drag to move it inside ${list}, or use the arrow keys` : "Drag to move, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, list, index, column.key, textOf(column.label)), onKeyDown: event => onHandleKeyDown(event, list, index, textOf(column.label)), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: column.shown && !bandOff, disabled: bandOff || !column.hideable || lastShown, title: bandOff ? `${list} is hidden` : column.hideable ? undefined : "Always shown", onChange: event => onToggle(column.key, !event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: column.label }) })] }), column.pin && (_jsxs("span", { className: "nx-columns__pin", title: `Pinned to the ${column.pin === "left" ? "left" : "right"}`, children: [_jsx(InlineIcon, { icon: iconPin }), _jsx("span", { className: "nx-visually-hidden", children: `Pinned to the ${column.pin === "left" ? "left" : "right"}` })] }))] }, column.key));
|
|
207
181
|
}
|
|
208
|
-
return (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (event.key === "Tab")
|
|
227
|
-
hidePopover(event.currentTarget);
|
|
228
|
-
}, children: [_jsxs("div", { className: "nx-columns__head", children: [_jsx("span", { className: "nx-columns__title", id: `${panelId}-title`, children: "Columns" }), _jsxs("span", { className: "nx-columns__count", children: [shownCount, " of ", all.length, " shown"] })] }), _jsx("ul", { className: "nx-columns__list", "data-list": "", children: rows.map((row, index) => {
|
|
229
|
-
if (row.band === null)
|
|
230
|
-
return columnRow(row.column, "", index, false, false);
|
|
231
|
-
const band = row.band;
|
|
232
|
-
// A band whose columns were split into two runs is drawn twice, so a row is known by its first column, never
|
|
233
|
-
// by the band's name: two rows under one name would be one row to React.
|
|
234
|
-
const bandId = `band:${row.columns[0].key}`;
|
|
235
|
-
const shown = row.columns.filter(column => column.shown).length;
|
|
236
|
-
const movable = row.columns.every(column => column.movable);
|
|
237
|
-
const last = shown > 0 && shownCount - shown < 1;
|
|
238
|
-
return (_jsxs("li", { className: "nx-columns__row nx-columns__row--band", "data-held": drag?.key === bandId ? "" : undefined, "data-drop": drag && drag.list === "" && drag.key !== bandId && drag.to === index ? "" : undefined, children: [_jsxs("div", { className: "nx-columns__band", children: [movable ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${band}`, title: "Drag to move the whole band, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, "", index, bandId, band), onKeyDown: event => onHandleKeyDown(event, "", index, band), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: shown > 0, disabled: last, ref: input => {
|
|
239
|
-
if (input)
|
|
240
|
-
input.indeterminate = shown > 0 && shown < row.columns.length;
|
|
241
|
-
}, "aria-label": `${band}: ${shown} of ${row.columns.length} shown`, onChange: event => toggleBand(band, row.columns, event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: band }) })] }), _jsxs("span", { className: "nx-columns__band-count", children: [shown, " / ", row.columns.length] })] }), _jsx("ul", { className: "nx-columns__children", "data-list": band, children: row.columns.map((column, childIndex) => columnRow(column, band, childIndex, true, shown === 0)) })] }, bandId));
|
|
242
|
-
}) }), _jsxs("p", { className: "nx-columns__note", children: [reorderable ? "Drag a handle, or move it with the arrow keys." : "This table keeps the order of its columns.", bands ? " A column under a band moves inside it; the band moves as one." : ""] }), onReset && (_jsx("div", { className: "nx-columns__foot", children: _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), disabled: !changed, onClick: () => onReset(), children: "Reset columns" }) })), _jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: said })] })] }));
|
|
182
|
+
return (_jsx(_Fragment, { children: _jsxs("div", { ref: panelRef, id: panelId, popover: "auto", role: "dialog", tabIndex: -1, "aria-labelledby": `${panelId}-title`, className: cx("nx-popover", "nx-table-panel", "nx-columns", size === "sm" && "nx-columns--sm"), "data-dragging": drag ? "" : undefined, onToggle: onPanelToggle, onKeyDown: event => {
|
|
183
|
+
if (event.key === "Tab")
|
|
184
|
+
hidePopover(event.currentTarget);
|
|
185
|
+
}, children: [_jsxs("div", { className: "nx-table-panel__head", children: [_jsx("span", { className: "nx-table-panel__title", id: `${panelId}-title`, children: "Columns" }), _jsxs("span", { className: "nx-table-panel__count", children: [shownCount, " of ", all.length, " shown"] })] }), _jsx("ul", { className: "nx-columns__list nx-popover__scroll", "data-list": "", children: rows.map((row, index) => {
|
|
186
|
+
if (row.band === null)
|
|
187
|
+
return columnRow(row.column, "", index, false, false);
|
|
188
|
+
const band = row.band;
|
|
189
|
+
// A band whose columns were split into two runs is drawn twice, so a row is known by its first column, never
|
|
190
|
+
// by the band's name: two rows under one name would be one row to React.
|
|
191
|
+
const bandId = `band:${row.columns[0].key}`;
|
|
192
|
+
const shown = row.columns.filter(column => column.shown).length;
|
|
193
|
+
const movable = row.columns.every(column => column.movable);
|
|
194
|
+
const last = shown > 0 && shownCount - shown < 1;
|
|
195
|
+
return (_jsxs("li", { className: "nx-columns__row nx-columns__row--band", "data-held": drag?.key === bandId ? "" : undefined, "data-drop": drag && drag.list === "" && drag.key !== bandId && drag.to === index ? "" : undefined, children: [_jsxs("div", { className: "nx-columns__band", children: [movable ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${band}`, title: "Drag to move the whole band, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, "", index, bandId, band), onKeyDown: event => onHandleKeyDown(event, "", index, band), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: shown > 0, disabled: last, ref: input => {
|
|
196
|
+
if (input)
|
|
197
|
+
input.indeterminate = shown > 0 && shown < row.columns.length;
|
|
198
|
+
}, "aria-label": `${band}: ${shown} of ${row.columns.length} shown`, onChange: event => toggleBand(band, row.columns, event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: band }) })] }), _jsxs("span", { className: "nx-columns__band-count", children: [shown, " / ", row.columns.length] })] }), _jsx("ul", { className: "nx-columns__children", "data-list": band, children: row.columns.map((column, childIndex) => columnRow(column, band, childIndex, true, shown === 0)) })] }, bandId));
|
|
199
|
+
}) }), _jsxs("p", { className: "nx-columns__note", children: [reorderable ? "Drag a handle, or move it with the arrow keys." : "This table keeps the order of its columns.", bands ? " A column under a band moves inside it; the band moves as one." : ""] }), onReset && (_jsx("div", { className: "nx-table-panel__foot", children: _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), disabled: !changed, onClick: () => onReset(), children: "Reset columns" }) })), _jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: said })] }) }));
|
|
243
200
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Ref, type RefObject } from "react";
|
|
2
|
+
import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
|
|
3
|
+
import type { InputParams } from "../../Interfaces/FormInterfaces.ts";
|
|
4
|
+
import type { TableFilters } from "../../Interfaces/TableInterfaces.ts";
|
|
5
|
+
export interface TableFiltersPanelProps {
|
|
6
|
+
/** The filter inputs, as the table prepared them (`asFilterInput`). */
|
|
7
|
+
inputs: readonly InputParams[];
|
|
8
|
+
/** The table's filters: a change from outside, such as Clear filters, loads them into the form. */
|
|
9
|
+
value: TableFilters | undefined;
|
|
10
|
+
/** Milliseconds a typed value waits: the table's search delay. */
|
|
11
|
+
delay: number;
|
|
12
|
+
/** The rows the search and the filters leave, and every row, for the foot. */
|
|
13
|
+
matches: number;
|
|
14
|
+
total: number;
|
|
15
|
+
onChange: (values: Readonly<Record<string, unknown>>) => void;
|
|
16
|
+
/** The options button (⋮) the panel opens under; Esc gives the focus back to it. */
|
|
17
|
+
anchor: RefObject<HTMLElement | null>;
|
|
18
|
+
ref?: Ref<PopoverPanelHandle>;
|
|
19
|
+
}
|
|
20
|
+
/** The Filters panel, opened through its `open()` handle: a `Form` of the filter inputs, the count, and Clear all. */
|
|
21
|
+
export declare function TableFiltersPanel({ inputs, value, delay, matches, total, onChange, anchor, ref }: TableFiltersPanelProps): import("react").JSX.Element;
|