@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.
Files changed (53) hide show
  1. package/dist/src/DataTable/DataTable.d.ts +1 -1
  2. package/dist/src/DataTable/DataTable.js +59 -6
  3. package/dist/src/DataTable/DataTable.types.d.ts +101 -0
  4. package/dist/src/DataTable/DataTableBody/DataTableBody.d.ts +2 -1
  5. package/dist/src/DataTable/DataTableBody/DataTableBody.js +72 -8
  6. package/dist/src/DataTable/DataTableBody/DataTableBody.types.d.ts +81 -1
  7. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.d.ts +64 -0
  8. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.js +53 -0
  9. package/dist/src/DataTable/DataTableBody/index.d.ts +1 -1
  10. package/dist/src/DataTable/DataTableHeader/DataTableHeader.js +13 -2
  11. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +5 -2
  12. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +35 -16
  13. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +9 -0
  14. package/dist/src/DataTable/DataTableProvider.d.ts +45 -3
  15. package/dist/src/DataTable/DataTableProvider.js +30 -3
  16. package/dist/src/DataTable/DataTableRow/DataTableRow.js +21 -12
  17. package/dist/src/DataTable/hooks/useVirtualizedColumns.d.ts +73 -0
  18. package/dist/src/DataTable/hooks/useVirtualizedColumns.js +109 -0
  19. package/dist/src/DataTable/index.d.ts +2 -2
  20. package/dist/src/DataTable/shared/commonStyles.d.ts +11 -0
  21. package/dist/src/DataTable/shared/commonStyles.js +11 -0
  22. package/dist/src/DataTable/utils/renderVirtualizedColumns.d.ts +42 -0
  23. package/dist/src/DataTable/utils/renderVirtualizedColumns.js +40 -0
  24. package/dist/src/DataTable/utils/resolveColumnWidths.d.ts +28 -0
  25. package/dist/src/DataTable/utils/resolveColumnWidths.js +41 -0
  26. package/dist/src/MultipleSelectBox/MultipleSelectBox.d.ts +1 -1
  27. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +20 -8
  28. package/dist/src/MultipleSelectBox/MultipleSelectBox.types.d.ts +33 -0
  29. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.d.ts +1 -0
  30. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +23 -16
  31. package/dist/src/SelectBox/SelectBox.js +18 -7
  32. package/dist/src/SelectBox/SelectBox.types.d.ts +33 -0
  33. package/dist/src/SelectBox/SelectBoxOption.d.ts +5 -1
  34. package/dist/src/SelectBox/SelectBoxOption.js +3 -4
  35. package/dist/src/SelectBox/SelectBoxOptionGroup.d.ts +5 -1
  36. package/dist/src/SelectBox/SelectBoxOptionGroup.js +2 -2
  37. package/dist/src/SelectBox/hooks/useKeyboardNavigation.d.ts +1 -0
  38. package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +29 -14
  39. package/dist/src/Tooltip/Tooltip.d.ts +6 -0
  40. package/dist/src/Tooltip/Tooltip.js +6 -0
  41. package/dist/src/shared/OptionLabel/OptionLabel.d.ts +45 -0
  42. package/dist/src/shared/OptionLabel/OptionLabel.js +9 -0
  43. package/dist/src/shared/OptionLabel/index.d.ts +1 -0
  44. package/dist/src/shared/OptionLabel/index.js +1 -0
  45. package/dist/styled-system/recipes/data-table-body-slot-recipe.d.ts +1 -1
  46. package/dist/styled-system/recipes/data-table-body-slot-recipe.js +4 -0
  47. package/dist/styled-system/recipes/data-table-slot-recipe.d.ts +5 -1
  48. package/dist/styled-system/recipes/data-table-slot-recipe.js +19 -1
  49. package/dist/styled-system/tokens/index.js +4 -0
  50. package/dist/styled-system/tokens/tokens.d.ts +1 -1
  51. package/dist/styles.css +40 -4
  52. package/dist/tsconfig.build.tsbuildinfo +1 -1
  53. 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("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, "ref"> & import("react").RefAttributes<HTMLTableSectionElement>>;
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
- const Base = forwardRef(({ className, size = 'medium', enableRowSelection = false, enableRowClickSelection = false, fixedHeader = false, showVerticalBorder = false, leftFixedColumnIndex, rightFixedColumnIndex, layout = 'default', wrapperProps, loading = false, emptyStateSlot, children, ...props }, ref) => {
19
- const classes = dataTableSlotRecipe({ layout });
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
- return (_jsx(DataTableProvider, { size: size, enableRowSelection: enableRowSelection, enableRowClickSelection: enableRowClickSelection, fixedHeader: fixedHeader, showVerticalBorder: showVerticalBorder, leftFixedColumnIndex: leftFixedColumnIndex, rightFixedColumnIndex: rightFixedColumnIndex, loading: loading, layout: layout, children: _jsxs("div", { ...wrapperProps, className: cx(classes.wrapper, 'mfui-DataTable__wrapper',
49
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__wrapper', wrapperProps?.className), children: [_jsx("table", { ref: mergeRefs(ref, tableRef), className: cx(classes.table, 'mfui-DataTable__table',
50
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__table', className), ...props, children: children }), shouldShowEmptyState ? (_jsx("div", { className: cx(classes.emptyState, 'mfui-DataTable__emptyState',
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
- export declare const DataTableBody: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, "ref"> & import("react").RefAttributes<HTMLTableSectionElement>>;
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
- export const DataTableBody = forwardRef(({ className, ...props }, ref) => {
10
- const { numberColumns, loading } = useDataTableContext();
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: cx(classes.root, 'mfui-DataTableBody__root',
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
- return (_jsx("tbody", { className: cx(classes.root, 'mfui-DataTableBody__root',
17
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGridBody__root', className), ...props, ref: ref }));
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 = ComponentPropsWithRef<'tbody'>;
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: 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, style: fixedPositionStyles, ...props, children: cellContentWithActions }));
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';