@moneyforward/mfui-components 4.7.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 (34) hide show
  1. package/dist/src/DataTable/DataTable.js +38 -5
  2. package/dist/src/DataTable/DataTable.types.d.ts +101 -0
  3. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +5 -2
  4. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +31 -15
  5. package/dist/src/DataTable/DataTableProvider.d.ts +19 -4
  6. package/dist/src/DataTable/DataTableProvider.js +11 -3
  7. package/dist/src/DataTable/DataTableRow/DataTableRow.js +21 -12
  8. package/dist/src/DataTable/hooks/useVirtualizedColumns.d.ts +73 -0
  9. package/dist/src/DataTable/hooks/useVirtualizedColumns.js +109 -0
  10. package/dist/src/DataTable/index.d.ts +1 -1
  11. package/dist/src/DataTable/shared/commonStyles.d.ts +11 -0
  12. package/dist/src/DataTable/shared/commonStyles.js +11 -0
  13. package/dist/src/DataTable/utils/renderVirtualizedColumns.d.ts +42 -0
  14. package/dist/src/DataTable/utils/renderVirtualizedColumns.js +40 -0
  15. package/dist/src/DataTable/utils/resolveColumnWidths.d.ts +28 -0
  16. package/dist/src/DataTable/utils/resolveColumnWidths.js +41 -0
  17. package/dist/src/MultipleSelectBox/MultipleSelectBox.d.ts +1 -1
  18. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +8 -5
  19. package/dist/src/MultipleSelectBox/MultipleSelectBox.types.d.ts +33 -0
  20. package/dist/src/SelectBox/SelectBox.js +6 -4
  21. package/dist/src/SelectBox/SelectBox.types.d.ts +33 -0
  22. package/dist/src/SelectBox/SelectBoxOption.d.ts +5 -1
  23. package/dist/src/SelectBox/SelectBoxOption.js +3 -4
  24. package/dist/src/SelectBox/SelectBoxOptionGroup.d.ts +5 -1
  25. package/dist/src/SelectBox/SelectBoxOptionGroup.js +2 -2
  26. package/dist/src/shared/OptionLabel/OptionLabel.d.ts +45 -0
  27. package/dist/src/shared/OptionLabel/OptionLabel.js +9 -0
  28. package/dist/src/shared/OptionLabel/index.d.ts +1 -0
  29. package/dist/src/shared/OptionLabel/index.js +1 -0
  30. package/dist/styled-system/recipes/data-table-slot-recipe.d.ts +5 -1
  31. package/dist/styled-system/recipes/data-table-slot-recipe.js +19 -1
  32. package/dist/styles.css +31 -0
  33. package/dist/tsconfig.build.tsbuildinfo +1 -1
  34. package/package.json +7 -7
@@ -15,8 +15,11 @@ 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);
21
24
  // A virtualized body needs the element that owns `overflow: auto`, which is the wrapper below.
22
25
  // A ref object would still be `null` when the body's layout effects run, because host refs are
@@ -32,6 +35,34 @@ const Base = forwardRef(({ className, size = 'medium', enableRowSelection = fals
32
35
  setHeaderRowCount((count) => count - 1);
33
36
  };
34
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]);
35
66
  // `:first-child` / `:last-child` cannot tell which grid column a cell renders in, so the
36
67
  // table-edge spacing of the cell recipes misfires on the rows a `rowSpan` extends into.
37
68
  // This marks the displaced cells so that spacing can be cancelled on them.
@@ -65,9 +96,11 @@ const Base = forwardRef(({ className, size = 'medium', enableRowSelection = fals
65
96
  // A virtualized body holds only a window of its rows, so the mounted rows are not the row count.
66
97
  // This is spread before `props` so that a consumer-supplied `aria-rowcount` still wins.
67
98
  const ariaRowCountProps = virtualizedRowCount !== null ? { 'aria-rowcount': virtualizedRowCount + headerRowCount } : undefined;
68
- 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, children: _jsxs("div", { ...wrapperProps, ref: setScrollElement, className: cx(classes.wrapper, 'mfui-DataTable__wrapper',
69
- /* 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',
70
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__table', className), ...ariaRowCountProps, ...props, children: children }), shouldShowEmptyState ? (_jsx("div", { className: cx(classes.emptyState, 'mfui-DataTable__emptyState',
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',
71
104
  /* TODO: Remove in v5.0.0 */ 'mfui-DataGrid__emptyState'), role: "status", "aria-live": "polite", children: emptyStateSlot })) : null] }) }));
72
105
  });
73
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'>;
@@ -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';
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cloneElement, forwardRef, isValidElement, useEffect } from 'react';
3
- import { dataTableHeaderRowSlotRecipe } from '../../../styled-system/recipes';
3
+ import { dataTableHeaderRowSlotRecipe, dataTableSlotRecipe } from '../../../styled-system/recipes';
4
4
  import { useDataTableContext } from '../DataTableProvider';
5
5
  import { DataTableHeaderCell } from '../DataTableHeaderCell';
6
6
  import { Checkbox } from '../../Checkbox';
7
7
  import { cx } from '../../../styled-system/css';
8
8
  import { isComponentOrWrapped } from '../../utilities/react/isComponentOrWrapped';
9
9
  import { flattenChildren } from '../../utilities/react/flattenChildren';
10
+ import { useIsomorphicLayoutEffect } from '../../utilities/react/useIsomorphicLayoutEffect';
11
+ import { renderVirtualizedColumns } from '../utils/renderVirtualizedColumns';
10
12
  export const DataTableHeaderRow = forwardRef(({ children, checkboxProps, className, headerRowIndex = 1, ...props }, ref) => {
11
- const { enableRowSelection, setNumberColumns, registerHeaderRow, virtualizedRowCount } = useDataTableContext();
13
+ const { enableRowSelection, setNumberColumns, registerHeaderRow, virtualizedRowCount, columnWindow, reportDeclaredColumnWidths, } = useDataTableContext();
12
14
  const classes = dataTableHeaderRowSlotRecipe();
15
+ const isColumnVirtualized = columnWindow !== null;
13
16
  // A virtualized body offsets the `aria-rowindex` of its rows by the number of header rows, which
14
17
  // only the header rows themselves can report.
15
18
  useEffect(() => registerHeaderRow(), [registerHeaderRow]);
@@ -17,22 +20,35 @@ export const DataTableHeaderRow = forwardRef(({ children, checkboxProps, classNa
17
20
  const selectionCheckbox = (_jsx(Checkbox, { checked: checkboxProps?.checked, indeterminate: checkboxProps?.indeterminate, "aria-label": checkboxProps?.['aria-label'] ?? 'すべての行を選択', disabled: checkboxProps?.disabled, onChange: checkboxProps?.onChange, onClick: checkboxProps?.onClick }));
18
21
  // Count the number of header cells, plus the selection checkbox column rendered
19
22
  // below when `enableRowSelection` is on (it isn't part of `children`).
20
- useEffect(() => {
23
+ // Every cell is counted rather than the mounted ones, so that the count stays the total number
24
+ // of columns while the columns are virtualized.
25
+ // While the columns are virtualized, the declared width of every header cell is reported as well,
26
+ // for the `<colgroup>` the table renders. This runs as a layout effect so that the table re-renders
27
+ // with its `<col>` elements before the first paint, rather than one frame later.
28
+ useIsomorphicLayoutEffect(() => {
21
29
  const headerCells = flattenChildren(children).filter((child) => isValidElement(child) && isComponentOrWrapped(child, DataTableHeaderCell));
22
30
  setNumberColumns(headerCells.length + (enableRowSelection ? 1 : 0));
23
- }, [children, enableRowSelection, setNumberColumns]);
31
+ if (isColumnVirtualized) {
32
+ reportDeclaredColumnWidths(headerCells.map((cell) => cell.props.style?.width));
33
+ }
34
+ }, [children, enableRowSelection, isColumnVirtualized, setNumberColumns, reportDeclaredColumnWidths]);
24
35
  return (_jsxs("tr", { className: cx(classes.root, 'mfui-DataTableHeaderRow__root',
25
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeaderRow__root', className), ...(virtualizedRowCount !== null && { 'aria-rowindex': headerRowIndex }), ...props, ref: ref, children: [enableRowSelection ? (_jsx(DataTableHeaderCell, { columnIndex: 0, disabled: checkboxProps?.disabled, children: checkboxProps?.wrapper ? checkboxProps.wrapper(selectionCheckbox) : selectionCheckbox })) : null, flattenChildren(children).map((child, index) => {
26
- if (!isValidElement(child)) {
27
- return child;
28
- }
29
- // Check if the component is DataTableHeaderCell or wraps DataTableHeaderCell
30
- if (isComponentOrWrapped(child, DataTableHeaderCell)) {
31
- // Add selectable offset to columnIndex if selectable is true
32
- const columnIndex = enableRowSelection ? index + 1 : index;
33
- return cloneElement(child, { columnIndex });
34
- }
35
- return child;
36
+ /* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeaderRow__root', className), ...(virtualizedRowCount !== null && { 'aria-rowindex': headerRowIndex }), ...props, ref: ref, children: [enableRowSelection ? (_jsx(DataTableHeaderCell, { columnIndex: 0, disabled: checkboxProps?.disabled, ...(isColumnVirtualized && { 'aria-colindex': 1 }), children: checkboxProps?.wrapper ? checkboxProps.wrapper(selectionCheckbox) : selectionCheckbox })) : null, renderVirtualizedColumns({
37
+ cells: flattenChildren(children),
38
+ // Add selectable offset to columnIndex if selectable is true
39
+ columnOffset: enableRowSelection ? 1 : 0,
40
+ columnWindow,
41
+ spacerClassName: cx(dataTableSlotRecipe().headerColumnSpacer, 'mfui-DataTable__headerColumnSpacer'),
42
+ renderCell: (child, columnIndex) => {
43
+ // Check if the component is DataTableHeaderCell or wraps DataTableHeaderCell
44
+ if (!isValidElement(child) || !isComponentOrWrapped(child, DataTableHeaderCell)) {
45
+ return child;
46
+ }
47
+ return cloneElement(child, {
48
+ columnIndex,
49
+ ...(isColumnVirtualized && { 'aria-colindex': child.props['aria-colindex'] ?? columnIndex + 1 }),
50
+ });
51
+ },
36
52
  })] }));
37
53
  });
38
54
  DataTableHeaderRow.displayName = 'DataTableHeaderRow';
@@ -1,5 +1,7 @@
1
1
  import { type PropsWithChildren } from 'react';
2
2
  import { type DataTableProps } from './DataTable.types';
3
+ import { type DataTableColumnWindow } from './hooks/useVirtualizedColumns';
4
+ import { type DeclaredColumnWidth } from './utils/resolveColumnWidths';
3
5
  export type DataTableSize = 'small' | 'medium';
4
6
  /**
5
7
  * The props for the `DataTableContext` component.
@@ -31,11 +33,24 @@ export type DataTableVirtualizationValues = {
31
33
  * Registers a header row and returns the function to unregister it.
32
34
  */
33
35
  registerHeaderRow: () => () => void;
34
- };
35
- export type DataTableProviderValues = {
36
+ /**
37
+ * The columns each row mounts while the columns are virtualized, or `null` when they are not.
38
+ * The table computes it once so that the header row and every row window the same columns.
39
+ */
40
+ columnWindow: DataTableColumnWindow | null;
41
+ /**
42
+ * Reports the `style.width` of every header cell, mounted or not, which the table turns into the
43
+ * widths of its `<col>` elements while the columns are virtualized.
44
+ */
45
+ reportDeclaredColumnWidths: (widths: readonly DeclaredColumnWidth[]) => void;
46
+ /**
47
+ * The total number of columns, counted by the header row from all of its cells rather than the
48
+ * mounted ones, plus the selection checkbox column.
49
+ */
36
50
  numberColumns: number;
37
51
  setNumberColumns: (number: number) => void;
38
- } & DataTableContextProps & DataTableVirtualizationValues;
52
+ };
53
+ export type DataTableProviderValues = DataTableContextProps & DataTableVirtualizationValues;
39
54
  /**
40
55
  * The context for the `DataTable` component.
41
56
  *
@@ -51,4 +66,4 @@ export declare const useDataTableContext: () => DataTableProviderValues;
51
66
  *
52
67
  * This should only be used in DataTable component.
53
68
  */
54
- export declare function DataTableProvider({ children, size, enableRowSelection, enableRowClickSelection, fixedHeader, showVerticalBorder, leftFixedColumnIndex, rightFixedColumnIndex, loading, layout, scrollElement, virtualizedRowCount, setVirtualizedRowCount, headerRowCount, registerHeaderRow, }: PropsWithChildren<DataTableContextProps & DataTableVirtualizationValues>): import("react/jsx-runtime").JSX.Element;
69
+ export declare function DataTableProvider({ children, size, enableRowSelection, enableRowClickSelection, fixedHeader, showVerticalBorder, leftFixedColumnIndex, rightFixedColumnIndex, loading, layout, scrollElement, virtualizedRowCount, setVirtualizedRowCount, headerRowCount, registerHeaderRow, columnWindow, reportDeclaredColumnWidths, numberColumns, setNumberColumns, }: PropsWithChildren<DataTableContextProps & DataTableVirtualizationValues>): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useContext, useMemo, useState } from 'react';
3
+ import { createContext, useContext, useMemo } from 'react';
4
4
  /**
5
5
  * The context for the `DataTable` component.
6
6
  *
@@ -28,6 +28,10 @@ export const DataTableContext = createContext({
28
28
  registerHeaderRow: () => () => {
29
29
  // Do nothing
30
30
  },
31
+ columnWindow: null,
32
+ reportDeclaredColumnWidths: () => {
33
+ // Do nothing
34
+ },
31
35
  });
32
36
  /**
33
37
  * The hook to use the `DataTableContext`.
@@ -38,8 +42,7 @@ export const useDataTableContext = () => useContext(DataTableContext);
38
42
  *
39
43
  * This should only be used in DataTable component.
40
44
  */
41
- export function DataTableProvider({ children, size = 'medium', enableRowSelection = false, enableRowClickSelection = false, fixedHeader = false, showVerticalBorder = false, leftFixedColumnIndex, rightFixedColumnIndex, loading, layout = 'default', scrollElement, virtualizedRowCount, setVirtualizedRowCount, headerRowCount, registerHeaderRow, }) {
42
- const [numberColumns, setNumberColumns] = useState(0);
45
+ export function DataTableProvider({ children, size = 'medium', enableRowSelection = false, enableRowClickSelection = false, fixedHeader = false, showVerticalBorder = false, leftFixedColumnIndex, rightFixedColumnIndex, loading, layout = 'default', scrollElement, virtualizedRowCount, setVirtualizedRowCount, headerRowCount, registerHeaderRow, columnWindow, reportDeclaredColumnWidths, numberColumns, setNumberColumns, }) {
43
46
  const contextValue = useMemo(() => ({
44
47
  size,
45
48
  enableRowSelection,
@@ -57,6 +60,8 @@ export function DataTableProvider({ children, size = 'medium', enableRowSelectio
57
60
  setVirtualizedRowCount,
58
61
  headerRowCount,
59
62
  registerHeaderRow,
63
+ columnWindow,
64
+ reportDeclaredColumnWidths,
60
65
  }), [
61
66
  size,
62
67
  enableRowSelection,
@@ -68,11 +73,14 @@ export function DataTableProvider({ children, size = 'medium', enableRowSelectio
68
73
  loading,
69
74
  layout,
70
75
  numberColumns,
76
+ setNumberColumns,
71
77
  scrollElement,
72
78
  virtualizedRowCount,
73
79
  setVirtualizedRowCount,
74
80
  headerRowCount,
75
81
  registerHeaderRow,
82
+ columnWindow,
83
+ reportDeclaredColumnWidths,
76
84
  ]);
77
85
  return _jsx(DataTableContext.Provider, { value: contextValue, children: children });
78
86
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cloneElement, forwardRef, isValidElement, useCallback, useMemo, useRef, useState } from 'react';
3
3
  import { DataTableRowActionContext, } from '../DataTableRowActionContext';
4
- import { dataTableRowSlotRecipe } from '../../../styled-system/recipes';
4
+ import { dataTableRowSlotRecipe, dataTableSlotRecipe } from '../../../styled-system/recipes';
5
+ import { renderVirtualizedColumns } from '../utils/renderVirtualizedColumns';
5
6
  import { DataTableCell } from '../DataTableCell';
6
7
  import { Checkbox } from '../../Checkbox';
7
8
  import { useDataTableContext } from '../DataTableProvider';
@@ -50,7 +51,8 @@ function isClickFromNestedInteractiveElement(event) {
50
51
  return !interactiveElement.matches(ROW_ACTION_BUTTON_SELECTOR);
51
52
  }
52
53
  export const DataTableRow = forwardRef(({ children, disabled = false, highlighted, checkboxProps, className, onClickRow, rowActionButtonProps, selectable, 'aria-expanded': ariaExpandedProp, ...props }, ref) => {
53
- const { enableRowSelection: contextEnableRowSelection, enableRowClickSelection, layout } = useDataTableContext();
54
+ const { enableRowSelection: contextEnableRowSelection, enableRowClickSelection, layout, columnWindow, } = useDataTableContext();
55
+ const isColumnVirtualized = columnWindow !== null;
54
56
  const selectionCheckboxRef = useRef(null);
55
57
  const isSelectable = selectable ?? contextEnableRowSelection;
56
58
  // A selected row is always highlighted, and `highlighted` stays an independent signal on top of it,
@@ -159,16 +161,23 @@ export const DataTableRow = forwardRef(({ children, disabled = false, highlighte
159
161
  onClickRow?.(event);
160
162
  };
161
163
  return (_jsx(DataTableRowActionContext.Provider, { value: rowActionContextValue, children: _jsxs("tr", { ref: ref, className: cx(classes.root, 'mfui-DataTableRow__root',
162
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGridRow__root', className), ...(!hasRowActionButton && ariaExpandedProp !== undefined && { 'aria-expanded': ariaExpandedProp }), ...props, ...(disabled && { 'aria-disabled': disabled }), onClick: handleClickRow, children: [isSelectable ? (_jsx(DataTableCell, { columnIndex: 0, isRowHighlighted: isRowHighlighted, disabled: disabled, children: checkboxProps?.wrapper ? checkboxProps.wrapper(selectionCheckbox) : selectionCheckbox })) : null, flatChildren.map((child, index) => {
163
- if (!isValidElement(child)) {
164
- return child;
165
- }
166
- if (isComponentOrWrapped(child, DataTableCell)) {
167
- // Add selectable offset to columnIndex if selectable is true
168
- const columnIndex = isSelectable ? index + 1 : index;
169
- return cloneElement(child, { isRowHighlighted, columnIndex, disabled });
170
- }
171
- return child;
164
+ /* TODO: Remove in v5.0.0 */ 'mfui-DataGridRow__root', className), ...(!hasRowActionButton && ariaExpandedProp !== undefined && { 'aria-expanded': ariaExpandedProp }), ...props, ...(disabled && { 'aria-disabled': disabled }), onClick: handleClickRow, children: [isSelectable ? (_jsx(DataTableCell, { columnIndex: 0, isRowHighlighted: isRowHighlighted, disabled: disabled, ...(isColumnVirtualized && { 'aria-colindex': 1 }), children: checkboxProps?.wrapper ? checkboxProps.wrapper(selectionCheckbox) : selectionCheckbox })) : null, renderVirtualizedColumns({
165
+ cells: flatChildren,
166
+ // Add selectable offset to columnIndex if selectable is true
167
+ columnOffset: isSelectable ? 1 : 0,
168
+ columnWindow,
169
+ spacerClassName: cx(dataTableSlotRecipe().columnSpacer, 'mfui-DataTable__columnSpacer'),
170
+ renderCell: (child, columnIndex) => {
171
+ if (!isCellElement(child)) {
172
+ return child;
173
+ }
174
+ return cloneElement(child, {
175
+ isRowHighlighted,
176
+ columnIndex,
177
+ disabled,
178
+ ...(isColumnVirtualized && { 'aria-colindex': child.props['aria-colindex'] ?? columnIndex + 1 }),
179
+ });
180
+ },
172
181
  })] }) }));
173
182
  });
174
183
  DataTableRow.displayName = 'DataTableRow';
@@ -0,0 +1,73 @@
1
+ import { type DataTableColumnVirtualizationOptions } from '../DataTable.types';
2
+ /**
3
+ * The columns of a row that are mounted while the columns are virtualized.
4
+ *
5
+ * Every index is a grid column index, which counts the selection checkbox column as column 0
6
+ * when the rows are selectable, and every end is exclusive.
7
+ * A row mounts `[0, prefixEnd)`, `[windowStart, windowEnd)` and `[suffixStart, end of row)`,
8
+ * and fills each gap between them with a single spacer cell.
9
+ */
10
+ export type DataTableColumnWindow = {
11
+ /**
12
+ * The end of the always-mounted columns on the left: the selection checkbox column, the first
13
+ * consumer column and the left fixed columns.
14
+ */
15
+ prefixEnd: number;
16
+ /**
17
+ * The first column of the window of scrollable columns that is mounted.
18
+ */
19
+ windowStart: number;
20
+ /**
21
+ * The end of the window of scrollable columns that is mounted.
22
+ */
23
+ windowEnd: number;
24
+ /**
25
+ * The first of the always-mounted right fixed columns, or `undefined` without right fixed columns.
26
+ */
27
+ suffixStart: number | undefined;
28
+ };
29
+ export type UseVirtualizedColumnsParams = {
30
+ /**
31
+ * Whether the columns are windowed.
32
+ */
33
+ enabled: boolean;
34
+ /**
35
+ * The element that scrolls the columns. It is `null` until it is mounted.
36
+ */
37
+ scrollElement: HTMLElement | null;
38
+ /**
39
+ * The table, whose first row is measured for the width of the always-mounted left columns.
40
+ */
41
+ tableRef: React.RefObject<HTMLTableElement | null>;
42
+ /**
43
+ * The total number of grid columns, including the selection checkbox column.
44
+ */
45
+ columnCount: number;
46
+ /**
47
+ * The number of columns that come before the consumer's columns, which is 1 when the rows are
48
+ * selectable and 0 otherwise.
49
+ */
50
+ selectionColumnCount: number;
51
+ /**
52
+ * The width of each consumer column in pixels, which is also the width of its `<col>`.
53
+ */
54
+ columnWidths: readonly number[];
55
+ leftFixedColumnIndex?: number;
56
+ rightFixedColumnIndex?: number;
57
+ } & Pick<DataTableColumnVirtualizationOptions, 'overscan'>;
58
+ /**
59
+ * Windows the columns of a `DataTable` with `@tanstack/react-virtual`, so that only the columns
60
+ * near the viewport are mounted.
61
+ *
62
+ * Only the columns between the left and the right fixed columns are windowed. The first column,
63
+ * after the selection checkbox column, is always mounted as well, because it hosts the row action
64
+ * button of a row that is not selectable and usually the row header.
65
+ *
66
+ * The columns are never measured: under `table-layout: fixed` each column is exactly as wide as its
67
+ * `<col>`, so the declared widths are the real ones.
68
+ *
69
+ * @param params - The virtualization configuration.
70
+ *
71
+ * @returns The columns to mount, or `null` when virtualization is disabled.
72
+ */
73
+ export declare function useVirtualizedColumns({ enabled, scrollElement, tableRef, columnCount, selectionColumnCount, columnWidths, leftFixedColumnIndex, rightFixedColumnIndex, overscan, }: UseVirtualizedColumnsParams): DataTableColumnWindow | null;
@@ -0,0 +1,109 @@
1
+ import { useMemo, useRef, useState } from 'react';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import { useIsomorphicLayoutEffect } from '../../utilities/react/useIsomorphicLayoutEffect';
4
+ import { DEFAULT_COLUMN_WIDTH } from '../utils/resolveColumnWidths';
5
+ /**
6
+ * The number of columns rendered outside the visible area, on each side.
7
+ */
8
+ const DEFAULT_OVERSCAN = 2;
9
+ /**
10
+ * Windows the columns of a `DataTable` with `@tanstack/react-virtual`, so that only the columns
11
+ * near the viewport are mounted.
12
+ *
13
+ * Only the columns between the left and the right fixed columns are windowed. The first column,
14
+ * after the selection checkbox column, is always mounted as well, because it hosts the row action
15
+ * button of a row that is not selectable and usually the row header.
16
+ *
17
+ * The columns are never measured: under `table-layout: fixed` each column is exactly as wide as its
18
+ * `<col>`, so the declared widths are the real ones.
19
+ *
20
+ * @param params - The virtualization configuration.
21
+ *
22
+ * @returns The columns to mount, or `null` when virtualization is disabled.
23
+ */
24
+ export function useVirtualizedColumns({ enabled, scrollElement, tableRef, columnCount, selectionColumnCount, columnWidths, leftFixedColumnIndex, rightFixedColumnIndex, overscan = DEFAULT_OVERSCAN, }) {
25
+ // The first consumer column stays mounted even without left fixed columns: it hosts the row action
26
+ // button of a row that is not selectable, and the row header that names each row.
27
+ const prefixEnd = selectionColumnCount + Math.max(leftFixedColumnIndex ?? 0, 0) + 1;
28
+ const suffixStart = rightFixedColumnIndex === undefined ? undefined : selectionColumnCount + rightFixedColumnIndex;
29
+ const scrollableColumnCount = Math.max(0, Math.min(suffixStart ?? columnCount, columnCount) - prefixEnd);
30
+ // The scrollable columns start after the always-mounted left columns, not at the left edge of the
31
+ // scroll element, so without the margin the window would be shifted right by their width.
32
+ // It is measured rather than summed from the widths, because the width of the selection column is
33
+ // a CSS expression of design tokens.
34
+ const [scrollMargin, setScrollMargin] = useState(0);
35
+ const hasWarnedDuplicateColgroup = useRef(false);
36
+ useIsomorphicLayoutEffect(() => {
37
+ const table = tableRef.current;
38
+ if (!enabled || !table)
39
+ return;
40
+ const measureScrollMargin = () => {
41
+ const cells = table.rows[0]?.cells;
42
+ let width = 0;
43
+ for (let index = 0; index < prefixEnd; index++) {
44
+ width += cells?.[index]?.offsetWidth ?? 0;
45
+ }
46
+ setScrollMargin(width);
47
+ };
48
+ measureScrollMargin();
49
+ // The always-mounted columns resize with the table, e.g. when the wrapper is resized.
50
+ const resizeObserver = new ResizeObserver(measureScrollMargin);
51
+ resizeObserver.observe(table);
52
+ return () => {
53
+ resizeObserver.disconnect();
54
+ };
55
+ }, [enabled, tableRef, prefixEnd, columnCount]);
56
+ // The table skips its own `<colgroup>` only when it finds the consumer's among its direct children,
57
+ // so a `<colgroup>` rendered by a consumer component ends up next to the table's without any error.
58
+ useIsomorphicLayoutEffect(() => {
59
+ const table = tableRef.current;
60
+ if (process.env['NODE_ENV'] === 'production' || !enabled || !table || hasWarnedDuplicateColgroup.current)
61
+ return;
62
+ if (table.querySelectorAll(':scope > colgroup').length > 1) {
63
+ hasWarnedDuplicateColgroup.current = true;
64
+ console.warn('[MFUI] DataTable received enableColumnVirtualization and found more than one <colgroup>. ' +
65
+ 'Pass your <colgroup> element directly as a child of DataTable so that DataTable skips its own, ' +
66
+ 'or remove it and let DataTable declare the columns from columnVirtualizationOptions.getColumnWidth ' +
67
+ 'and the style.width of each DataTable.HeaderCell.');
68
+ }
69
+ });
70
+ // Always call useVirtualizer to keep the hook order stable, but with 0 count when disabled.
71
+ const virtualizer = useVirtualizer({
72
+ count: enabled ? scrollableColumnCount : 0,
73
+ horizontal: true,
74
+ getScrollElement: () => scrollElement,
75
+ // The widths count the consumer columns, as `leftFixedColumnIndex` does, without the selection column.
76
+ estimateSize: (index) => columnWidths[prefixEnd + index - selectionColumnCount] ?? DEFAULT_COLUMN_WIDTH,
77
+ overscan,
78
+ enabled,
79
+ // Keyed by the grid column, so that a column keeps its key when the number of fixed columns changes.
80
+ getItemKey: (index) => prefixEnd + index,
81
+ scrollMargin,
82
+ });
83
+ // The virtualizer keeps its item sizes until it is told to recompute them, so a column whose
84
+ // width changes would otherwise keep its old offset. The widths are compared by value, because
85
+ // `getColumnWidth` is usually an inline function and they are resolved again on every render.
86
+ const columnWidthsKey = columnWidths.join(',');
87
+ const previousColumnWidthsKey = useRef(columnWidthsKey);
88
+ useIsomorphicLayoutEffect(() => {
89
+ if (previousColumnWidthsKey.current === columnWidthsKey)
90
+ return;
91
+ previousColumnWidthsKey.current = columnWidthsKey;
92
+ virtualizer.measure();
93
+ }, [columnWidthsKey, virtualizer]);
94
+ const virtualColumns = enabled ? virtualizer.getVirtualItems() : [];
95
+ const firstIndex = virtualColumns[0]?.index;
96
+ const lastIndex = virtualColumns.at(-1)?.index;
97
+ // Kept referentially stable while the mounted columns stay the same, so that the rows only
98
+ // re-render when the window actually moves.
99
+ return useMemo(() => {
100
+ if (!enabled)
101
+ return null;
102
+ return {
103
+ prefixEnd,
104
+ windowStart: firstIndex === undefined ? prefixEnd : prefixEnd + firstIndex,
105
+ windowEnd: lastIndex === undefined ? prefixEnd : prefixEnd + lastIndex + 1,
106
+ suffixStart,
107
+ };
108
+ }, [enabled, prefixEnd, firstIndex, lastIndex, suffixStart]);
109
+ }
@@ -1,6 +1,6 @@
1
1
  export { DataTable } from './DataTable';
2
2
  export { getColumnWidthFromContentWidth } from './utils/getColumnWidthFromContentWidth';
3
- export type { DataTableProps } from './DataTable.types';
3
+ export type { DataTableProps, DataTableColumnVirtualizationOptions } from './DataTable.types';
4
4
  export type { DataTableBodyProps, DataTableBodyVirtualizationOptions } from './DataTableBody';
5
5
  export type { DataTableCellProps } from './DataTableCell';
6
6
  export type { DataTableHeaderProps } from './DataTableHeader';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The width of the selection checkbox column.
3
+ *
4
+ * The checkbox cell is always the table's start-edge cell, so its border-box has to hold the
5
+ * start-edge padding from `_first` as well as the inner cell's padding around the checkbox.
6
+ * Otherwise the checkbox overflows into the next column under `table-layout: fixed`.
7
+ *
8
+ * The header cell, the body cell and the `<col>` that `DataTable` renders for column virtualization
9
+ * all take the width from here, so that they cannot disagree.
10
+ */
11
+ export declare const SELECTION_COLUMN_WIDTH = "[calc({sizes.mfui.size.dimension.icon.square.comfort} + {spacing.mfui.size.padding.data-table-cell.horizontal.comfort} * 2 + {spacing.mfui.size.padding.container.horizontal.comfort})]";