@itwin/itwinui-react
Version:
A react component library for iTwinUI
916 lines (915 loc) • 26.2 kB
JavaScript
import * as React from 'react';
import cx from 'classnames';
import {
actions as TableActions,
useFlexLayout,
useFilters,
useRowSelect,
useSortBy,
useTable,
useExpanded,
usePagination,
useColumnOrder,
useGlobalFilter,
} from 'react-table';
import { ProgressRadial } from '../ProgressIndicators/ProgressRadial.js';
import {
useGlobals,
useResizeObserver,
useLayoutEffect,
Box,
useWarningLogger,
ShadowRoot,
useMergedRefs,
useLatestRef,
useVirtualScroll,
useId,
} from '../../utils/index.js';
import { TableInstanceContext } from './utils.js';
import { TableRowMemoized } from './TableRowMemoized.js';
import { customFilterFunctions } from './filters/customFilterFunctions.js';
import {
useExpanderCell,
useSelectionCell,
useSubRowFiltering,
useSubRowSelection,
useResizeColumns,
useColumnDragAndDrop,
useScrollToRow,
useStickyColumns,
} from './hooks/index.js';
import {
onExpandHandler,
onFilterHandler,
onToggleHandler,
onShiftSelectHandler,
onSingleSelectHandler,
onTableResizeEnd,
onTableResizeStart,
} from './actionHandlers/index.js';
import { SELECTION_CELL_ID } from './columns/index.js';
import { ColumnHeader } from './ColumnHeader.js';
import { TableExpandableContentMemoized } from './TableExpandableContentMemoized.js';
import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden.js';
let singleRowSelectedAction = 'singleRowSelected';
let shiftRowSelectedAction = 'shiftRowSelected';
export const tableResizeStartAction = 'tableResizeStart';
let tableResizeEndAction = 'tableResizeEnd';
export const iuiId = Symbol('iui-id');
let flattenColumns = (columns) => {
let flatColumns = [];
columns.forEach((column) => {
flatColumns.push(column);
if ('columns' in column)
flatColumns.push(...flattenColumns(column.columns));
});
return flatColumns;
};
export const Table = (props) => {
let {
data,
columns,
isLoading = false,
emptyTableContent,
className,
style,
id,
isSelectable = false,
onSelect,
onRowClick,
selectionMode = 'multi',
isSortable = false,
onSort,
stateReducer,
onBottomReached,
onRowInViewport,
intersectionMargin = 300,
subComponent,
onExpand,
onFilter,
globalFilterValue,
emptyFilteredTableContent,
filterTypes: filterFunctions,
expanderCell,
isRowDisabled,
rowProps,
density = 'default',
selectSubRows = true,
getSubRows,
selectRowOnClick = true,
paginatorRenderer,
pageSize = 25,
isResizable = false,
columnResizeMode = 'fit',
styleType = 'default',
enableVirtualization = false,
enableColumnReordering = false,
headerWrapperProps,
headerProps,
bodyProps,
tableProps,
emptyTableContentProps,
getRowId,
caption = 'Table',
role,
scrollToRow,
useControlledState,
autoResetExpanded,
autoResetFilters,
autoResetGlobalFilter,
autoResetHiddenColumns,
autoResetPage,
autoResetResize,
autoResetSelectedRows,
autoResetSortBy,
defaultCanFilter,
defaultCanSort,
defaultColumn: defaultColumnProp,
disableFilters,
disableGlobalFilter,
disableMultiSort,
disableSortRemove,
disabledMultiRemove,
expandSubRows,
globalFilter,
initialState,
isMultiSortEvent,
manualExpandedKey,
manualFilters,
manualGlobalFilter,
manualRowSelectedKey,
manualSortBy,
maxMultiSortColCount,
orderByFn,
pageCount,
sortTypes,
manualPagination,
paginateExpandedRows,
..._rest
} = props;
let { ariaRestAttributes, nonAriaRestAttributes } = React.useMemo(
() =>
Object.entries(_rest).reduce(
(result, [key, value]) => {
if (key.startsWith('aria-')) result.ariaRestAttributes[key] = value;
else result.nonAriaRestAttributes[key] = value;
return result;
},
{
ariaRestAttributes: {},
nonAriaRestAttributes: {},
},
),
[_rest],
);
let { outerAriaRestAttributes, innerAriaRestAttributes } =
React.useMemo(() => {
if (tableProps || role)
return {
outerAriaRestAttributes: {
...ariaRestAttributes,
},
innerAriaRestAttributes: {},
};
return {
outerAriaRestAttributes: {},
innerAriaRestAttributes: {
...ariaRestAttributes,
},
};
}, [ariaRestAttributes, role, tableProps]);
useGlobals();
let ownerDocument = React.useRef(void 0);
let defaultColumn = React.useMemo(
() => ({
maxWidth: 0,
minWidth: 0,
width: 0,
...defaultColumnProp,
}),
[defaultColumnProp],
);
let rowHeight = React.useMemo(() => {
if ('condensed' === density) return 50;
if ('extra-condensed' === density) return 38;
return 62;
}, [density]);
let onBottomReachedRef = useLatestRef(onBottomReached);
let onRowInViewportRef = useLatestRef(onRowInViewport);
let hasManualSelectionColumn = React.useMemo(() => {
let flatColumns = flattenColumns(columns);
return flatColumns.some((column) => column.id === SELECTION_CELL_ID);
}, [columns]);
let disableUserSelect = React.useCallback((e) => {
if ('Shift' === e.key)
ownerDocument.current &&
(ownerDocument.current.documentElement.style.userSelect = 'none');
}, []);
let enableUserSelect = React.useCallback((e) => {
if ('Shift' === e.key)
ownerDocument.current &&
(ownerDocument.current.documentElement.style.userSelect = '');
}, []);
React.useEffect(() => {
if (!isSelectable || 'multi' !== selectionMode) return;
let ownerDoc = ownerDocument.current;
ownerDoc?.addEventListener('keydown', disableUserSelect);
ownerDoc?.addEventListener('keyup', enableUserSelect);
return () => {
ownerDoc?.removeEventListener('keydown', disableUserSelect);
ownerDoc?.removeEventListener('keyup', enableUserSelect);
};
}, [
isSelectable,
selectionMode,
ownerDocument,
disableUserSelect,
enableUserSelect,
]);
let previousFilter = React.useRef([]);
let currentFilter = React.useRef(previousFilter.current);
let tableStateReducer = React.useCallback(
(newState, action, previousState, instance) => {
switch (action.type) {
case TableActions.toggleSortBy:
onSort?.(newState);
break;
case TableActions.setFilter:
currentFilter.current = onFilterHandler(
newState,
action,
previousState,
currentFilter.current,
instance,
);
break;
case TableActions.toggleRowExpanded:
case TableActions.toggleAllRowsExpanded:
onExpandHandler(newState, instance, onExpand);
break;
case singleRowSelectedAction:
newState = onSingleSelectHandler(
newState,
action,
instance,
onSelect,
hasManualSelectionColumn ? void 0 : isRowDisabled,
);
break;
case shiftRowSelectedAction:
newState = onShiftSelectHandler(
newState,
action,
instance,
onSelect,
hasManualSelectionColumn ? void 0 : isRowDisabled,
);
break;
case TableActions.toggleRowSelected:
case TableActions.toggleAllRowsSelected:
case TableActions.toggleAllPageRowsSelected:
onToggleHandler(
newState,
action,
instance,
onSelect,
hasManualSelectionColumn ? void 0 : isRowDisabled,
);
break;
case tableResizeStartAction:
newState = onTableResizeStart(newState);
break;
case tableResizeEndAction:
newState = onTableResizeEnd(newState, action);
break;
default:
break;
}
return stateReducer
? stateReducer(newState, action, previousState, instance)
: newState;
},
[
hasManualSelectionColumn,
isRowDisabled,
onExpand,
onSelect,
onSort,
stateReducer,
],
);
let filterTypes = React.useMemo(
() => ({
...customFilterFunctions,
...filterFunctions,
}),
[filterFunctions],
);
let hasAnySubRows = React.useMemo(
() =>
data.some((item, index) =>
getSubRows ? getSubRows(item, index) : item.subRows,
),
[data, getSubRows],
);
let getSubRowsWithSubComponents = React.useCallback(
(originalRow, relativeIndex) => {
if (originalRow[iuiId]) return [];
if (originalRow.subRows) return originalRow.subRows;
return [
{
[iuiId]: `subcomponent-${relativeIndex}`,
...originalRow,
},
];
},
[],
);
let getRowIdWithSubComponents = React.useCallback(
(originalRow, relativeIndex, parent) => {
let defaultRowId = parent
? `${parent.id}.${relativeIndex}`
: `${relativeIndex}`;
let rowIdFromUser = getRowId?.(originalRow, relativeIndex, parent);
if (void 0 !== rowIdFromUser && originalRow[iuiId])
return `${rowIdFromUser}-${defaultRowId}`;
return rowIdFromUser ?? defaultRowId;
},
[getRowId],
);
let instance = useTable(
{
manualPagination: manualPagination ?? !paginatorRenderer,
paginateExpandedRows: paginateExpandedRows ?? false,
useControlledState,
autoResetExpanded,
autoResetFilters,
autoResetGlobalFilter,
autoResetHiddenColumns,
autoResetPage,
autoResetResize,
autoResetSelectedRows,
autoResetSortBy,
defaultCanFilter,
defaultCanSort,
disableFilters,
disableGlobalFilter,
disableMultiSort,
disableSortRemove,
disabledMultiRemove,
expandSubRows,
globalFilter,
isMultiSortEvent,
manualExpandedKey,
manualFilters,
manualGlobalFilter,
manualRowSelectedKey,
manualSortBy,
maxMultiSortColCount,
orderByFn,
pageCount: pageCount ?? -1,
sortTypes,
columns,
defaultColumn,
disableSortBy: !isSortable,
stateReducer: tableStateReducer,
filterTypes,
selectSubRows,
data,
getSubRows: subComponent ? getSubRowsWithSubComponents : getSubRows,
initialState: {
pageSize,
...initialState,
},
columnResizeMode,
getRowId: subComponent ? getRowIdWithSubComponents : getRowId,
},
useFlexLayout,
useResizeColumns(ownerDocument),
useFilters,
useSubRowFiltering(hasAnySubRows),
useGlobalFilter,
useSortBy,
useExpanded,
usePagination,
useRowSelect,
useSubRowSelection,
useExpanderCell(subComponent, expanderCell, isRowDisabled),
useSelectionCell(isSelectable, selectionMode, isRowDisabled, density),
useColumnOrder,
useColumnDragAndDrop(enableColumnReordering),
useStickyColumns,
);
let {
getTableProps,
rows,
headerGroups: _headerGroups,
getTableBodyProps,
prepareRow,
state,
allColumns,
dispatch,
page,
gotoPage,
setPageSize,
flatHeaders,
setGlobalFilter,
} = instance;
let headerGroups = _headerGroups;
let logWarning = useWarningLogger();
if (1 === columns.length && 'columns' in columns[0]) {
headerGroups = _headerGroups.slice(1);
if ('development' === process.env.NODE_ENV)
logWarning(
"Table's `columns` prop should not have a top-level `Header` or sub-columns. They are only allowed to be passed for backwards compatibility.\n See https://github.com/iTwin/iTwinUI/wiki/iTwinUI-react-v2-migration-guide#breaking-changes",
);
}
if (
'development' === process.env.NODE_ENV &&
subComponent &&
data.some((item) => !!item.subRows?.length)
)
logWarning(
'Passing both `subComponent` and `data` with `subRows` is not supported. There are features designed for `subRows` that are not compatible with `subComponent` and vice versa.',
);
let areFiltersSet =
allColumns.some(
(column) => null != column.filterValue && '' !== column.filterValue,
) || !!globalFilterValue;
let onRowClickHandler = React.useCallback(
(event, row) => {
let isDisabled = isRowDisabled?.(row.original);
let ctrlPressed = event.ctrlKey || event.metaKey;
if (!isDisabled) onRowClick?.(event, row);
if (
isSelectable &&
!isDisabled &&
selectRowOnClick &&
!event.isDefaultPrevented()
)
if ('multi' === selectionMode && event.shiftKey)
dispatch({
type: shiftRowSelectedAction,
id: row.id,
ctrlPressed: ctrlPressed,
});
else if (row.isSelected || ('single' !== selectionMode && ctrlPressed))
row.toggleRowSelected(!row.isSelected);
else
dispatch({
type: singleRowSelectedAction,
id: row.id,
});
},
[
isRowDisabled,
isSelectable,
selectRowOnClick,
selectionMode,
dispatch,
onRowClick,
],
);
React.useEffect(() => {
setGlobalFilter(globalFilterValue);
}, [globalFilterValue, setGlobalFilter]);
React.useEffect(() => {
setPageSize(pageSize);
}, [pageSize, setPageSize]);
React.useEffect(() => {
if (previousFilter.current !== currentFilter.current) {
previousFilter.current = currentFilter.current;
onFilter?.(currentFilter.current, state, instance.filteredRows);
}
}, [state, instance.filteredRows, onFilter]);
let lastPassedColumns = React.useRef([]);
React.useEffect(() => {
if (
lastPassedColumns.current.length > 0 &&
JSON.stringify(lastPassedColumns.current) !== JSON.stringify(columns)
)
instance.setColumnOrder([]);
lastPassedColumns.current = columns;
}, [columns, instance]);
let paginatorRendererProps = React.useMemo(
() => ({
currentPage: state.pageIndex,
pageSize: state.pageSize,
totalRowsCount: rows.length,
size: 'default' !== density ? 'small' : 'default',
isLoading,
onPageChange: gotoPage,
onPageSizeChange: setPageSize,
totalSelectedRowsCount:
'single' === selectionMode ? 0 : instance.selectedFlatRows.length,
}),
[
density,
gotoPage,
isLoading,
rows.length,
setPageSize,
state.pageIndex,
state.pageSize,
instance.selectedFlatRows,
selectionMode,
],
);
let tableRef = React.useRef(null);
let { scrollToIndex, tableRowRef } = useScrollToRow({
...props,
scrollToRow,
page,
});
let columnRefs = React.useRef({});
let previousTableWidth = React.useRef(0);
let onTableResize = React.useCallback(
({ width }) => {
if (!isResizable) return;
instance.tableWidth = width;
if (width === previousTableWidth.current) return;
previousTableWidth.current = width;
flatHeaders.forEach((header) => {
if (columnRefs.current[header.id])
header.resizeWidth =
columnRefs.current[header.id].getBoundingClientRect().width;
});
if (0 === Object.keys(state.columnResizing.columnWidths).length) return;
dispatch({
type: tableResizeStartAction,
});
},
[
dispatch,
state.columnResizing.columnWidths,
flatHeaders,
instance,
isResizable,
],
);
let [resizeRef] = useResizeObserver(onTableResize);
useLayoutEffect(() => {
if (state.isTableResizing) {
let newColumnWidths = {};
flatHeaders.forEach((column) => {
if (columnRefs.current[column.id])
newColumnWidths[column.id] =
columnRefs.current[column.id].getBoundingClientRect().width;
});
dispatch({
type: tableResizeEndAction,
columnWidths: newColumnWidths,
});
}
});
let { virtualizer, css: virtualizerCss } = useVirtualScroll({
enabled: enableVirtualization,
count: page.length,
getScrollElement: () => tableRef.current,
estimateSize: () => rowHeight,
getItemKey: (index) => page[index].id,
overscan: 1,
});
useLayoutEffect(() => {
if (scrollToIndex)
virtualizer.scrollToIndex(scrollToIndex, {
align: 'start',
});
}, [virtualizer, scrollToIndex]);
let getPreparedRow = React.useCallback(
(index, virtualItem, virtualizer) => {
let row = page[index];
prepareRow(row);
let isRowASubComponent = !!row.original[iuiId] && !!subComponent;
if (isRowASubComponent)
return React.createElement(
TableExpandableContentMemoized,
{
key: row.getRowProps().key,
virtualItem: virtualItem,
ref: enableVirtualization
? virtualizer?.measureElement
: tableRowRef(row),
isDisabled: !!isRowDisabled?.(row.original),
},
subComponent(row),
);
return React.createElement(TableRowMemoized, {
row: row,
rowProps: rowProps,
isLast: index === page.length - 1,
onRowInViewport: onRowInViewportRef,
onBottomReached: onBottomReachedRef,
intersectionMargin: intersectionMargin,
state: state,
key: row.getRowProps().key,
onClick: onRowClickHandler,
subComponent: subComponent,
isDisabled: !!isRowDisabled?.(row.original),
tableHasSubRows: hasAnySubRows,
tableInstance: instance,
expanderCell: expanderCell,
scrollContainerRef: tableRef.current,
tableRowRef: enableVirtualization ? void 0 : tableRowRef(row),
density: density,
virtualItem: virtualItem,
virtualizer: virtualizer,
});
},
[
page,
prepareRow,
subComponent,
rowProps,
onRowInViewportRef,
onBottomReachedRef,
intersectionMargin,
state,
onRowClickHandler,
isRowDisabled,
hasAnySubRows,
instance,
expanderCell,
enableVirtualization,
tableRowRef,
density,
],
);
let updateStickyState = () => {
if (!tableRef.current || flatHeaders.every((header) => !header.sticky))
return;
0 !== tableRef.current.scrollLeft
? dispatch({
type: TableActions.setScrolledRight,
value: true,
})
: dispatch({
type: TableActions.setScrolledRight,
value: false,
});
tableRef.current.scrollLeft !==
tableRef.current.scrollWidth - tableRef.current.clientWidth
? dispatch({
type: TableActions.setScrolledLeft,
value: true,
})
: dispatch({
type: TableActions.setScrolledLeft,
value: false,
});
};
React.useEffect(() => {
updateStickyState();
}, []);
let captionId = useId();
return React.createElement(
TableInstanceContext.Provider,
{
value: instance,
},
React.createElement(
Box,
{
ref: useMergedRefs(
tableRef,
resizeRef,
React.useCallback((element) => {
ownerDocument.current = element?.ownerDocument;
}, []),
),
id: id,
...getTableProps({
className: cx('iui-table', className),
style: {
minWidth: 0,
...style,
},
}),
role: role,
onScroll: () => updateStickyState(),
'data-iui-size': 'default' === density ? void 0 : density,
...outerAriaRestAttributes,
...nonAriaRestAttributes,
},
React.createElement(
ShadowRoot,
null,
React.createElement(
'div',
{
role: 'table',
...innerAriaRestAttributes,
...tableProps,
'aria-labelledby': captionId,
},
React.createElement(
VisuallyHidden,
{
id: captionId,
},
caption,
),
React.createElement('slot', {
name: 'iui-table-header-wrapper',
}),
React.createElement('slot', {
name: 'iui-table-body',
}),
),
React.createElement('slot', {
name: 'iui-table-body-extra',
}),
React.createElement('slot', null),
),
headerGroups.map((headerGroup) => {
headerGroup.headers = headerGroup.headers.filter(
(header) =>
!header.id.includes('iui-table-checkbox-selector_placeholder') &&
!header.id.includes('iui-table-expander_placeholder'),
);
let headerGroupProps = headerGroup.getHeaderGroupProps({
className: 'iui-table-row',
});
return React.createElement(
Box,
{
slot: 'iui-table-header-wrapper',
as: 'div',
key: headerGroupProps.key,
...headerWrapperProps,
className: cx(
'iui-table-header-wrapper',
headerWrapperProps?.className,
),
},
React.createElement(
Box,
{
as: 'div',
...headerProps,
className: cx('iui-table-header', headerProps?.className),
},
React.createElement(
Box,
{
...headerGroupProps,
key: headerGroupProps.key,
},
headerGroup.headers.map((column, index) => {
let dragAndDropProps = column.getDragAndDropProps();
return React.createElement(ColumnHeader, {
...dragAndDropProps,
key: dragAndDropProps.key || column.id || index,
column: column,
areFiltersSet: areFiltersSet,
columnHasExpanders:
hasAnySubRows &&
index ===
headerGroup.headers.findIndex(
(c) => c.id !== SELECTION_CELL_ID,
),
isLast: index === headerGroup.headers.length - 1,
isTableEmpty: 0 === data.length,
isResizable: isResizable,
columnResizeMode: columnResizeMode,
enableColumnReordering: enableColumnReordering,
density: density,
ref: (el) => {
if (el) columnRefs.current[column.id] = el;
},
});
}),
),
),
);
}),
React.createElement(
Box,
{
slot: 'iui-table-body',
as: 'div',
...bodyProps,
...getTableBodyProps({
className: cx(
'iui-table-body',
{
'iui-zebra-striping': 'zebra-rows' === styleType,
},
bodyProps?.className,
),
}),
role: void 0,
},
React.createElement(
ShadowRoot,
{
css: virtualizerCss,
flush: false,
},
enableVirtualization && 0 !== data.length
? React.createElement(
'div',
{
'data-iui-virtualizer': 'root',
style: {
minBlockSize: virtualizer.getTotalSize(),
},
},
React.createElement('slot', null),
)
: React.createElement('slot', null),
),
0 !== data.length &&
React.createElement(
React.Fragment,
null,
enableVirtualization
? virtualizer
.getVirtualItems()
.map((virtualItem) =>
getPreparedRow(virtualItem.index, virtualItem, virtualizer),
)
: page.map((_, index) => getPreparedRow(index)),
),
),
isLoading &&
0 === data.length &&
React.createElement(
TableBodyExtraWrapper,
null,
React.createElement(
TableEmptyWrapper,
emptyTableContentProps,
React.createElement(ProgressRadial, {
indeterminate: true,
}),
),
),
!isLoading &&
0 === data.length &&
!areFiltersSet &&
React.createElement(
TableBodyExtraWrapper,
null,
React.createElement(
TableEmptyWrapper,
emptyTableContentProps,
React.createElement('div', null, emptyTableContent),
),
),
!isLoading &&
(0 === data.length || 0 === rows.length) &&
areFiltersSet &&
React.createElement(
TableBodyExtraWrapper,
null,
React.createElement(
TableEmptyWrapper,
emptyTableContentProps,
React.createElement('div', null, emptyFilteredTableContent),
),
),
isLoading &&
0 !== data.length &&
React.createElement(
TableBodyExtraWrapper,
{
'data-iui-loading': 'true',
},
React.createElement(ProgressRadial, {
indeterminate: true,
size: 'small',
}),
),
paginatorRenderer?.(paginatorRendererProps),
),
);
};
if ('development' === process.env.NODE_ENV) Table.displayName = 'Table';
let TableBodyExtraWrapper = React.forwardRef((props, ref) => {
let { children, ...rest } = props;
return React.createElement(
Box,
{
as: 'div',
ref: ref,
slot: 'iui-table-body-extra',
...rest,
className: cx('iui-table-body-extra', rest.className),
},
children,
);
});
let TableEmptyWrapper = React.forwardRef((props, ref) => {
let { children, ...rest } = props;
return React.createElement(
Box,
{
as: 'div',
ref: ref,
...rest,
className: cx('iui-table-empty', rest.className),
},
children,
);
});