@carbon/react
Version:
React components for the Carbon Design System
669 lines (644 loc) • 21.1 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import PropTypes from 'prop-types';
import { useMemo, useState, useEffect } from 'react';
import isEqual from 'react-fast-compare';
import getDerivedStateFromProps from './state/getDerivedStateFromProps.js';
import { getNextSortState } from './state/sorting.js';
import { getCellId } from './tools/cells.js';
import denormalize from './tools/denormalize.js';
import { composeEventHandlers } from '../../tools/events.js';
import { defaultFilterRows } from './tools/filter.js';
import { setupGetInstanceId } from '../../tools/setupGetInstanceId.js';
import { Table } from './Table.js';
import TableActionList from './TableActionList.js';
import TableBatchAction from './TableBatchAction.js';
import TableBatchActions from './TableBatchActions.js';
import TableBody from './TableBody.js';
import TableCell from './TableCell.js';
import TableContainer from './TableContainer.js';
import TableDecoratorRow from './TableDecoratorRow.js';
import TableExpandHeader from './TableExpandHeader.js';
import TableExpandRow from './TableExpandRow.js';
import TableExpandedRow from './TableExpandedRow.js';
import TableHead from './TableHead.js';
import TableHeader from './TableHeader.js';
import TableRow from './TableRow.js';
import TableSelectAll from './TableSelectAll.js';
import TableSelectRow from './TableSelectRow.js';
import TableSlugRow from './TableSlugRow.js';
import TableToolbar from './TableToolbar.js';
import TableToolbarAction from './TableToolbarAction.js';
import TableToolbarContent from './TableToolbarContent.js';
import TableToolbarSearch from './TableToolbarSearch.js';
import TableToolbarMenu from './TableToolbarMenu.js';
import { deprecate } from '../../prop-types/deprecate.js';
const getInstanceId = setupGetInstanceId();
const translationIds = {
'carbon.table.row.expand': 'carbon.table.row.expand',
'carbon.table.row.collapse': 'carbon.table.row.collapse',
'carbon.table.all.expand': 'carbon.table.all.expand',
'carbon.table.all.collapse': 'carbon.table.all.collapse',
'carbon.table.all.select': 'carbon.table.all.select',
'carbon.table.all.unselect': 'carbon.table.all.unselect',
'carbon.table.row.select': 'carbon.table.row.select',
'carbon.table.row.unselect': 'carbon.table.row.unselect'
};
/**
* Message IDs that will be passed to translateWithId().
*/
const defaultTranslations = {
[translationIds['carbon.table.all.expand']]: 'Expand all rows',
[translationIds['carbon.table.all.collapse']]: 'Collapse all rows',
[translationIds['carbon.table.row.expand']]: 'Expand current row',
[translationIds['carbon.table.row.collapse']]: 'Collapse current row',
[translationIds['carbon.table.all.select']]: 'Select all rows',
[translationIds['carbon.table.all.unselect']]: 'Unselect all rows',
[translationIds['carbon.table.row.select']]: 'Select row',
[translationIds['carbon.table.row.unselect']]: 'Unselect row'
};
const defaultTranslateWithId = messageId => {
return defaultTranslations[messageId];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
/**
* DataTable components are used to represent a collection of resources,
* displaying a subset of their fields in columns, or headers. We prioritize
* direct updates to the state of what we're rendering, so internally we
* normalize the given data and then denormalize it at render time. Each part of
* the DataTable is accessible through look-up by ID, and updating the state of
* a single entity cascades updates to the consumer.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
const DataTable = props => {
const {
children,
filterRows = defaultFilterRows,
headers,
render,
translateWithId: t = defaultTranslateWithId,
size,
isSortable,
useZebraStyles,
useStaticWidth,
stickyHeader,
overflowMenuOnHover,
experimentalAutoAlign,
radio,
rows
} = props;
const instanceId = useMemo(() => getInstanceId(), []);
const [state, setState] = useState(() => ({
...getDerivedStateFromProps(props, {}),
// Initialize to collapsed. A value of `undefined` is treated as neutral.
isExpandedAll: false
}));
useEffect(() => {
const nextRowIds = rows.map(row => row.id);
const nextHeaders = headers.map(header => header.key);
const hasRowIdsChanged = !isEqual(nextRowIds, state.rowIds);
const currentHeaders = Array.from(new Set(Object.keys(state.cellsById).map(id => id.split(':')[1])));
const hasHeadersChanged = !isEqual(nextHeaders, currentHeaders);
const currentRows = state.rowIds.map(id => {
const row = state.rowsById[id];
return {
id: row.id,
disabled: row.disabled,
isExpanded: row.isExpanded,
isSelected: row.isSelected
};
});
const hasRowsChanged = !isEqual(rows, currentRows);
if (hasRowIdsChanged || hasHeadersChanged || hasRowsChanged) {
setState(prev => getDerivedStateFromProps(props, prev));
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452
}, [headers, rows]);
const getHeaderProps = ({
header,
onClick,
isSortable: headerIsSortable,
...rest
}) => {
const {
sortDirection,
sortHeaderKey
} = state;
const {
key,
slug,
decorator
} = header;
return {
...rest,
key,
sortDirection,
isSortable: headerIsSortable ?? header.isSortable ?? isSortable,
isSortHeader: sortHeaderKey === key,
slug,
decorator,
onClick: event => {
const nextSortState = getNextSortState(props, state, {
key
});
setState(prev => ({
...prev,
...nextSortState
}));
if (onClick) {
handleOnHeaderClick(onClick, {
sortHeaderKey: key,
sortDirection: nextSortState.sortDirection
})(event);
}
}
};
};
const getExpandHeaderProps = ({
onClick,
onExpand,
...rest
} = {}) => {
const {
isExpandedAll,
rowIds,
rowsById
} = state;
const isExpanded = isExpandedAll || rowIds.every(id => rowsById[id].isExpanded);
const translationKey = isExpanded ? translationIds['carbon.table.all.collapse'] : translationIds['carbon.table.all.expand'];
const handlers = [handleOnExpandAll, onExpand];
if (onClick) {
handlers.push(handleOnExpandHeaderClick(onClick, {
isExpanded
}));
}
return {
...rest,
'aria-label': t(translationKey),
// Provide a string of all expanded row IDs, separated by a space.
'aria-controls': rowIds.map(id => `${getTablePrefix()}-expanded-row-${id}`).join(' '),
isExpanded,
onExpand: composeEventHandlers(handlers),
id: `${getTablePrefix()}-expand`
};
};
/**
* Wraps the consumer's `onClick` with sorting metadata.
*/
const handleOnHeaderClick = (onClick, sortParams) => {
return event => onClick(event, sortParams);
};
/**
* Wraps the consumer's `onClick` with sorting metadata.
*/
const handleOnExpandHeaderClick = (onClick, expandParams) => {
return event => onClick(event, expandParams);
};
const getRowProps = ({
row,
onClick,
...rest
}) => {
const translationKey = row.isExpanded ? translationIds['carbon.table.row.collapse'] : translationIds['carbon.table.row.expand'];
return {
...rest,
key: row.id,
onClick,
// Compose the event handlers so we don't overwrite a consumer's `onClick`
// handler
onExpand: composeEventHandlers([handleOnExpandRow(row.id), onClick]),
isExpanded: row.isExpanded,
'aria-label': t(translationKey),
'aria-controls': `${getTablePrefix()}-expanded-row-${row.id}`,
isSelected: row.isSelected,
disabled: row.disabled,
expandHeader: `${getTablePrefix()}-expand`
};
};
const getExpandedRowProps = ({
row,
...rest
}) => {
return {
...rest,
id: `${getTablePrefix()}-expanded-row-${row.id}`
};
};
/**
* Gets the props associated with selection for a header or a row.
*/
const getSelectionProps = ({
onClick,
row,
...rest
} = {}) => {
// If we're given a row, return the selection state values for that row
if (row) {
const translationKey = row.isSelected ? translationIds['carbon.table.row.unselect'] : translationIds['carbon.table.row.select'];
return {
...rest,
checked: row.isSelected,
onSelect: composeEventHandlers([handleOnSelectRow(row.id), onClick]),
id: `${getTablePrefix()}__select-row-${row.id}`,
name: `select-row-${instanceId}`,
'aria-label': t(translationKey),
disabled: row.disabled,
radio
};
}
// Otherwise, we're working on `TableSelectAll` which handles toggling the
// selection state of all rows.
const rowCount = state.rowIds.length;
const selectedRowCount = selectedRows.length;
const checked = rowCount > 0 && selectedRowCount === rowCount;
const indeterminate = rowCount > 0 && selectedRowCount > 0 && selectedRowCount !== rowCount;
const translationKey = checked || indeterminate ? translationIds['carbon.table.all.unselect'] : translationIds['carbon.table.all.select'];
return {
...rest,
'aria-label': t(translationKey),
checked,
id: `${getTablePrefix()}__select-all`,
indeterminate,
name: `select-all-${instanceId}`,
onSelect: composeEventHandlers([handleSelectAll, onClick])
};
};
const getToolbarProps = props => {
const isSmall = size === 'xs' || size === 'sm';
return {
...props,
size: isSmall ? 'sm' : undefined
};
};
const getBatchActionProps = props => {
const {
shouldShowBatchActions
} = state;
const selectedRowCount = selectedRows.length;
return {
onSelectAll: undefined,
totalCount: state.rowIds.length,
...props,
shouldShowBatchActions: shouldShowBatchActions && selectedRowCount > 0,
totalSelected: selectedRowCount,
onCancel: handleOnCancel
};
};
const getTableProps = () => {
return {
useZebraStyles,
size: size ?? 'lg',
isSortable,
useStaticWidth,
stickyHeader,
overflowMenuOnHover: overflowMenuOnHover ?? false,
experimentalAutoAlign
};
};
const getTableContainerProps = () => {
return {
stickyHeader,
useStaticWidth
};
};
const getCellProps = ({
cell: {
hasAILabelHeader,
id
},
...rest
}) => {
return {
...rest,
hasAILabelHeader,
key: id
};
};
/**
* Selected row IDs, excluding disabled rows.
*/
const selectedRows = state.rowIds.filter(id => {
const row = state.rowsById[id];
return row.isSelected && !row.disabled;
});
const filteredRowIds = typeof state.filterInputValue === 'string' ? filterRows({
cellsById: state.cellsById,
getCellId,
headers,
inputValue: state.filterInputValue,
rowIds: state.rowIds
}) : state.rowIds;
/**
* Generates a prefix for table related IDs.
*/
const getTablePrefix = () => `data-table-${instanceId}`;
/**
* Generates a new `rowsById` object with updated selection state.
*/
const getUpdatedSelectionState = (initialState, isSelected) => {
const {
rowIds
} = initialState;
const isFiltered = rowIds.length !== filteredRowIds.length;
return {
rowsById: rowIds.reduce((acc, id) => {
const row = {
...initialState.rowsById[id]
};
if (!row.disabled && (!isFiltered || filteredRowIds.includes(id))) {
row.isSelected = isSelected;
}
// Local mutation for performance with large tables
acc[id] = row;
return acc;
}, {})
};
};
/**
* Handler for `onCancel` to hide the batch action toolbar and deselect all
* rows.
*/
const handleOnCancel = () => {
setState(prev => {
return {
...prev,
shouldShowBatchActions: false,
...getUpdatedSelectionState(prev, false)
};
});
};
/**
* Handler for toggling the selection state of all rows.
*/
const handleSelectAll = () => {
setState(prev => {
const {
rowsById
} = prev;
const isSelected = !Object.values(rowsById).filter(row => row.isSelected && !row.disabled).length;
return {
...prev,
shouldShowBatchActions: isSelected,
...getUpdatedSelectionState(prev, isSelected)
};
});
};
/**
* Handler for toggling selection state of a given row.
*/
const handleOnSelectRow = rowId => () => {
setState(prev => {
const row = prev.rowsById[rowId];
if (radio) {
// Deselect all radio buttons, then toggle the target row
const rowsById = Object.entries(prev.rowsById).reduce((acc, [id, row]) => {
acc[id] = {
...row,
isSelected: false
};
return acc;
}, {});
return {
...prev,
shouldShowBatchActions: false,
rowsById: {
...rowsById,
[rowId]: {
...rowsById[rowId],
isSelected: !rowsById[rowId].isSelected
}
}
};
}
const selectedRows = prev.rowIds.filter(id => prev.rowsById[id].isSelected).length;
// Predict the length of the selected rows after this change occurs
const selectedRowsCount = !row.isSelected ? selectedRows + 1 : selectedRows - 1;
return {
...prev,
// Show batch action toolbar if selecting, or if there are other
// selected rows remaining.
shouldShowBatchActions: !row.isSelected || selectedRowsCount > 0,
rowsById: {
...prev.rowsById,
[rowId]: {
...row,
isSelected: !row.isSelected
}
}
};
});
};
const handleOnExpandRow = rowId => () => {
setState(prev => {
const row = prev.rowsById[rowId];
const {
isExpandedAll
} = prev;
return {
...prev,
isExpandedAll: row.isExpanded ? false : isExpandedAll,
rowsById: {
...prev.rowsById,
[rowId]: {
...row,
isExpanded: !row.isExpanded
}
}
};
});
};
const handleOnExpandAll = () => {
setState(prev => {
const {
rowIds,
isExpandedAll
} = prev;
return {
...prev,
isExpandedAll: !isExpandedAll,
rowsById: rowIds.reduce((acc, id) => {
acc[id] = {
...prev.rowsById[id],
isExpanded: !isExpandedAll
};
return acc;
}, {})
};
});
};
/**
* Transitions to the next sort state of the table.
*/
const handleSortBy = headerKey => () => {
setState(prev => {
const sortState = getNextSortState(props, prev, {
key: headerKey
});
return {
...prev,
// Preserve ALL existing state
...sortState // Then apply only the sorting changes
};
});
};
/**
* Event handler for table filter input changes.
*/
const handleOnInputValueChange = (event, defaultValue) => {
const value = defaultValue || event.target?.value;
setState(prev => ({
...prev,
filterInputValue: value
}));
};
const renderProps = {
// Data derived from state
rows: denormalize(filteredRowIds, state.rowsById, state.cellsById),
headers: headers,
selectedRows: denormalize(selectedRows, state.rowsById, state.cellsById),
// Prop accessors/getters
getHeaderProps,
getExpandHeaderProps,
getRowProps,
getExpandedRowProps,
getSelectionProps,
getToolbarProps,
getBatchActionProps,
getTableProps,
getTableContainerProps,
getCellProps,
// Custom event handlers
onInputChange: handleOnInputValueChange,
// Expose internal state change actions
sortBy: headerKey => handleSortBy(headerKey)(),
selectAll: handleSelectAll,
selectRow: rowId => handleOnSelectRow(rowId)(),
expandRow: rowId => handleOnExpandRow(rowId)(),
expandAll: handleOnExpandAll,
radio: radio
};
if (typeof render !== 'undefined') {
return render(renderProps);
}
if (typeof children !== 'undefined') {
return children(renderProps);
}
return null;
};
DataTable.Table = Table;
DataTable.TableActionList = TableActionList;
DataTable.TableBatchAction = TableBatchAction;
DataTable.TableBatchActions = TableBatchActions;
DataTable.TableBody = TableBody;
DataTable.TableCell = TableCell;
DataTable.TableContainer = TableContainer;
DataTable.TableDecoratorRow = TableDecoratorRow;
DataTable.TableExpandHeader = TableExpandHeader;
DataTable.TableExpandRow = TableExpandRow;
DataTable.TableExpandedRow = TableExpandedRow;
DataTable.TableHead = TableHead;
DataTable.TableHeader = TableHeader;
DataTable.TableRow = TableRow;
DataTable.TableSelectAll = TableSelectAll;
DataTable.TableSelectRow = TableSelectRow;
DataTable.TableSlugRow = TableSlugRow;
DataTable.TableToolbar = TableToolbar;
DataTable.TableToolbarAction = TableToolbarAction;
DataTable.TableToolbarContent = TableToolbarContent;
DataTable.TableToolbarSearch = TableToolbarSearch;
DataTable.TableToolbarMenu = TableToolbarMenu;
DataTable.propTypes = {
/**
* Pass in the children that will be rendered within the Table
*/
children: PropTypes.func,
/**
* Experimental property. Allows table to align cell contents to the top if there is text wrapping in the content. Might have performance issues, intended for smaller tables
*/
experimentalAutoAlign: PropTypes.bool,
/**
* Optional hook to manually control filtering of the rows from the
* TableToolbarSearch component
*/
filterRows: PropTypes.func,
/**
* The `headers` prop represents the order in which the headers should
* appear in the table. We expect an array of objects to be passed in, where
* `key` is the name of the key in a row object, and `header` is the name of
* the header.
*/
headers: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
header: PropTypes.node.isRequired,
isSortable: PropTypes.bool
})).isRequired,
/**
* Specify whether the table should be able to be sorted by its headers
*/
isSortable: PropTypes.bool,
/**
* Provide a string for the current locale
*/
locale: PropTypes.string,
/**
* Specify whether the overflow menu (if it exists) should be shown always, or only on hover
*/
overflowMenuOnHover: PropTypes.bool,
/**
* Specify whether the control should be a radio button or inline checkbox
*/
radio: PropTypes.bool,
/**
* @deprecated Use `children` instead. This prop will be removed in
* the next major version.
*
* https://www.patterns.dev/react/render-props-pattern/#children-as-a-function
*/
render: deprecate(PropTypes.func),
/**
* The `rows` prop is where you provide us with a list of all the rows that
* you want to render in the table. The only hard requirement is that this
* is an array of objects, and that each object has a unique `id` field
* available on it.
*/
rows: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
disabled: PropTypes.bool,
isSelected: PropTypes.bool,
isExpanded: PropTypes.bool
})).isRequired,
/**
* Change the row height of table. Currently supports `xs`, `sm`, `md`, `lg`, and `xl`.
*/
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
/**
* Optional hook to manually control sorting of the rows.
*/
sortRow: PropTypes.func,
/**
* Specify whether the header should be sticky.
* Still experimental: may not work with every combination of table props
*/
stickyHeader: PropTypes.bool,
/**
* Translates component strings using your i18n tool.
*/
translateWithId: PropTypes.func,
/**
* If `true`, sets the table width to `auto` instead of `100%`.
*/
useStaticWidth: PropTypes.bool,
/**
* `true` to add useZebraStyles striping.
*/
useZebraStyles: PropTypes.bool
};
export { DataTable };