import React, { cloneElement, ComponentPropsWithoutRef, Fragment, JSXElementConstructor, MutableRefObject, ReactElement, useCallback, useContext, useMemo, } from 'react'; import {useControlledState} from '@react-stately/utils'; import {SortDescriptor} from './types/sort-descriptor'; import {useGridNavigation} from './navigate-grid'; import {RowElementProps, TableRow} from './table-row'; import { TableContext, TableContextValue, TableSelectionStyle, } from './table-context'; import {ColumnConfig} from '../../datatable/column-config'; import {TableDataItem} from './types/table-data-item'; import clsx from 'clsx'; import {useInteractOutside} from '@react-aria/interactions'; import {mergeProps, useObjectRef} from '@react-aria/utils'; import {isCtrlKeyPressed} from '@common/utils/keybinds/is-ctrl-key-pressed'; import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query'; import {CheckboxColumnConfig} from '@common/ui/tables/checkbox-column-config'; import {TableHeaderRow} from '@common/ui/tables/table-header-row'; export interface TableProps extends ComponentPropsWithoutRef<'table'> { className?: string; columns: ColumnConfig[]; hideHeaderRow?: boolean; data: T[]; meta?: any; tableRef?: MutableRefObject; selectedRows?: (number | string)[]; defaultSelectedRows?: (number | string)[]; onSelectionChange?: (keys: (number | string)[]) => void; sortDescriptor?: SortDescriptor; onSortChange?: (descriptor: SortDescriptor) => any; enableSorting?: boolean; onDelete?: (items: T[]) => void; enableSelection?: boolean; selectionStyle?: TableSelectionStyle; ariaLabelledBy?: string; onAction?: (item: T, index: number) => void; selectRowOnContextMenu?: boolean; renderRowAs?: JSXElementConstructor>; tableBody?: ReactElement; hideBorder?: boolean; closeOnInteractOutside?: boolean; collapseOnMobile?: boolean; cellHeight?: string; headerCellHeight?: string; } export function Table({ className, columns: userColumns, collapseOnMobile = true, hideHeaderRow = false, hideBorder = false, data, selectedRows: propsSelectedRows, defaultSelectedRows: propsDefaultSelectedRows, onSelectionChange: propsOnSelectionChange, sortDescriptor: propsSortDescriptor, onSortChange: propsOnSortChange, enableSorting = true, onDelete, enableSelection = true, selectionStyle = 'checkbox', ariaLabelledBy, selectRowOnContextMenu, onAction, renderRowAs, tableBody, meta, tableRef: propsTableRef, closeOnInteractOutside = false, cellHeight, headerCellHeight, ...domProps }: TableProps) { const isMobile = useIsMobileMediaQuery(); const isCollapsedMode = !!isMobile && collapseOnMobile; if (isCollapsedMode) { hideHeaderRow = true; hideBorder = true; } const [selectedRows, onSelectionChange] = useControlledState( propsSelectedRows, propsDefaultSelectedRows || [], propsOnSelectionChange, ); const [sortDescriptor, onSortChange] = useControlledState( propsSortDescriptor, undefined, propsOnSortChange, ); const toggleRow = useCallback( (item: TableDataItem) => { const newValues = [...selectedRows]; if (!newValues.includes(item.id)) { newValues.push(item.id); } else { const index = newValues.indexOf(item.id); newValues.splice(index, 1); } onSelectionChange(newValues); }, [selectedRows, onSelectionChange], ); const selectRow = useCallback( // allow deselecting all rows by passing in null (item: TableDataItem | null, merge?: boolean) => { let newValues: (string | number)[] = []; if (item) { newValues = merge ? [...selectedRows?.filter(id => id !== item.id), item.id] : [item.id]; } onSelectionChange(newValues); }, [selectedRows, onSelectionChange], ); // add checkbox columns to config, if selection is enabled const columns = useMemo(() => { const filteredColumns = userColumns.filter(c => { const visibleInMode = c.visibleInMode || 'regular'; if (visibleInMode === 'all') { return true; } if (visibleInMode === 'compact' && isCollapsedMode) { return true; } if (visibleInMode === 'regular' && !isCollapsedMode) { return true; } }); const showCheckboxCell = enableSelection && selectionStyle !== 'highlight' && !isMobile; if (showCheckboxCell) { filteredColumns.unshift(CheckboxColumnConfig); } return filteredColumns; }, [isMobile, userColumns, enableSelection, selectionStyle, isCollapsedMode]); const contextValue: TableContextValue = { isCollapsedMode, cellHeight, headerCellHeight, hideBorder, hideHeaderRow, selectedRows, onSelectionChange, enableSorting, enableSelection, selectionStyle, data, columns, sortDescriptor, onSortChange, toggleRow, selectRow, onAction, selectRowOnContextMenu, meta, collapseOnMobile, }; const navProps = useGridNavigation({ cellCount: enableSelection ? columns.length + 1 : columns.length, rowCount: data.length + 1, }); const tableBodyProps: TableBodyProps = { renderRowAs: renderRowAs as any, }; if (!tableBody) { tableBody = ; } else { tableBody = cloneElement(tableBody, tableBodyProps); } // deselect rows when clicking outside the table const tableRef = useObjectRef(propsTableRef); useInteractOutside({ ref: tableRef, onInteractOutside: e => { if ( closeOnInteractOutside && enableSelection && selectedRows?.length && // don't deselect if clicking on a dialog (for example is table row has a context menu) !(e.target as HTMLElement).closest('[role="dialog"]') ) { onSelectionChange([]); } }, }); return (
{ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (selectedRows?.length) { onSelectionChange([]); } } else if (e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); if (selectedRows?.length) { onDelete?.( data.filter(item => selectedRows?.includes(item.id)), ); } } else if (isCtrlKeyPressed(e) && e.key === 'a') { e.preventDefault(); e.stopPropagation(); if (enableSelection) { onSelectionChange(data.map(item => item.id)); } } }, })} role="grid" tabIndex={0} aria-rowcount={data.length + 1} aria-colcount={columns.length + 1} ref={tableRef} aria-multiselectable={enableSelection ? true : undefined} aria-labelledby={ariaLabelledBy} className={clsx( className, 'isolate select-none text-sm outline-none focus-visible:ring-2', )} > {!hideHeaderRow && } {tableBody}
); } export interface TableBodyProps { renderRowAs?: TableProps['renderRowAs']; } function BasicTableBody({renderRowAs}: TableBodyProps) { const {data} = useContext(TableContext); return ( {data.map((item, rowIndex) => ( ))} ); }