UNPKG

@itwin/itwinui-react

Version:

A react component library for iTwinUI

916 lines (915 loc) 26.2 kB
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, ); });