terra-table
Version:
The Terra Table component provides user a way to display data in an accessible table format.
454 lines (396 loc) • 13.5 kB
JSX
import React, {
useContext,
useEffect,
useRef,
useState,
} from 'react';
import * as KeyCode from 'keycode-js';
import classNames from 'classnames/bind';
import FocusTrap from 'focus-trap-react';
import { injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import ThemeContext from 'terra-theme-context';
import VisuallyHiddenText from 'terra-visually-hidden-text';
import ColumnContext from '../utils/ColumnContext';
import GridContext, { GridConstants } from '../utils/GridContext';
import getFocusableElements from '../utils/focusManagement';
import { ColumnHighlightColor } from '../proptypes/columnShape';
import styles from './Cell.module.scss';
const cx = classNames.bind(styles);
const propTypes = {
/**
* String identifier of the row in which the Cell will be rendered.
*/
rowId: PropTypes.string.isRequired,
/**
* String identifier of the column in which the Cell will be rendered.
*/
columnId: PropTypes.string.isRequired,
/**
* The cell's row position in the table. This is zero based.
*/
rowIndex: PropTypes.number,
/**
* The cell's column position in the table. This is zero based.
*/
columnIndex: PropTypes.number,
/**
* An identifier for the section.
*/
sectionId: PropTypes.string,
/**
* An identifier for the subsection.
*/
subsectionId: PropTypes.string,
/**
* Unique identifier for the parent table
*/
tableId: PropTypes.string.isRequired,
/**
* Content that will be rendered within the Cell.
*/
children: PropTypes.node,
/**
* Boolean indicating if cell contents are masked.
*/
isMasked: PropTypes.bool,
/**
* Provides a custom string for masked cells to be read by screen readers. This value is only applied if the cell is masked.
*/
maskedLabel: PropTypes.string,
/**
* Boolean value indicating whether or not the column header is selectable.
*/
isSelectable: PropTypes.bool,
/**
* Boolean indicating whether the Cell is currently selected.
*/
isSelected: PropTypes.bool,
/**
* String that labels the cell for accessibility.
*/
ariaLabel: PropTypes.string,
/**
* Boolean indicating that the cell is a row header.
*/
isRowHeader: PropTypes.bool,
/**
* Boolean indicating that the cell has been highlighted.
*/
isHighlighted: PropTypes.bool,
/**
* Callback function that will be called when a cell is selected.
*/
onCellSelect: PropTypes.func,
/**
* String that specifies the height of the cell. Any valid CSS value is accepted.
*/
height: PropTypes.string,
/**
* String that specifies the minimum height for the rows on the table. rowHeight takes precedence if valid CSS value is passed.
* With this property the height of the cell will grow to fit the cell content.
*/
rowMinimumHeight: PropTypes.string,
/**
* A zero-based index indicating which column represents the row header.
* Index can be set to -1 if row headers are not required.
*/
rowHeaderIndex: PropTypes.number,
/**
* @private
* The intl object containing translations. This is retrieved from the context automatically by injectIntl.
*/
intl: PropTypes.shape({ formatMessage: PropTypes.func }).isRequired,
/**
* @private
* Id of the first row in table
*/
firstRowId: PropTypes.string,
/**
* @private
* Id of the last row in table
*/
lastRowId: PropTypes.string,
/**
* @private
* The color to be used for highlighting a column.
*/
columnHighlightColor: PropTypes.oneOf(Object.values(ColumnHighlightColor)),
/**
* @private
* The column span index value for a column.
*/
columnSpanIndex: PropTypes.number,
/**
* Enables row selection capabilities for the table.
* Use 'single' for single row selection and 'multiple' for multi-row selection.
*/
rowSelectionMode: PropTypes.string,
};
const defaultProps = {
isMasked: false,
isRowHeader: false,
isSelectable: false,
sectionId: '',
};
function Cell(props) {
const {
ariaLabel,
children,
columnId,
columnIndex,
height,
intl,
isHighlighted,
isMasked,
isRowHeader,
isSelectable,
isSelected,
maskedLabel,
onCellSelect,
rowHeaderIndex,
rowId,
rowIndex,
rowMinimumHeight,
sectionId,
subsectionId,
tableId,
firstRowId,
lastRowId,
columnHighlightColor,
columnSpanIndex,
rowSelectionMode,
} = props;
const cellRef = useRef();
const theme = useContext(ThemeContext);
const gridContext = useContext(GridContext);
const columnContext = useContext(ColumnContext);
const [isInteractable, setIsInteractable] = useState(false);
const [isFocusTrapEnabled, setIsFocusTrapEnabled] = useState(false);
const isGridContext = gridContext.role === GridConstants.GRID;
/**
* Determine if cell has focusable elements
*/
const hasFocusableElements = () => {
const focusableElements = getFocusableElements(cellRef.current);
return focusableElements.length > 0;
};
/**
* Determine if a cell only has a single button or hyperlink
* @returns The auto focusable button or anchor element. If there is no auto focusable element, null is returned.
*/
const getAutoFocusableElement = () => {
if (!gridContext.isAutoFocusEnabled) {
return null;
}
const focusableElements = getFocusableElements(cellRef.current);
if (focusableElements.length > 1) {
return null;
}
return cellRef.current.querySelector('a, button');
};
/**
* Determine if a cell only has a single button or hyperlink
* @returns True if the element only has a single button or hyperlink. Otherwise, false.
*/
const hasOnlySingleButtonOrHyperlink = () => getAutoFocusableElement() !== null;
/**
* Handles the onDeactivate callback for FocusTrap component
*/
const deactivateFocusTrap = () => {
setIsFocusTrapEnabled(false);
if (gridContext.setCellAriaLiveMessage) {
gridContext.setCellAriaLiveMessage(intl.formatMessage({ id: 'Terra.table.resume-navigation' }));
}
};
useEffect(() => {
if (isGridContext) {
const autoFocusableElement = getAutoFocusableElement();
if (autoFocusableElement !== null) {
// Update aria live region when auto focusable element is given focus
autoFocusableElement.addEventListener('focus', () => {
if (gridContext.setCellAriaLiveMessage) {
gridContext.setCellAriaLiveMessage(intl.formatMessage({ id: 'Terra.table.cell-focus-trapped' }));
}
});
} else {
setIsInteractable(hasFocusableElements());
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridContext, intl, isGridContext]);
const handleMouseDown = (event) => {
if (rowSelectionMode && (event.button === 2 || hasFocusableElements())) {
return;
}
if (!isFocusTrapEnabled) {
onCellSelect({
sectionId,
subsectionId,
rowId,
rowIndex: (rowIndex - 1),
columnId,
columnIndex,
columnSpanIndex,
isShiftPressed: event.shiftKey,
isMetaPressed: event.metaKey || event.ctrlKey,
isCellSelectable: (!isMasked && isSelectable),
}, event);
}
};
const handleKeyDown = (event) => {
const key = event.keyCode;
const targetElement = event.target;
if (isFocusTrapEnabled) {
switch (key) {
case KeyCode.KEY_ESCAPE:
deactivateFocusTrap();
break;
default:
}
event.stopPropagation();
} else {
switch (key) {
case KeyCode.KEY_RETURN:
// Lock focus into component
if (isGridContext && targetElement === cellRef.current && hasFocusableElements()) {
// If the current cell has only a single button or hyperlink component, do not enable focus trap
const autoFocusableElement = getAutoFocusableElement();
if (autoFocusableElement !== null) {
autoFocusableElement.focus();
} else {
setIsFocusTrapEnabled(true);
}
// Update aria live region when cell user "dives into" cell
if (gridContext.setCellAriaLiveMessage) {
gridContext.setCellAriaLiveMessage(intl.formatMessage({ id: 'Terra.table.cell-focus-trapped' }));
}
event.stopPropagation();
event.preventDefault();
}
break;
case KeyCode.KEY_ESCAPE:
// Handle escape key event when the cell content is auto focusable
if (isGridContext && targetElement !== cellRef.current && hasOnlySingleButtonOrHyperlink()) {
cellRef.current.focus();
// Update aria live region when focus is returned to table cell element
if (gridContext.setCellAriaLiveMessage) {
gridContext.setCellAriaLiveMessage(intl.formatMessage({ id: 'Terra.table.resume-navigation' }));
}
}
break;
case KeyCode.KEY_SPACE:
if (onCellSelect) {
if (rowSelectionMode && hasFocusableElements()) {
return;
}
onCellSelect({
sectionId,
subsectionId,
rowId,
rowIndex: (rowIndex - 1),
columnId,
columnIndex,
columnSpanIndex,
isShiftPressed: event.shiftKey,
isMetaPressed: event.metaKey || event.ctrlKey,
isCellSelectable: (!isMasked && isSelectable),
}, event);
}
// Prevent scrolling when table cell element has focus
if (['td', 'th'].indexOf(targetElement.tagName.toLowerCase()) >= 0) {
event.preventDefault();
}
break;
default:
}
}
};
// Create cell content for masked and blank cells
let cellContent;
if (isMasked) {
cellContent = (
<span className={cx('no-data-cell', theme.className)}>
{maskedLabel || intl.formatMessage({ id: 'Terra.table.maskedCell' })}
</span>
);
} else if (!children) {
cellContent = (
<span className={cx('no-data-cell', theme.className)}>
{intl.formatMessage({ id: 'Terra.table.blank' })}
</span>
);
} else {
cellContent = children;
}
// Added to check if rowHeight is defined, it will take precedence. Otherwise the minimum row height would be used.
const heightProperties = (height) ? {
height,
} : { minHeight: rowMinimumHeight };
// eslint-disable-next-line react/forbid-dom-props
let cellContentComponent = (<div className={cx('cell-content', theme.className)} style={{ ...heightProperties }}>{cellContent}</div>);
// Render FocusTrap container when within a grid context
if (isGridContext) {
cellContentComponent = (
<FocusTrap
active={isFocusTrapEnabled}
focusTrapOptions={{
returnFocusOnDeactivate: true,
clickOutsideDeactivates: true,
escapeDeactivates: false,
onDeactivate: deactivateFocusTrap,
}}
>
{cellContentComponent}
</FocusTrap>
);
}
// Determine table cell header attribute values
const cellLeftEdge = (columnIndex < columnContext.pinnedColumnOffsets.length) ? columnContext.pinnedColumnOffsets[columnIndex] : null;
const CellTag = isRowHeader ? 'th' : 'td';
const columnHeaderId = `${tableId}-${columnId}-headerCell`;
const rowHeaderId = !isRowHeader && rowHeaderIndex !== -1 ? `${tableId}-rowheader-${rowId} ` : '';
const sectionHeaderId = sectionId ? `${tableId}-${sectionId} ` : '';
const subsectionHeaderId = subsectionId ? `${tableId}-${sectionId}-${subsectionId} ` : '';
let columnHighlight = {};
if (columnHighlightColor) {
columnHighlight = {
[`column-highlight-${columnHighlightColor.toLowerCase()}`]: true,
[`first-highlight-${columnHighlightColor.toLowerCase()}`]: rowId === firstRowId,
[`last-highlight-${columnHighlightColor.toLowerCase()}`]: rowId === lastRowId,
};
}
const className = cx('cell', {
masked: isMasked,
pinned: columnIndex < columnContext.pinnedColumnOffsets.length,
'last-pinned-column': columnIndex === columnContext.pinnedColumnOffsets.length - 1,
selectable: isSelectable && !isMasked,
selected: isSelected && !isMasked,
highlighted: isHighlighted,
blank: !children,
...columnHighlight,
}, theme.className);
return (
<CellTag
id={isRowHeader ? `${tableId}-rowheader-${rowId}` : undefined}
ref={isGridContext || rowSelectionMode ? cellRef : undefined}
aria-selected={isSelected || undefined}
aria-label={ariaLabel}
headers={`${sectionHeaderId}${subsectionHeaderId}${rowHeaderId}${columnHeaderId}`}
tabIndex={isGridContext ? -1 : undefined}
className={className}
data-cell-column-id={`${columnId}-${columnSpanIndex}`}
onMouseDown={onCellSelect ? handleMouseDown : undefined}
onKeyDown={handleKeyDown}
// eslint-disable-next-line react/forbid-component-props
style={{ left: cellLeftEdge }}
{...(isRowHeader && { scope: 'row', role: 'rowheader' })}
>
{cellContentComponent}
{isInteractable && <VisuallyHiddenText text={intl.formatMessage({ id: 'Terra.table.cell-interactable' })} />}
</CellTag>
);
}
Cell.propTypes = propTypes;
Cell.defaultProps = defaultProps;
export default React.memo(injectIntl(Cell));