UNPKG

@carbon/react

Version:

React components for the Carbon Design System

487 lines (485 loc) 16 kB
/** * Copyright IBM Corp. 2016, 2026 * * 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 { setupGetInstanceId } from "../../tools/setupGetInstanceId.js"; import { deprecate } from "../../prop-types/deprecate.js"; import { composeEventHandlers } from "../../tools/events.js"; import { getCellId } from "./tools/cells.js"; import { getNextSortState } from "./state/sorting.js"; import getDerivedStateFromProps from "./state/getDerivedStateFromProps.js"; import denormalize from "./tools/denormalize.js"; import { defaultFilterRows } from "./tools/filter.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 TableSlugRow from "./TableSlugRow.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 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 { useEffect, useMemo, useState } from "react"; import PropTypes from "prop-types"; import isEqual from "react-fast-compare"; //#region src/components/DataTable/DataTable.tsx /** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 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" }; 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]; }; /** * 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. */ 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, {}), isExpandedAll: false })); useEffect(() => { const nextRowIds = rows.map((row) => row.id); const nextHeaders = headers.map((header) => header.key); const hasRowIdsChanged = !isEqual(nextRowIds, state.rowIds); const hasHeadersChanged = !isEqual(nextHeaders, Array.from(new Set(Object.keys(state.cellsById).map((id) => id.split(":")[1])))); const hasRowsChanged = !isEqual(rows, state.rowIds.map((id) => { const row = state.rowsById[id]; return { id: row.id, disabled: row.disabled, isExpanded: row.isExpanded, isSelected: row.isSelected }; })); if (hasRowIdsChanged || hasHeadersChanged || hasRowsChanged) setState((prev) => getDerivedStateFromProps(props, prev)); }, [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), "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, 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 (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 }; } 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" : void 0 }; }; const getBatchActionProps = (props) => { const { shouldShowBatchActions } = state; const selectedRowCount = selectedRows.length; return { onSelectAll: void 0, 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; 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) { 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; const selectedRowsCount = !row.isSelected ? selectedRows + 1 : selectedRows - 1; return { ...prev, 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, ...sortState }; }); }; /** * Event handler for table filter input changes. */ const handleOnInputValueChange = (event, defaultValue) => { const value = defaultValue ?? (event === "" ? event : event.target.value); setState((prev) => ({ ...prev, filterInputValue: value })); }; const renderProps = { rows: denormalize(filteredRowIds, state.rowsById, state.cellsById), headers, selectedRows: denormalize(selectedRows, state.rowsById, state.cellsById), getHeaderProps, getExpandHeaderProps, getRowProps, getExpandedRowProps, getSelectionProps, getToolbarProps, getBatchActionProps, getTableProps, getTableContainerProps, getCellProps, onInputChange: handleOnInputValueChange, sortBy: (headerKey) => handleSortBy(headerKey)(), selectAll: handleSelectAll, selectRow: (rowId) => handleOnSelectRow(rowId)(), expandRow: (rowId) => handleOnExpandRow(rowId)(), expandAll: handleOnExpandAll, 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 = { children: PropTypes.func, experimentalAutoAlign: PropTypes.bool, filterRows: PropTypes.func, headers: PropTypes.arrayOf(PropTypes.shape({ key: PropTypes.string.isRequired, header: PropTypes.node.isRequired, isSortable: PropTypes.bool })).isRequired, isSortable: PropTypes.bool, locale: PropTypes.string, overflowMenuOnHover: PropTypes.bool, radio: PropTypes.bool, render: deprecate(PropTypes.func), rows: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, disabled: PropTypes.bool, isSelected: PropTypes.bool, isExpanded: PropTypes.bool })).isRequired, size: PropTypes.oneOf([ "xs", "sm", "md", "lg", "xl" ]), sortRow: PropTypes.func, stickyHeader: PropTypes.bool, translateWithId: PropTypes.func, useStaticWidth: PropTypes.bool, useZebraStyles: PropTypes.bool }; //#endregion export { DataTable };