@moneyforward/mfui-components 4.6.0 → 4.8.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/dist/src/DataTable/DataTable.d.ts +1 -1
- package/dist/src/DataTable/DataTable.js +59 -6
- package/dist/src/DataTable/DataTable.types.d.ts +101 -0
- package/dist/src/DataTable/DataTableBody/DataTableBody.d.ts +2 -1
- package/dist/src/DataTable/DataTableBody/DataTableBody.js +72 -8
- package/dist/src/DataTable/DataTableBody/DataTableBody.types.d.ts +81 -1
- package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.d.ts +64 -0
- package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.js +53 -0
- package/dist/src/DataTable/DataTableBody/index.d.ts +1 -1
- package/dist/src/DataTable/DataTableHeader/DataTableHeader.js +13 -2
- package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +5 -2
- package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +35 -16
- package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +9 -0
- package/dist/src/DataTable/DataTableProvider.d.ts +45 -3
- package/dist/src/DataTable/DataTableProvider.js +30 -3
- package/dist/src/DataTable/DataTableRow/DataTableRow.js +21 -12
- package/dist/src/DataTable/hooks/useVirtualizedColumns.d.ts +73 -0
- package/dist/src/DataTable/hooks/useVirtualizedColumns.js +109 -0
- package/dist/src/DataTable/index.d.ts +2 -2
- package/dist/src/DataTable/shared/commonStyles.d.ts +11 -0
- package/dist/src/DataTable/shared/commonStyles.js +11 -0
- package/dist/src/DataTable/utils/renderVirtualizedColumns.d.ts +42 -0
- package/dist/src/DataTable/utils/renderVirtualizedColumns.js +40 -0
- package/dist/src/DataTable/utils/resolveColumnWidths.d.ts +28 -0
- package/dist/src/DataTable/utils/resolveColumnWidths.js +41 -0
- package/dist/src/MultipleSelectBox/MultipleSelectBox.d.ts +1 -1
- package/dist/src/MultipleSelectBox/MultipleSelectBox.js +20 -8
- package/dist/src/MultipleSelectBox/MultipleSelectBox.types.d.ts +33 -0
- package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.d.ts +1 -0
- package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +23 -16
- package/dist/src/SelectBox/SelectBox.js +18 -7
- package/dist/src/SelectBox/SelectBox.types.d.ts +33 -0
- package/dist/src/SelectBox/SelectBoxOption.d.ts +5 -1
- package/dist/src/SelectBox/SelectBoxOption.js +3 -4
- package/dist/src/SelectBox/SelectBoxOptionGroup.d.ts +5 -1
- package/dist/src/SelectBox/SelectBoxOptionGroup.js +2 -2
- package/dist/src/SelectBox/hooks/useKeyboardNavigation.d.ts +1 -0
- package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +29 -14
- package/dist/src/Tooltip/Tooltip.d.ts +6 -0
- package/dist/src/Tooltip/Tooltip.js +6 -0
- package/dist/src/shared/OptionLabel/OptionLabel.d.ts +45 -0
- package/dist/src/shared/OptionLabel/OptionLabel.js +9 -0
- package/dist/src/shared/OptionLabel/index.d.ts +1 -0
- package/dist/src/shared/OptionLabel/index.js +1 -0
- package/dist/styled-system/recipes/data-table-body-slot-recipe.d.ts +1 -1
- package/dist/styled-system/recipes/data-table-body-slot-recipe.js +4 -0
- package/dist/styled-system/recipes/data-table-slot-recipe.d.ts +5 -1
- package/dist/styled-system/recipes/data-table-slot-recipe.js +19 -1
- package/dist/styled-system/tokens/index.js +4 -0
- package/dist/styled-system/tokens/tokens.d.ts +1 -1
- package/dist/styles.css +40 -4
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +7 -7
|
@@ -72,7 +72,7 @@ export declare const DataTable: import("react").ForwardRefExoticComponent<Omit<D
|
|
|
72
72
|
* Body.displayName = 'CustomDataTableBody'; // Will cause hooks violation
|
|
73
73
|
* ```
|
|
74
74
|
*/
|
|
75
|
-
Body: import("react").ForwardRefExoticComponent<Omit<import("
|
|
75
|
+
Body: import("react").ForwardRefExoticComponent<Omit<import("./DataTableBody").DataTableBodyProps, "ref"> & import("react").RefAttributes<HTMLTableSectionElement>>;
|
|
76
76
|
/**
|
|
77
77
|
* The `DataTableCell` component.
|
|
78
78
|
* This component should be used inside the `DataTable` component.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Children, isValidElement, useMemo, useRef, forwardRef } from 'react';
|
|
3
|
+
import { Children, isValidElement, useCallback, useMemo, useRef, useState, forwardRef } from 'react';
|
|
4
4
|
import { DataTableProvider } from './DataTableProvider';
|
|
5
5
|
import { dataTableSlotRecipe } from '../../styled-system/recipes';
|
|
6
6
|
import { DataTableBody } from './DataTableBody';
|
|
@@ -15,9 +15,54 @@ import { cx } from '../../styled-system/css';
|
|
|
15
15
|
import { isComponentOrWrapped } from '../utilities/react/isComponentOrWrapped';
|
|
16
16
|
import { mergeRefs } from '../utilities/dom/mergeRefs';
|
|
17
17
|
import { useTableEdgeCellMarkers } from '../utilities/dom/useTableEdgeCellMarkers';
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
import { useVirtualizedColumns } from './hooks/useVirtualizedColumns';
|
|
19
|
+
import { flattenChildren } from '../utilities/react/flattenChildren';
|
|
20
|
+
import { resolveColumnWidths } from './utils/resolveColumnWidths';
|
|
21
|
+
const Base = forwardRef(({ className, size = 'medium', enableRowSelection = false, enableRowClickSelection = false, fixedHeader = false, showVerticalBorder = false, leftFixedColumnIndex, rightFixedColumnIndex, layout = 'default', wrapperProps, loading = false, emptyStateSlot, enableColumnVirtualization = false, columnVirtualizationOptions, children, ...props }, ref) => {
|
|
22
|
+
const classes = dataTableSlotRecipe({ layout, columnVirtualization: enableColumnVirtualization });
|
|
20
23
|
const tableRef = useRef(null);
|
|
24
|
+
// A virtualized body needs the element that owns `overflow: auto`, which is the wrapper below.
|
|
25
|
+
// A ref object would still be `null` when the body's layout effects run, because host refs are
|
|
26
|
+
// attached child-first, so the element is held in state and the body re-renders once it lands.
|
|
27
|
+
const [scrollElement, setScrollElement] = useState(null);
|
|
28
|
+
// A virtualized body mounts only a window of its rows, so the row count it publishes here is what
|
|
29
|
+
// keeps `aria-rowcount` honest. It stays `null` while no body is virtualized.
|
|
30
|
+
const [virtualizedRowCount, setVirtualizedRowCount] = useState(null);
|
|
31
|
+
const [headerRowCount, setHeaderRowCount] = useState(0);
|
|
32
|
+
const registerHeaderRow = useCallback(() => {
|
|
33
|
+
setHeaderRowCount((count) => count + 1);
|
|
34
|
+
return () => {
|
|
35
|
+
setHeaderRowCount((count) => count - 1);
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
// The header row counts all of its cells, mounted or not, and the column virtualizer windows that count.
|
|
39
|
+
const [numberColumns, setNumberColumns] = useState(0);
|
|
40
|
+
// The header row holds the element of every column, mounted or not, so it reports the
|
|
41
|
+
// `style.width` of each header cell for the `<colgroup>` of a table that windows its columns.
|
|
42
|
+
const [declaredColumnWidths, setDeclaredColumnWidths] = useState([]);
|
|
43
|
+
const reportDeclaredColumnWidths = useCallback((widths) => {
|
|
44
|
+
// The header row reports a new array on every render of its children, which must not re-render
|
|
45
|
+
// the whole table when no width changed.
|
|
46
|
+
setDeclaredColumnWidths((current) => current.length === widths.length && current.every((width, index) => width === widths[index]) ? current : widths);
|
|
47
|
+
}, []);
|
|
48
|
+
const columnWidths = resolveColumnWidths(enableColumnVirtualization ? declaredColumnWidths : [], columnVirtualizationOptions?.getColumnWidth);
|
|
49
|
+
// No single row owns the columns, so the window is computed here once and every row applies it
|
|
50
|
+
// to its own cells. The header and the body therefore always mount the same columns.
|
|
51
|
+
const columnWindow = useVirtualizedColumns({
|
|
52
|
+
enabled: enableColumnVirtualization,
|
|
53
|
+
scrollElement,
|
|
54
|
+
tableRef,
|
|
55
|
+
columnCount: numberColumns,
|
|
56
|
+
selectionColumnCount: enableRowSelection ? 1 : 0,
|
|
57
|
+
columnWidths,
|
|
58
|
+
leftFixedColumnIndex,
|
|
59
|
+
rightFixedColumnIndex,
|
|
60
|
+
overscan: columnVirtualizationOptions?.overscan,
|
|
61
|
+
});
|
|
62
|
+
// A consumer `<colgroup>` replaces the table's own. Only an element of type `'colgroup'` among the
|
|
63
|
+
// direct children (Fragments expanded) is detected, as documented on `enableColumnVirtualization`.
|
|
64
|
+
const hasConsumerColgroup = useMemo(() => enableColumnVirtualization &&
|
|
65
|
+
flattenChildren(children).some((child) => isValidElement(child) && child.type === 'colgroup'), [enableColumnVirtualization, children]);
|
|
21
66
|
// `:first-child` / `:last-child` cannot tell which grid column a cell renders in, so the
|
|
22
67
|
// table-edge spacing of the cell recipes misfires on the rows a `rowSpan` extends into.
|
|
23
68
|
// This marks the displaced cells so that spacing can be cancelled on them.
|
|
@@ -29,6 +74,7 @@ const Base = forwardRef(({ className, size = 'medium', enableRowSelection = fals
|
|
|
29
74
|
const bodyElement = Children.toArray(children).find((child) => isValidElement(child) &&
|
|
30
75
|
(isComponentOrWrapped(child, DataTableBody) ||
|
|
31
76
|
(typeof child.type !== 'string' &&
|
|
77
|
+
typeof child.type !== 'symbol' &&
|
|
32
78
|
'displayName' in child.type &&
|
|
33
79
|
child.type.displayName === 'DataGridBody')));
|
|
34
80
|
if (!bodyElement || !isValidElement(bodyElement))
|
|
@@ -38,16 +84,23 @@ const Base = forwardRef(({ className, size = 'medium', enableRowSelection = fals
|
|
|
38
84
|
const bodyChildren = Children.toArray(bodyElement.props.children);
|
|
39
85
|
return bodyChildren.some((child) => isValidElement(child) &&
|
|
40
86
|
(isComponentOrWrapped(child, DataTableRow) ||
|
|
87
|
+
// A `Fragment` has a symbol as its type, which `in` cannot search.
|
|
41
88
|
(typeof child.type !== 'string' &&
|
|
89
|
+
typeof child.type !== 'symbol' &&
|
|
42
90
|
'displayName' in child.type &&
|
|
43
91
|
child.type.displayName === 'DataGridRow')));
|
|
44
92
|
}
|
|
45
93
|
return false;
|
|
46
94
|
}, [children, loading]);
|
|
47
95
|
const shouldShowEmptyState = !hasRows && !!emptyStateSlot && !loading;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
96
|
+
// A virtualized body holds only a window of its rows, so the mounted rows are not the row count.
|
|
97
|
+
// This is spread before `props` so that a consumer-supplied `aria-rowcount` still wins.
|
|
98
|
+
const ariaRowCountProps = virtualizedRowCount !== null ? { 'aria-rowcount': virtualizedRowCount + headerRowCount } : undefined;
|
|
99
|
+
// The same holds for the columns: only a window of the cells of each row is mounted.
|
|
100
|
+
const ariaColCountProps = columnWindow !== null && numberColumns > 0 ? { 'aria-colcount': numberColumns } : undefined;
|
|
101
|
+
return (_jsx(DataTableProvider, { size: size, enableRowSelection: enableRowSelection, enableRowClickSelection: enableRowClickSelection, fixedHeader: fixedHeader, showVerticalBorder: showVerticalBorder, leftFixedColumnIndex: leftFixedColumnIndex, rightFixedColumnIndex: rightFixedColumnIndex, loading: loading, layout: layout, scrollElement: scrollElement, virtualizedRowCount: virtualizedRowCount, setVirtualizedRowCount: setVirtualizedRowCount, headerRowCount: headerRowCount, registerHeaderRow: registerHeaderRow, columnWindow: columnWindow, reportDeclaredColumnWidths: reportDeclaredColumnWidths, numberColumns: numberColumns, setNumberColumns: setNumberColumns, children: _jsxs("div", { ...wrapperProps, ref: setScrollElement, className: cx(classes.wrapper, 'mfui-DataTable__wrapper',
|
|
102
|
+
/* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__wrapper', wrapperProps?.className), children: [_jsxs("table", { ref: mergeRefs(ref, tableRef), className: cx(classes.table, 'mfui-DataTable__table',
|
|
103
|
+
/* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__table', className), ...ariaRowCountProps, ...ariaColCountProps, ...props, children: [enableColumnVirtualization && !hasConsumerColgroup ? (_jsxs("colgroup", { children: [enableRowSelection ? (_jsx("col", { className: cx(classes.selectionColumn, 'mfui-DataTable__selectionColumn') })) : null, columnWidths.map((width, index) => (_jsx("col", { style: { width } }, index)))] })) : null, children] }), shouldShowEmptyState ? (_jsx("div", { className: cx(classes.emptyState, 'mfui-DataTable__emptyState',
|
|
51
104
|
/* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__emptyState'), role: "status", "aria-live": "polite", children: emptyStateSlot })) : null] }) }));
|
|
52
105
|
});
|
|
53
106
|
Base.displayName = 'DataTable';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ComponentPropsWithRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
|
+
import { type VirtualizerOptions } from '@tanstack/react-virtual';
|
|
2
3
|
import { type DataTableSize } from './DataTableProvider';
|
|
3
4
|
import { type DataTableSlotRecipeVariant } from '../../styled-system/recipes';
|
|
4
5
|
/**
|
|
@@ -18,6 +19,19 @@ export type CellDataType = 'text' | 'amount';
|
|
|
18
19
|
* @param checkbox - The built-in selection checkbox element.
|
|
19
20
|
*/
|
|
20
21
|
export type DataTableSelectionCheckboxWrapper = (checkbox: ReactNode) => ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* The options for the column virtualization of the `DataTable` component.
|
|
24
|
+
*/
|
|
25
|
+
export type DataTableColumnVirtualizationOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* Returns the width of a column in pixels.
|
|
28
|
+
*
|
|
29
|
+
* `index` counts the columns as `leftFixedColumnIndex` does, without the selection column.
|
|
30
|
+
* Return `undefined` to fall back to the `style.width` of the column's `DataTable.HeaderCell`,
|
|
31
|
+
* and then to 160px.
|
|
32
|
+
*/
|
|
33
|
+
getColumnWidth?: (index: number) => number | undefined;
|
|
34
|
+
} & Pick<VirtualizerOptions<HTMLElement, Element>, 'overscan'>;
|
|
21
35
|
/**
|
|
22
36
|
* The props for the `DataTable` component.
|
|
23
37
|
*
|
|
@@ -142,4 +156,91 @@ export type DataTableProps = {
|
|
|
142
156
|
* @default undefined
|
|
143
157
|
*/
|
|
144
158
|
emptyStateSlot?: ReactNode;
|
|
159
|
+
/**
|
|
160
|
+
* Whether to render only the columns near the viewport (column virtualization).
|
|
161
|
+
*
|
|
162
|
+
* When enabled, the header row and every row mount a window of their cells instead of all of them,
|
|
163
|
+
* and reserve the width of the columns they leave out with a spacer cell on each side of the window.
|
|
164
|
+
* This removes the DOM and mount cost of a wide table, and composes with `enableVirtualization` on
|
|
165
|
+
* `DataTable.Body` to window a table in both directions.
|
|
166
|
+
*
|
|
167
|
+
* The first column, the columns up to `leftFixedColumnIndex` and the columns from
|
|
168
|
+
* `rightFixedColumnIndex` are always mounted; only the columns between them are windowed.
|
|
169
|
+
*
|
|
170
|
+
* Layout:
|
|
171
|
+
*
|
|
172
|
+
* - The table switches to `table-layout: fixed`, because the cells of a row stay in their columns only
|
|
173
|
+
* when every column takes its width from a `<col>`. A `tableLayout` you set through `style` or
|
|
174
|
+
* `className` still wins.
|
|
175
|
+
* - The table renders a `<colgroup>` with one `<col>` per column, including the selection column when
|
|
176
|
+
* `enableRowSelection` is `true`. Each column takes the first width of:
|
|
177
|
+
* 1. `columnVirtualizationOptions.getColumnWidth(index)`, when it returns a width.
|
|
178
|
+
* 2. The `style.width` of the column's `DataTable.HeaderCell`, when it is a number or a `px` string.
|
|
179
|
+
* Other units (`%`, `rem`, `calc()`) are ignored, because the window is computed in pixels before
|
|
180
|
+
* anything is laid out.
|
|
181
|
+
* 3. 160px.
|
|
182
|
+
* - Content wider than its column overflows the cell under the fixed layout instead of widening the
|
|
183
|
+
* column. Truncate it, e.g. with `text-overflow: ellipsis`, or give the column a wider width.
|
|
184
|
+
* - To declare the columns yourself, render a `<colgroup>` as a direct child of `DataTable` (a
|
|
185
|
+
* Fragment around it is fine). The table then renders none of its own. It detects yours by the
|
|
186
|
+
* element type `'colgroup'` only, so a `<colgroup>` rendered by your own component is not detected
|
|
187
|
+
* and the table would end up with two (a warning is logged in development). Keep the widths of
|
|
188
|
+
* your `<col>` elements in sync with the resolved widths above, since the window is computed from those.
|
|
189
|
+
* `getColumnWidthFromContentWidth` turns a content width into the matching column width.
|
|
190
|
+
* - The table has to render a `DataTable.HeaderRow`, which is what the columns are counted from.
|
|
191
|
+
*
|
|
192
|
+
* `aria-colcount` on the table and `aria-colindex` on each cell are set for you, so that assistive
|
|
193
|
+
* technology is told the real column count rather than the number of mounted cells. Both stay
|
|
194
|
+
* overridable by the values you pass yourself.
|
|
195
|
+
*
|
|
196
|
+
* **Not supported together with merged cells.** A `colSpan` / `rowSpan` shifts the source-order
|
|
197
|
+
* index the window is resolved by, so the wrong cells are mounted.
|
|
198
|
+
*
|
|
199
|
+
* @default false
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```tsx
|
|
203
|
+
* <div style={{ height: 500 }}>
|
|
204
|
+
* <DataTable enableColumnVirtualization leftFixedColumnIndex={0}>
|
|
205
|
+
* <DataTable.Header>
|
|
206
|
+
* <DataTable.HeaderRow>
|
|
207
|
+
* {columns.map((column) => (
|
|
208
|
+
* <DataTable.HeaderCell key={column.id} style={{ width: column.width }}>
|
|
209
|
+
* {column.label}
|
|
210
|
+
* </DataTable.HeaderCell>
|
|
211
|
+
* ))}
|
|
212
|
+
* </DataTable.HeaderRow>
|
|
213
|
+
* </DataTable.Header>
|
|
214
|
+
* <DataTable.Body>
|
|
215
|
+
* {rows.map((row) => (
|
|
216
|
+
* <DataTable.Row key={row.id}>
|
|
217
|
+
* {columns.map((column) => (
|
|
218
|
+
* <DataTable.Cell key={column.id}>{row[column.id]}</DataTable.Cell>
|
|
219
|
+
* ))}
|
|
220
|
+
* </DataTable.Row>
|
|
221
|
+
* ))}
|
|
222
|
+
* </DataTable.Body>
|
|
223
|
+
* </DataTable>
|
|
224
|
+
* </div>
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
enableColumnVirtualization?: boolean;
|
|
228
|
+
/**
|
|
229
|
+
* The configuration of the column virtualization.
|
|
230
|
+
* Only applies when `enableColumnVirtualization` is `true`.
|
|
231
|
+
*
|
|
232
|
+
* @property getColumnWidth - Returns the width of a column in pixels. It receives the column index counted
|
|
233
|
+
* as `leftFixedColumnIndex` is, without the selection column. It takes precedence over the `style.width`
|
|
234
|
+
* of the header cell; return `undefined` to fall back to it, and then to 160px.
|
|
235
|
+
* @property overscan - The number of columns rendered outside the visible area, on each side. Defaults to `2`.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```tsx
|
|
239
|
+
* <DataTable
|
|
240
|
+
* enableColumnVirtualization
|
|
241
|
+
* columnVirtualizationOptions={{ getColumnWidth: (index) => columns[index]?.width, overscan: 4 }}
|
|
242
|
+
* >
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
columnVirtualizationOptions?: DataTableColumnVirtualizationOptions;
|
|
145
246
|
} & ComponentPropsWithRef<'table'>;
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { type DataTableBodyProps } from './DataTableBody.types';
|
|
2
|
+
export declare const DataTableBody: import("react").ForwardRefExoticComponent<Omit<DataTableBodyProps, "ref"> & import("react").RefAttributes<HTMLTableSectionElement>>;
|
|
@@ -1,19 +1,83 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Children, cloneElement, forwardRef, Fragment, isValidElement, useCallback, useEffect, useMemo, useState, } from 'react';
|
|
3
|
+
import { useVirtualizedRows, VIRTUAL_ROW_INDEX_ATTRIBUTE } from './hooks/useVirtualizedRows';
|
|
3
4
|
import { dataTableBodySlotRecipe } from '../../../styled-system/recipes';
|
|
4
5
|
import { cx } from '../../../styled-system/css';
|
|
5
6
|
import { useDataTableContext } from '../DataTableProvider';
|
|
6
7
|
import { DataTableRow } from '../DataTableRow';
|
|
7
8
|
import { DataTableCell } from '../DataTableCell';
|
|
9
|
+
import { mergeRefs } from '../../utilities/dom/mergeRefs';
|
|
8
10
|
const NUMBER_OF_LOADING_ROWS = 4;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* React 19 exposes the ref of an element through its props, while React 18 keeps it on the element
|
|
13
|
+
* itself. Both are supported peers, so both have to be read to merge rather than clobber a
|
|
14
|
+
* consumer-supplied ref on a row.
|
|
15
|
+
*
|
|
16
|
+
* @param element - The row element to read the ref from.
|
|
17
|
+
*
|
|
18
|
+
* @returns The ref the consumer put on the row, if any.
|
|
19
|
+
*/
|
|
20
|
+
function getRowRef(element) {
|
|
21
|
+
return element.props.ref ?? element.ref;
|
|
22
|
+
}
|
|
23
|
+
export const DataTableBody = forwardRef(({ className, children, enableVirtualization = false, virtualizationOptions, ...props }, ref) => {
|
|
24
|
+
const { numberColumns, loading, scrollElement, setVirtualizedRowCount, headerRowCount } = useDataTableContext();
|
|
11
25
|
const classes = dataTableBodySlotRecipe();
|
|
26
|
+
// The body is held in state so that its offset below the `<thead>` can be read while rendering.
|
|
27
|
+
const [bodyElement, setBodyElement] = useState(null);
|
|
28
|
+
const bodyRef = useMemo(() => mergeRefs(ref, setBodyElement), [ref]);
|
|
29
|
+
const rows = useMemo(() => (enableVirtualization ? Children.toArray(children) : []), [children, enableVirtualization]);
|
|
30
|
+
const isVirtualized = enableVirtualization && !loading;
|
|
31
|
+
// `Children.toArray` gives every element a key, so measured heights stay with their row on reorder.
|
|
32
|
+
const getRowKey = useCallback((index) => {
|
|
33
|
+
const row = rows[index];
|
|
34
|
+
return isValidElement(row) && row.key != null ? row.key : index;
|
|
35
|
+
}, [rows]);
|
|
36
|
+
// Called before the `loading` branch below returns, so that the hook order stays stable.
|
|
37
|
+
const { virtualRows, paddingTop, paddingBottom, measureElement } = useVirtualizedRows({
|
|
38
|
+
enabled: isVirtualized,
|
|
39
|
+
scrollElement,
|
|
40
|
+
rowCount: rows.length,
|
|
41
|
+
getItemKey: getRowKey,
|
|
42
|
+
scrollMargin: bodyElement?.offsetTop ?? 0,
|
|
43
|
+
...virtualizationOptions,
|
|
44
|
+
});
|
|
45
|
+
// Only a window of the rows is mounted, so the table cannot count them. Publishing the real
|
|
46
|
+
// count is what lets the table report `aria-rowcount` instead of the mounted count.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isVirtualized)
|
|
49
|
+
return;
|
|
50
|
+
setVirtualizedRowCount(rows.length);
|
|
51
|
+
return () => {
|
|
52
|
+
setVirtualizedRowCount(null);
|
|
53
|
+
};
|
|
54
|
+
}, [isVirtualized, rows.length, setVirtualizedRowCount]);
|
|
55
|
+
const bodyClassName = cx(classes.root, 'mfui-DataTableBody__root',
|
|
56
|
+
/* TODO: Remove in v5.0.0 */ 'mfui-DataGridBody__root', className);
|
|
12
57
|
if (loading) {
|
|
13
|
-
return (_jsx("tbody", { className:
|
|
14
|
-
/* TODO: Remove in v5.0.0 */ 'mfui-DataGridBody__root', className), ...props, ref: ref, children: Array.from({ length: NUMBER_OF_LOADING_ROWS }).map((_, rowIndex) => (_jsx(DataTableRow, { children: Array.from({ length: numberColumns }).map((_, colIndex) => (_jsx(DataTableCell, {}, colIndex))) }, rowIndex))) }));
|
|
58
|
+
return (_jsx("tbody", { className: bodyClassName, ...props, ref: ref, children: Array.from({ length: NUMBER_OF_LOADING_ROWS }).map((_, rowIndex) => (_jsx(DataTableRow, { children: Array.from({ length: numberColumns }).map((_, colIndex) => (_jsx(DataTableCell, {}, colIndex))) }, rowIndex))) }));
|
|
15
59
|
}
|
|
16
|
-
|
|
17
|
-
|
|
60
|
+
if (!isVirtualized) {
|
|
61
|
+
return (_jsx("tbody", { className: bodyClassName, ...props, ref: ref, children: children }));
|
|
62
|
+
}
|
|
63
|
+
// `numberColumns` already counts the selection checkbox column, and falls back to a single
|
|
64
|
+
// column when the table renders no header row for it to count.
|
|
65
|
+
const spacerColSpan = Math.max(numberColumns, 1);
|
|
66
|
+
// The spacer stands in for the rows that are not mounted, so it reserves their height without
|
|
67
|
+
// being announced as a row of the table.
|
|
68
|
+
const renderSpacer = (position, height) => (_jsx("tr", { role: "presentation", "data-mfui-virtual-spacer": position, children: _jsx("td", { colSpan: spacerColSpan, className: classes.spacer, style: { height } }) }));
|
|
69
|
+
return (_jsxs("tbody", { className: bodyClassName, ...props, ref: bodyRef, children: [paddingTop > 0 ? renderSpacer('start', paddingTop) : null, virtualRows.map((virtualRow) => {
|
|
70
|
+
const child = rows[virtualRow.index];
|
|
71
|
+
// A `Fragment` cannot take the measuring ref or the ARIA attributes, so it is left as it is.
|
|
72
|
+
if (!isValidElement(child) || child.type === Fragment) {
|
|
73
|
+
return child;
|
|
74
|
+
}
|
|
75
|
+
return cloneElement(child, {
|
|
76
|
+
[VIRTUAL_ROW_INDEX_ATTRIBUTE]: virtualRow.index,
|
|
77
|
+
// The header rows come first in the table, so the first data row is the row after them.
|
|
78
|
+
'aria-rowindex': child.props['aria-rowindex'] ?? virtualRow.index + headerRowCount + 1,
|
|
79
|
+
ref: mergeRefs(getRowRef(child), measureElement),
|
|
80
|
+
});
|
|
81
|
+
}), paddingBottom > 0 ? renderSpacer('end', paddingBottom) : null] }));
|
|
18
82
|
});
|
|
19
83
|
DataTableBody.displayName = 'DataTableBody';
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { type ComponentPropsWithRef } from 'react';
|
|
2
|
+
import { type VirtualizerOptions } from '@tanstack/react-virtual';
|
|
3
|
+
/**
|
|
4
|
+
* The options for the row virtualization of the `DataTableBody` component.
|
|
5
|
+
*/
|
|
6
|
+
export type DataTableBodyVirtualizationOptions = Pick<VirtualizerOptions<HTMLElement, Element>, 'estimateSize' | 'overscan'>;
|
|
2
7
|
/**
|
|
3
8
|
* The props for the `DataTableBody` component.
|
|
4
9
|
*
|
|
@@ -6,4 +11,79 @@ import { type ComponentPropsWithRef } from 'react';
|
|
|
6
11
|
*
|
|
7
12
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/tbody
|
|
8
13
|
*/
|
|
9
|
-
export type DataTableBodyProps =
|
|
14
|
+
export type DataTableBodyProps = {
|
|
15
|
+
/**
|
|
16
|
+
* Whether to render only the rows near the viewport (row virtualization).
|
|
17
|
+
*
|
|
18
|
+
* When enabled, the body mounts a window of its `DataTable.Row` children instead of all of them,
|
|
19
|
+
* and reserves the height of the rows it leaves out with spacer rows. This removes the DOM and
|
|
20
|
+
* mount cost of a large table, which is what dominates at thousands of rows.
|
|
21
|
+
* Note that the rows are still *created* by the consumer's own `map`, so creating the row elements
|
|
22
|
+
* stays O(number of rows).
|
|
23
|
+
*
|
|
24
|
+
* Row heights are measured as the rows mount, so rows whose content wraps to different heights
|
|
25
|
+
* are supported.
|
|
26
|
+
*
|
|
27
|
+
* Requirements:
|
|
28
|
+
*
|
|
29
|
+
* - An ancestor has to constrain the height of the `DataTable`, because the wrapper of the
|
|
30
|
+
* `DataTable` is the element that scrolls and it is `height: 100%`.
|
|
31
|
+
* - The table has to use `table-layout: fixed` with explicit column widths. Column widths are
|
|
32
|
+
* otherwise derived from the mounted rows only, so they shift while scrolling.
|
|
33
|
+
* `getColumnWidthFromContentWidth` turns a content width into the matching column width.
|
|
34
|
+
*
|
|
35
|
+
* `aria-rowcount` on the table and `aria-rowindex` on each row are set for you, so that assistive
|
|
36
|
+
* technology is told the real row count rather than the number of mounted rows. Both stay
|
|
37
|
+
* overridable by the values you pass yourself.
|
|
38
|
+
*
|
|
39
|
+
* **Not supported together with merged cells.** A `rowSpan` reaches across rows that virtualization
|
|
40
|
+
* unmounts, so the merged cell is lost as soon as the row that owns it scrolls out of the window.
|
|
41
|
+
*
|
|
42
|
+
* **Each child has to be a single `DataTable.Row`.** A `Fragment` that groups several rows is
|
|
43
|
+
* windowed as one row and its rows are not measured, so pass the rows as a flat list instead.
|
|
44
|
+
*
|
|
45
|
+
* @default false
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* <div style={{ height: 500 }}>
|
|
50
|
+
* <DataTable fixedHeader style={{ tableLayout: 'fixed' }}>
|
|
51
|
+
* <colgroup>
|
|
52
|
+
* <col style={{ width: getColumnWidthFromContentWidth(96, { position: 'first' }) }} />
|
|
53
|
+
* <col />
|
|
54
|
+
* </colgroup>
|
|
55
|
+
* <DataTable.Header>
|
|
56
|
+
* <DataTable.HeaderRow>
|
|
57
|
+
* <DataTable.HeaderCell>行</DataTable.HeaderCell>
|
|
58
|
+
* <DataTable.HeaderCell>エラー内容</DataTable.HeaderCell>
|
|
59
|
+
* </DataTable.HeaderRow>
|
|
60
|
+
* </DataTable.Header>
|
|
61
|
+
* <DataTable.Body enableVirtualization>
|
|
62
|
+
* {rows.map((row) => (
|
|
63
|
+
* <DataTable.Row key={row.id}>
|
|
64
|
+
* <DataTable.Cell>{row.line}</DataTable.Cell>
|
|
65
|
+
* <DataTable.Cell>{row.message}</DataTable.Cell>
|
|
66
|
+
* </DataTable.Row>
|
|
67
|
+
* ))}
|
|
68
|
+
* </DataTable.Body>
|
|
69
|
+
* </DataTable>
|
|
70
|
+
* </div>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
enableVirtualization?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The configuration of the row virtualization.
|
|
76
|
+
* Only applies when `enableVirtualization` is `true`.
|
|
77
|
+
*
|
|
78
|
+
* @property estimateSize - The estimated height of a row in pixels, used until the row is measured. Defaults to `() => 44`.
|
|
79
|
+
* @property overscan - The number of rows rendered outside the visible area, on each side. Defaults to `8`. *
|
|
80
|
+
* @example
|
|
81
|
+
* ```tsx
|
|
82
|
+
* <DataTable.Body
|
|
83
|
+
* enableVirtualization
|
|
84
|
+
* virtualizationOptions={{ estimateSize: () => 36, overscan: 12 }}
|
|
85
|
+
* >
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
virtualizationOptions?: DataTableBodyVirtualizationOptions;
|
|
89
|
+
} & ComponentPropsWithRef<'tbody'>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Key } from 'react';
|
|
2
|
+
import { type VirtualItem } from '@tanstack/react-virtual';
|
|
3
|
+
import { type DataTableBodyVirtualizationOptions } from '../DataTableBody.types';
|
|
4
|
+
/**
|
|
5
|
+
* The attribute the virtualizer reads the row index from while measuring a mounted row.
|
|
6
|
+
* TanStack's default is `data-index`, which the repository's `data-mfui-` prefix rule forbids.
|
|
7
|
+
*/
|
|
8
|
+
export declare const VIRTUAL_ROW_INDEX_ATTRIBUTE = "data-mfui-index";
|
|
9
|
+
export type UseVirtualizedRowsParams = {
|
|
10
|
+
/**
|
|
11
|
+
* Whether the rows are windowed.
|
|
12
|
+
*/
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The element that scrolls the rows. It is `null` until it is mounted.
|
|
16
|
+
*/
|
|
17
|
+
scrollElement: HTMLElement | null;
|
|
18
|
+
/**
|
|
19
|
+
* The total number of rows the body holds, not the number of mounted rows.
|
|
20
|
+
*/
|
|
21
|
+
rowCount: number;
|
|
22
|
+
/**
|
|
23
|
+
* The distance in pixels from the top of the scroll element's content to the first row,
|
|
24
|
+
* which is the height of the `<thead>` above the body.
|
|
25
|
+
*/
|
|
26
|
+
scrollMargin: number;
|
|
27
|
+
/**
|
|
28
|
+
* Returns a stable key for the row at the index, so that measured heights follow the row
|
|
29
|
+
* rather than its position when rows are reordered.
|
|
30
|
+
*/
|
|
31
|
+
getItemKey?: (index: number) => Key;
|
|
32
|
+
} & Partial<DataTableBodyVirtualizationOptions>;
|
|
33
|
+
export type UseVirtualizedRowsReturn = {
|
|
34
|
+
/**
|
|
35
|
+
* The rows to mount, which is the visible rows plus the overscan.
|
|
36
|
+
* It is empty when virtualization is disabled.
|
|
37
|
+
*/
|
|
38
|
+
virtualRows: VirtualItem[];
|
|
39
|
+
/**
|
|
40
|
+
* The height in pixels the spacer row before the window has to reserve.
|
|
41
|
+
*/
|
|
42
|
+
paddingTop: number;
|
|
43
|
+
/**
|
|
44
|
+
* The height in pixels the spacer row after the window has to reserve.
|
|
45
|
+
*/
|
|
46
|
+
paddingBottom: number;
|
|
47
|
+
/**
|
|
48
|
+
* The ref callback that measures a mounted row.
|
|
49
|
+
* The row must carry the index in {@link VIRTUAL_ROW_INDEX_ATTRIBUTE}.
|
|
50
|
+
*/
|
|
51
|
+
measureElement: (node: Element | null) => void;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Windows the rows of a `DataTable.Body` with `@tanstack/react-virtual`, so that only the rows near
|
|
55
|
+
* the viewport are mounted.
|
|
56
|
+
*
|
|
57
|
+
* Rows are measured as they mount rather than trusted to `estimateSize`, so rows whose content wraps
|
|
58
|
+
* to different heights are supported.
|
|
59
|
+
*
|
|
60
|
+
* @param params - The virtualization configuration.
|
|
61
|
+
*
|
|
62
|
+
* @returns The rows to mount and the spacer heights that stand in for the rows that are not mounted.
|
|
63
|
+
*/
|
|
64
|
+
export declare function useVirtualizedRows({ enabled, scrollElement, rowCount, scrollMargin, estimateSize, overscan, getItemKey, }: UseVirtualizedRowsParams): UseVirtualizedRowsReturn;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
2
|
+
/**
|
|
3
|
+
* The attribute the virtualizer reads the row index from while measuring a mounted row.
|
|
4
|
+
* TanStack's default is `data-index`, which the repository's `data-mfui-` prefix rule forbids.
|
|
5
|
+
*/
|
|
6
|
+
export const VIRTUAL_ROW_INDEX_ATTRIBUTE = 'data-mfui-index';
|
|
7
|
+
/**
|
|
8
|
+
* The estimated height of a row in pixels, used before a row has been measured.
|
|
9
|
+
* It only affects the scroll range of the not-yet-mounted rows, because every mounted row is
|
|
10
|
+
* measured for real.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_ESTIMATED_ROW_HEIGHT = 44;
|
|
13
|
+
/**
|
|
14
|
+
* The number of rows rendered outside the visible area, on each side.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_OVERSCAN = 8;
|
|
17
|
+
/**
|
|
18
|
+
* Windows the rows of a `DataTable.Body` with `@tanstack/react-virtual`, so that only the rows near
|
|
19
|
+
* the viewport are mounted.
|
|
20
|
+
*
|
|
21
|
+
* Rows are measured as they mount rather than trusted to `estimateSize`, so rows whose content wraps
|
|
22
|
+
* to different heights are supported.
|
|
23
|
+
*
|
|
24
|
+
* @param params - The virtualization configuration.
|
|
25
|
+
*
|
|
26
|
+
* @returns The rows to mount and the spacer heights that stand in for the rows that are not mounted.
|
|
27
|
+
*/
|
|
28
|
+
export function useVirtualizedRows({ enabled, scrollElement, rowCount, scrollMargin, estimateSize = () => DEFAULT_ESTIMATED_ROW_HEIGHT, overscan = DEFAULT_OVERSCAN, getItemKey, }) {
|
|
29
|
+
// Always call useVirtualizer to keep the hook order stable, but with 0 count when disabled.
|
|
30
|
+
const virtualizer = useVirtualizer({
|
|
31
|
+
count: enabled ? rowCount : 0,
|
|
32
|
+
getScrollElement: () => scrollElement,
|
|
33
|
+
estimateSize,
|
|
34
|
+
overscan,
|
|
35
|
+
enabled,
|
|
36
|
+
getItemKey,
|
|
37
|
+
indexAttribute: VIRTUAL_ROW_INDEX_ATTRIBUTE,
|
|
38
|
+
// The rows start below the `<thead>`, not at the top of the scroll element, so without the margin
|
|
39
|
+
// the window would be shifted down by the header height.
|
|
40
|
+
scrollMargin,
|
|
41
|
+
});
|
|
42
|
+
const virtualRows = enabled ? virtualizer.getVirtualItems() : [];
|
|
43
|
+
const [firstVirtualRow] = virtualRows;
|
|
44
|
+
const lastVirtualRow = virtualRows.at(-1);
|
|
45
|
+
// `start` / `end` include the scroll margin while `getTotalSize()` does not, so the margin is
|
|
46
|
+
// removed to get distances within the body.
|
|
47
|
+
return {
|
|
48
|
+
virtualRows,
|
|
49
|
+
paddingTop: firstVirtualRow ? firstVirtualRow.start - scrollMargin : 0,
|
|
50
|
+
paddingBottom: lastVirtualRow ? virtualizer.getTotalSize() - (lastVirtualRow.end - scrollMargin) : 0,
|
|
51
|
+
measureElement: virtualizer.measureElement,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { DataTableBody } from './DataTableBody';
|
|
2
|
-
export type { DataTableBodyProps } from './DataTableBody.types';
|
|
2
|
+
export type { DataTableBodyProps, DataTableBodyVirtualizationOptions } from './DataTableBody.types';
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef } from 'react';
|
|
2
|
+
import { Children, cloneElement, forwardRef, isValidElement } from 'react';
|
|
3
3
|
import { dataTableHeaderSlotRecipe } from '../../../styled-system/recipes';
|
|
4
4
|
import { useDataTableContext } from '../DataTableProvider';
|
|
5
|
+
import { DataTableHeaderRow } from '../DataTableHeaderRow';
|
|
5
6
|
import { cx } from '../../../styled-system/css';
|
|
7
|
+
import { isComponentOrWrapped } from '../../utilities/react/isComponentOrWrapped';
|
|
6
8
|
export const DataTableHeader = forwardRef(({ children, className, ...props }, ref) => {
|
|
7
9
|
const { fixedHeader } = useDataTableContext();
|
|
8
10
|
const classes = dataTableHeaderSlotRecipe({ fixedHeader });
|
|
11
|
+
// Each header row needs its own position for `aria-rowindex`, which only the header can tell.
|
|
12
|
+
const items = Children.toArray(children);
|
|
13
|
+
const headerRows = items.filter((child) => isValidElement(child) && isComponentOrWrapped(child, DataTableHeaderRow));
|
|
9
14
|
return (_jsx("thead", { className: cx(classes.root, 'mfui-DataTableHeader__root',
|
|
10
|
-
/* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeader__root', className), ...props, ref: ref, children:
|
|
15
|
+
/* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeader__root', className), ...props, ref: ref, children: items.map((child) => {
|
|
16
|
+
const headerRowIndex = headerRows.indexOf(child) + 1;
|
|
17
|
+
if (headerRowIndex === 0 || !isValidElement(child)) {
|
|
18
|
+
return child;
|
|
19
|
+
}
|
|
20
|
+
return cloneElement(child, { headerRowIndex });
|
|
21
|
+
}) }));
|
|
11
22
|
});
|
|
12
23
|
DataTableHeader.displayName = 'DataTableHeader';
|
|
@@ -9,7 +9,7 @@ import { cx } from '../../../styled-system/css';
|
|
|
9
9
|
import { useFixedColumns } from '../../utilities/dom/useFixedColumns';
|
|
10
10
|
import { handleCheckboxCellClick } from '../utils/handleCheckbox';
|
|
11
11
|
import { mergeRefs } from '../../utilities/dom/mergeRefs';
|
|
12
|
-
export const DataTableHeaderCell = forwardRef(({ children, type = 'text', sortButtonProps, sortOrder, actions, ascendingIconProps, descendingIconProps, unsortedIconProps, className, columnIndex, disabled = false, ...props }, ref) => {
|
|
12
|
+
export const DataTableHeaderCell = forwardRef(({ children, type = 'text', sortButtonProps, sortOrder, actions, ascendingIconProps, descendingIconProps, unsortedIconProps, className, columnIndex, disabled = false, style, ...props }, ref) => {
|
|
13
13
|
const { size, leftFixedColumnIndex, rightFixedColumnIndex, enableRowSelection, showVerticalBorder } = useDataTableContext();
|
|
14
14
|
const { cellRef, isFixedColumn, isEdgeFixedColumn, fixedPositionStyles } = useFixedColumns({
|
|
15
15
|
enableRowSelection,
|
|
@@ -62,6 +62,9 @@ export const DataTableHeaderCell = forwardRef(({ children, type = 'text', sortBu
|
|
|
62
62
|
// scope is stated explicitly rather than left to the auto-scope algorithm, whose result
|
|
63
63
|
// is harder to predict with `colSpan` / `rowSpan` and fixed columns.
|
|
64
64
|
scope: "col", className: cx(classes.root, 'mfui-DataTableHeaderCell__root',
|
|
65
|
-
/* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeaderCell__root', className), "aria-sort": sortOrder,
|
|
65
|
+
/* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeaderCell__root', className), "aria-sort": sortOrder, ...props,
|
|
66
|
+
// Merged rather than replaced, so that a consumer `style` (e.g. the column `width`) keeps the
|
|
67
|
+
// sticky offsets of a fixed column, as `DataTableCell` does.
|
|
68
|
+
style: { ...style, ...fixedPositionStyles }, children: cellContentWithActions }));
|
|
66
69
|
});
|
|
67
70
|
DataTableHeaderCell.displayName = 'DataTableHeaderCell';
|