@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
@@ -1,35 +1,54 @@
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
- export const DataTableHeaderRow = forwardRef(({ children, checkboxProps, className, ...props }, ref) => {
11
- const { enableRowSelection, setNumberColumns } = useDataTableContext();
10
+ import { useIsomorphicLayoutEffect } from '../../utilities/react/useIsomorphicLayoutEffect';
11
+ import { renderVirtualizedColumns } from '../utils/renderVirtualizedColumns';
12
+ export const DataTableHeaderRow = forwardRef(({ children, checkboxProps, className, headerRowIndex = 1, ...props }, ref) => {
13
+ const { enableRowSelection, setNumberColumns, registerHeaderRow, virtualizedRowCount, columnWindow, reportDeclaredColumnWidths, } = useDataTableContext();
12
14
  const classes = dataTableHeaderRowSlotRecipe();
15
+ const isColumnVirtualized = columnWindow !== null;
16
+ // A virtualized body offsets the `aria-rowindex` of its rows by the number of header rows, which
17
+ // only the header rows themselves can report.
18
+ useEffect(() => registerHeaderRow(), [registerHeaderRow]);
13
19
  // Building the element before wrapping keeps everything the header row owns on the Checkbox itself.
14
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 }));
15
21
  // Count the number of header cells, plus the selection checkbox column rendered
16
22
  // below when `enableRowSelection` is on (it isn't part of `children`).
17
- 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(() => {
18
29
  const headerCells = flattenChildren(children).filter((child) => isValidElement(child) && isComponentOrWrapped(child, DataTableHeaderCell));
19
30
  setNumberColumns(headerCells.length + (enableRowSelection ? 1 : 0));
20
- }, [children, enableRowSelection, setNumberColumns]);
31
+ if (isColumnVirtualized) {
32
+ reportDeclaredColumnWidths(headerCells.map((cell) => cell.props.style?.width));
33
+ }
34
+ }, [children, enableRowSelection, isColumnVirtualized, setNumberColumns, reportDeclaredColumnWidths]);
21
35
  return (_jsxs("tr", { className: cx(classes.root, 'mfui-DataTableHeaderRow__root',
22
- /* TODO: Remove in v5.0.0 */ 'mfui-DataGridHeaderRow__root', className), ...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) => {
23
- if (!isValidElement(child)) {
24
- return child;
25
- }
26
- // Check if the component is DataTableHeaderCell or wraps DataTableHeaderCell
27
- if (isComponentOrWrapped(child, DataTableHeaderCell)) {
28
- // Add selectable offset to columnIndex if selectable is true
29
- const columnIndex = enableRowSelection ? index + 1 : index;
30
- return cloneElement(child, { columnIndex });
31
- }
32
- 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
+ },
33
52
  })] }));
34
53
  });
35
54
  DataTableHeaderRow.displayName = 'DataTableHeaderRow';
@@ -44,4 +44,13 @@ export type DataTableHeaderRowProps = {
44
44
  checkboxProps?: Pick<CheckboxProps, 'aria-label' | 'checked' | 'indeterminate' | 'onChange' | 'disabled' | 'onClick'> & {
45
45
  wrapper?: DataTableSelectionCheckboxWrapper;
46
46
  };
47
+ /**
48
+ * **Do not pass this prop manually.**
49
+ *
50
+ * The 1-based position of this row among the header rows.
51
+ * Used for the `aria-rowindex` of the row while the body is virtualized.
52
+ *
53
+ * @default undefined
54
+ */
55
+ headerRowIndex?: number;
47
56
  } & ComponentPropsWithRef<'tr'>;
@@ -1,14 +1,56 @@
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.
6
8
  */
7
9
  export type DataTableContextProps = Pick<DataTableProps, 'size' | 'enableRowSelection' | 'enableRowClickSelection' | 'fixedHeader' | 'showVerticalBorder' | 'leftFixedColumnIndex' | 'rightFixedColumnIndex' | 'loading' | 'layout'>;
8
- export type DataTableProviderValues = {
10
+ /**
11
+ * The values the `DataTable` owns and shares with its composition components.
12
+ *
13
+ * This should only be used in DataTable component.
14
+ */
15
+ export type DataTableVirtualizationValues = {
16
+ /**
17
+ * The wrapper element of the table, which is the element that scrolls.
18
+ * It is `null` until the wrapper is mounted, and when the body is used outside a `DataTable`.
19
+ */
20
+ scrollElement: HTMLElement | null;
21
+ /**
22
+ * The number of rows the body holds while it is virtualized, or `null` when it is not.
23
+ * The body publishes it so that the table can report the real row count to assistive technology
24
+ * even though only a window of rows is mounted.
25
+ */
26
+ virtualizedRowCount: number | null;
27
+ setVirtualizedRowCount: (rowCount: number | null) => void;
28
+ /**
29
+ * The number of mounted header rows, which offsets the `aria-rowindex` of the body rows.
30
+ */
31
+ headerRowCount: number;
32
+ /**
33
+ * Registers a header row and returns the function to unregister it.
34
+ */
35
+ registerHeaderRow: () => () => void;
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
+ */
9
50
  numberColumns: number;
10
51
  setNumberColumns: (number: number) => void;
11
- } & DataTableContextProps;
52
+ };
53
+ export type DataTableProviderValues = DataTableContextProps & DataTableVirtualizationValues;
12
54
  /**
13
55
  * The context for the `DataTable` component.
14
56
  *
@@ -24,4 +66,4 @@ export declare const useDataTableContext: () => DataTableProviderValues;
24
66
  *
25
67
  * This should only be used in DataTable component.
26
68
  */
27
- export declare function DataTableProvider({ children, size, enableRowSelection, enableRowClickSelection, fixedHeader, showVerticalBorder, leftFixedColumnIndex, rightFixedColumnIndex, loading, layout, }: PropsWithChildren<DataTableContextProps>): 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
  *
@@ -19,6 +19,19 @@ export const DataTableContext = createContext({
19
19
  setNumberColumns: () => {
20
20
  // Do nothing
21
21
  },
22
+ scrollElement: null,
23
+ virtualizedRowCount: null,
24
+ setVirtualizedRowCount: () => {
25
+ // Do nothing
26
+ },
27
+ headerRowCount: 0,
28
+ registerHeaderRow: () => () => {
29
+ // Do nothing
30
+ },
31
+ columnWindow: null,
32
+ reportDeclaredColumnWidths: () => {
33
+ // Do nothing
34
+ },
22
35
  });
23
36
  /**
24
37
  * The hook to use the `DataTableContext`.
@@ -29,8 +42,7 @@ export const useDataTableContext = () => useContext(DataTableContext);
29
42
  *
30
43
  * This should only be used in DataTable component.
31
44
  */
32
- export function DataTableProvider({ children, size = 'medium', enableRowSelection = false, enableRowClickSelection = false, fixedHeader = false, showVerticalBorder = false, leftFixedColumnIndex, rightFixedColumnIndex, loading, layout = 'default', }) {
33
- 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, }) {
34
46
  const contextValue = useMemo(() => ({
35
47
  size,
36
48
  enableRowSelection,
@@ -43,6 +55,13 @@ export function DataTableProvider({ children, size = 'medium', enableRowSelectio
43
55
  layout,
44
56
  numberColumns,
45
57
  setNumberColumns,
58
+ scrollElement,
59
+ virtualizedRowCount,
60
+ setVirtualizedRowCount,
61
+ headerRowCount,
62
+ registerHeaderRow,
63
+ columnWindow,
64
+ reportDeclaredColumnWidths,
46
65
  }), [
47
66
  size,
48
67
  enableRowSelection,
@@ -54,6 +73,14 @@ export function DataTableProvider({ children, size = 'medium', enableRowSelectio
54
73
  loading,
55
74
  layout,
56
75
  numberColumns,
76
+ setNumberColumns,
77
+ scrollElement,
78
+ virtualizedRowCount,
79
+ setVirtualizedRowCount,
80
+ headerRowCount,
81
+ registerHeaderRow,
82
+ columnWindow,
83
+ reportDeclaredColumnWidths,
57
84
  ]);
58
85
  return _jsx(DataTableContext.Provider, { value: contextValue, children: children });
59
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,7 +1,7 @@
1
1
  export { DataTable } from './DataTable';
2
2
  export { getColumnWidthFromContentWidth } from './utils/getColumnWidthFromContentWidth';
3
- export type { DataTableProps } from './DataTable.types';
4
- export type { DataTableBodyProps } from './DataTableBody';
3
+ export type { DataTableProps, DataTableColumnVirtualizationOptions } from './DataTable.types';
4
+ export type { DataTableBodyProps, DataTableBodyVirtualizationOptions } from './DataTableBody';
5
5
  export type { DataTableCellProps } from './DataTableCell';
6
6
  export type { DataTableHeaderProps } from './DataTableHeader';
7
7
  export type { DataTableHeaderCellProps } from './DataTableHeaderCell';
@@ -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})]";
@@ -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 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})]';
@@ -0,0 +1,42 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type DataTableColumnWindow } from '../hooks/useVirtualizedColumns';
3
+ type RenderVirtualizedColumnsParams = {
4
+ /**
5
+ * The cells of the row, flattened, without the selection checkbox cell the row renders itself.
6
+ */
7
+ cells: ReactNode[];
8
+ /**
9
+ * The grid column of the first cell, which is 1 when the row renders a selection checkbox cell.
10
+ */
11
+ columnOffset: number;
12
+ /**
13
+ * The columns to mount, or `null` to mount every cell.
14
+ */
15
+ columnWindow: DataTableColumnWindow | null;
16
+ /**
17
+ * The class name of the spacer cells.
18
+ */
19
+ spacerClassName: string;
20
+ /**
21
+ * Renders a mounted cell.
22
+ *
23
+ * @param cell - The cell to render.
24
+ * @param columnIndex - The grid column of the cell.
25
+ */
26
+ renderCell: (cell: ReactNode, columnIndex: number) => ReactNode;
27
+ };
28
+ /**
29
+ * Applies the column window to the cells of a row. The header row and the rows share it, so that
30
+ * they cannot disagree about which columns are mounted.
31
+ *
32
+ * Each gap left by the columns that are not mounted is filled with a single spacer cell spanning
33
+ * them. Under `table-layout: fixed` the spacer takes exactly the width of the `<col>` elements it
34
+ * spans, so the mounted cells stay in their columns and the sticky offsets of the fixed columns,
35
+ * which are summed over the cells of the row, stay what they are without virtualization.
36
+ *
37
+ * @param params - The cells of the row and the window to apply.
38
+ *
39
+ * @returns The cells to mount, with the spacer cells in place of the columns that are not mounted.
40
+ */
41
+ export declare function renderVirtualizedColumns({ cells, columnOffset, columnWindow, spacerClassName, renderCell, }: RenderVirtualizedColumnsParams): ReactNode[];
42
+ export {};
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Applies the column window to the cells of a row. The header row and the rows share it, so that
4
+ * they cannot disagree about which columns are mounted.
5
+ *
6
+ * Each gap left by the columns that are not mounted is filled with a single spacer cell spanning
7
+ * them. Under `table-layout: fixed` the spacer takes exactly the width of the `<col>` elements it
8
+ * spans, so the mounted cells stay in their columns and the sticky offsets of the fixed columns,
9
+ * which are summed over the cells of the row, stay what they are without virtualization.
10
+ *
11
+ * @param params - The cells of the row and the window to apply.
12
+ *
13
+ * @returns The cells to mount, with the spacer cells in place of the columns that are not mounted.
14
+ */
15
+ export function renderVirtualizedColumns({ cells, columnOffset, columnWindow, spacerClassName, renderCell, }) {
16
+ if (columnWindow === null) {
17
+ return cells.map((cell, index) => renderCell(cell, index + columnOffset));
18
+ }
19
+ const rowColumnCount = cells.length + columnOffset;
20
+ // The bounds are clamped to the row, so a row with fewer cells than the header still lays out.
21
+ const suffixStart = Math.min(columnWindow.suffixStart ?? rowColumnCount, rowColumnCount);
22
+ const prefixEnd = Math.min(columnWindow.prefixEnd, suffixStart);
23
+ const windowStart = Math.min(Math.max(columnWindow.windowStart, prefixEnd), suffixStart);
24
+ const windowEnd = Math.min(Math.max(columnWindow.windowEnd, windowStart), suffixStart);
25
+ const renderRange = (start, end) => cells
26
+ .slice(Math.max(start - columnOffset, 0), Math.max(end - columnOffset, 0))
27
+ .map((cell, index) => renderCell(cell, Math.max(start, columnOffset) + index));
28
+ // The spacer stands in for the columns that are not mounted, so it reserves their width without
29
+ // being announced as a cell of the table.
30
+ const renderSpacer = (position, colSpan) => (_jsx("td", {
31
+ // oxlint-disable-next-line jsx-a11y/no-interactive-element-to-noninteractive-role -- A `td` is not interactive; the role only keeps this empty layout cell from being announced as a cell.
32
+ role: "presentation", colSpan: colSpan, className: spacerClassName, "data-mfui-virtual-spacer": position }, `mfui-virtual-column-spacer-${position}`));
33
+ return [
34
+ ...renderRange(0, prefixEnd),
35
+ windowStart > prefixEnd ? renderSpacer('start', windowStart - prefixEnd) : null,
36
+ ...renderRange(windowStart, windowEnd),
37
+ suffixStart > windowEnd ? renderSpacer('end', suffixStart - windowEnd) : null,
38
+ ...renderRange(suffixStart, rowColumnCount),
39
+ ];
40
+ }
@@ -0,0 +1,28 @@
1
+ import { type CSSProperties } from 'react';
2
+ import { type DataTableColumnVirtualizationOptions } from '../DataTable.types';
3
+ /**
4
+ * The width of a column in pixels when neither `getColumnWidth` nor its header cell declares one.
5
+ */
6
+ export declare const DEFAULT_COLUMN_WIDTH = 160;
7
+ /**
8
+ * The `style.width` of a header cell, which the header row reports for each column.
9
+ */
10
+ export type DeclaredColumnWidth = CSSProperties['width'];
11
+ /**
12
+ * Resolves the width of every consumer column, in pixels, for column virtualization.
13
+ *
14
+ * Each column takes the first of:
15
+ *
16
+ * 1. `getColumnWidth(index)`, when it returns a width.
17
+ * 2. The `style.width` of the column's `DataTable.HeaderCell`, when it is a number or a `px` string.
18
+ * 3. {@link DEFAULT_COLUMN_WIDTH}.
19
+ *
20
+ * @param declaredColumnWidths - The `style.width` of each header cell, which also gives the number
21
+ * of consumer columns.
22
+ *
23
+ * @param getColumnWidth - The consumer's width callback, which receives the column index counted as
24
+ * `leftFixedColumnIndex` is, without the selection column.
25
+ *
26
+ * @returns The width of each consumer column in pixels.
27
+ */
28
+ export declare function resolveColumnWidths(declaredColumnWidths: readonly DeclaredColumnWidth[], getColumnWidth: DataTableColumnVirtualizationOptions['getColumnWidth']): number[];