terra-table
Version:
The Terra Table component provides user a way to display data in an accessible table format.
471 lines (411 loc) • 14.6 kB
JSX
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import * as KeyCode from 'keycode-js';
import classNames from 'classnames/bind';
import ThemeContext from 'terra-theme-context';
import VisuallyHiddenText from 'terra-visually-hidden-text';
import Button from 'terra-button';
import { IconUp, IconDown, IconError } from 'terra-icon';
import { validateAction } from '../proptypes/validators';
import ColumnResizeHandle from './ColumnResizeHandle';
import GridContext, { GridConstants } from '../utils/GridContext';
import { ColumnHighlightColor, SortIndicators } from '../proptypes/columnShape';
import ColumnContext from '../utils/ColumnContext';
import styles from './ColumnHeaderCell.module.scss';
const cx = classNames.bind(styles);
const propTypes = {
/**
* Required string representing a unique identifier for the column header cell.
*/
id: PropTypes.string.isRequired,
/**
* Unique identifier for the parent table
*/
tableId: PropTypes.string.isRequired,
/**
* Unique identifier for the column
*/
columnId: PropTypes.string.isRequired,
/**
* CallBack to trigger re-focusing when focused row or col didn't change, but focus update is needed
*/
triggerFocus: PropTypes.func,
/**
* String of text to render within the column header cell.
*/
displayName: PropTypes.string,
/**
* A string indicating which sorting indicator should be rendered. If not provided, no sorting indicator will be rendered.
* If a `component` value is specified, `sortIndicator` will be ignored. One of `ascending`, `descending`.
*/
sortIndicator: PropTypes.oneOf(Object.values(SortIndicators)),
/**
* Boolean value indicating whether or not the column has an error in the data.
*/
hasError: PropTypes.bool,
/**
* Number that specifies the minimum column width in pixels.
*/
minimumWidth: PropTypes.number,
/**
* Number that specifies the maximum column width in pixels.
*/
maximumWidth: PropTypes.number,
/**
* Boolean value indicating whether or not the header cell is focused.
*/
isActive: PropTypes.bool,
/**
* Boolean that specifies that header cell owns a resize handle.
*/
ownsResizeHandle: PropTypes.bool,
/**
* Boolean value indicating whether or not the header cell text is displayed in the cell.
*/
isDisplayVisible: PropTypes.bool,
/**
* Boolean value indicating whether or not the column header is selectable.
*/
isSelectable: PropTypes.bool,
/**
* Boolean value indicating whether or not the column header cell is an action cell.
* The action cell might be a placeholder cell without actual action button
*/
isActionCell: PropTypes.bool,
/**
* Data for action cell.
*/
action: validateAction,
/**
* Boolean value indicating whether or not the column header is resizable.
*/
isResizable: PropTypes.bool,
/**
* Boolean value indicating whether or not the column resize handle is active.
*/
isResizeHandleActive: PropTypes.bool,
/**
* A function to be executed upon the resize handler activation to pass its data to parent component.
* @param {element} leftNeighborCell - `columnHeaderCellRef.current`
* Skip both parameters to indicate that there is no active resize handle at the moment.
*/
resizeHandleStateSetter: PropTypes.func,
/**
* String that specifies the initial height for the resize handler to accommodate actions row.
*/
initialHeight: PropTypes.string,
/**
* Height of the parent table.
*/
tableHeight: PropTypes.number,
/**
* Boolean value indicating whether or not the column header is resizable.
*/
isResizeActive: PropTypes.bool,
/**
* Numeric increment in pixels to adjust column width when resizing via the keyboard.
*/
columnResizeIncrement: PropTypes.number,
/**
* The number (in px) specifying the width of the column.
*/
width: PropTypes.number,
/**
* String that specifies the column height. Any valid CSS height value accepted.
*/
headerHeight: PropTypes.string.isRequired,
/**
* The cell's column position in the grid. This is zero based.
*/
columnIndex: PropTypes.number,
/**
* The column span value for a column.
*/
columnSpan: PropTypes.number,
/**
* Function that is called when a selectable header cell is selected. Parameters:
* @param {string} rowId rowId
* @param {string} columnId columnId
*/
onColumnSelect: PropTypes.func,
/**
* Function that is called when the mouse down event is triggered on the column resize handle.
*/
onResizeMouseDown: PropTypes.func,
/**
* Function that is called when the the keyboard is used to adjust the column size.
*/
onResizeHandleChange: PropTypes.func,
/**
* @private
* Object containing intl APIs
*/
intl: PropTypes.shape({ formatMessage: PropTypes.func }),
/**
* @private
* The information to be conveyed to screen readers about the highlighted column.
*/
columnHighlightDescription: PropTypes.string,
/**
* @private
* The color to be used for highlighting a column.
*/
columnHighlightColor: PropTypes.oneOf(Object.values(ColumnHighlightColor)),
};
const defaultProps = {
hasError: false,
isSelectable: false,
isActive: false,
isDisplayVisible: true,
isResizable: false,
isResizeActive: false,
};
const ColumnHeaderCell = (props) => {
const {
id,
tableId,
isActionCell,
action,
displayName,
sortIndicator,
hasError,
isActive,
isDisplayVisible,
isSelectable,
isResizable,
isResizeHandleActive,
resizeHandleStateSetter,
initialHeight,
triggerFocus,
columnId,
tableHeight,
isResizeActive,
columnResizeIncrement,
width,
minimumWidth,
maximumWidth,
headerHeight,
onColumnSelect,
intl,
columnIndex,
columnSpan,
onResizeMouseDown,
onResizeHandleChange,
ownsResizeHandle,
columnHighlightDescription,
columnHighlightColor,
} = props;
const columnContext = useContext(ColumnContext);
const gridContext = useContext(GridContext);
const columnHeaderCellRef = useRef();
const setResizeHandleActive = useCallback((setActive) => {
if (setActive) {
resizeHandleStateSetter(columnId);
} else {
resizeHandleStateSetter();
}
}, [columnId, resizeHandleStateSetter]);
const isGridContext = gridContext.role === GridConstants.GRID;
useEffect(() => {
if (isActive) {
if (isResizable && isResizeActive) {
setResizeHandleActive(true);
} else {
columnHeaderCellRef.current.focus();
setResizeHandleActive(false);
}
} else {
setResizeHandleActive(false);
}
}, [isActive, isResizable, isResizeActive]);
const onResizeHandleMouseDown = useCallback((event) => {
event.stopPropagation();
if (onResizeMouseDown) {
onResizeMouseDown(event, columnIndex, columnHeaderCellRef.current.offsetWidth);
}
}, [columnIndex, onResizeMouseDown]);
// Restore focus to column header after resize action is completed.
const onResizeHandleMouseUp = useCallback(() => {
columnHeaderCellRef.current.focus();
setResizeHandleActive(false);
}, []);
// Handle column header selection via the mouse click.
const handleMouseDown = () => {
onColumnSelect(columnId);
};
// Handle column header selection via the space bar.
const handleKeyDown = (event, callback) => {
const key = event.keyCode;
switch (key) {
case KeyCode.KEY_SPACE:
case KeyCode.KEY_RETURN:
if (callback) {
// for action button
callback();
break;
} else if (isSelectable && onColumnSelect) {
onColumnSelect(columnId);
}
event.stopPropagation();
event.preventDefault(); // prevent the default scrolling
break;
case KeyCode.KEY_LEFT:
if (isResizable && isResizeHandleActive && isGridContext) {
setResizeHandleActive(false);
if (triggerFocus) {
triggerFocus();
}
event.stopPropagation();
event.preventDefault();
}
break;
case KeyCode.KEY_RIGHT:
if (isResizable && !isResizeHandleActive && isGridContext) {
setResizeHandleActive(true);
event.stopPropagation();
event.preventDefault();
}
break;
default:
}
};
const errorIcon = hasError && <IconError className={cx('error-icon')} />;
// Add the sort indicator based on the sort direction
let sortIndicatorIcon;
let sortDescription = '';
if (sortIndicator === SortIndicators.ASCENDING) {
sortIndicatorIcon = <IconUp />;
sortDescription = intl.formatMessage({ id: 'Terra.table.sort-ascending' });
} else if (sortIndicator === SortIndicators.DESCENDING) {
sortIndicatorIcon = <IconDown />;
sortDescription = intl.formatMessage({ id: 'Terra.table.sort-descending' });
}
// Add column highlight indicator based on color
let columnHighlightIcon;
if (columnHighlightColor === ColumnHighlightColor.GREEN) {
columnHighlightIcon = <svg className={cx('highlight-icon-svg')} xmlns="http://www.w3.org/2000/svg"><circle className={cx('highlight-icon-circle')} r="3" cx="110%" cy="11" transform="translate(-5)" /></svg>;
} else if (columnHighlightColor === ColumnHighlightColor.ORANGE) {
columnHighlightIcon = <svg className={cx('highlight-icon-svg')} xmlns="http://www.w3.org/2000/svg"><rect className={cx('highlight-icon-square')} x="110%" y="7.5" transform="translate(-8)" /></svg>;
}
// Retrieve current theme from context
const theme = useContext(ThemeContext);
// Calculate cell left position for pinned columns due to their sticky position style
const cellLeftEdge = (columnIndex < columnContext.pinnedColumnHeaderOffsets.length) ? columnContext.pinnedColumnHeaderOffsets[columnIndex] : null;
// For tables, we want elements to be tabbable when selectable, but not anytime else.
let buttonTabIndex = isSelectable ? 0 : undefined;
// Determine if button element is required for column header
const hasButtonElement = (isSelectable && displayName) || (isActionCell && action);
let cellTabIndex;
if (isGridContext) {
if (columnIndex === 0 && !isActionCell) {
buttonTabIndex = isSelectable && displayName ? 0 : undefined;
cellTabIndex = !hasButtonElement ? 0 : undefined;
} else {
// For grids, we only want 1 tab stop. We then define the focus behavior in DataGrid.
buttonTabIndex = isSelectable && displayName ? -1 : undefined;
cellTabIndex = !hasButtonElement ? -1 : undefined;
}
}
// Format header description for screenreader
let headerDescription = displayName;
headerDescription += errorIcon ? `, ${intl.formatMessage({ id: 'Terra.table.columnError' })}` : '';
headerDescription += sortDescription ? `, ${sortDescription}` : '';
headerDescription += columnHighlightDescription ? `, ${columnHighlightDescription}` : '';
const isPinnedColumn = columnIndex < columnContext.pinnedColumnHeaderOffsets.length;
const CellTag = !isActionCell ? 'th' : 'td';
const setColumnHeaderCellRef = (node) => {
columnHeaderCellRef.current = node;
};
// Create cell content
let cellContent;
if (isActionCell) {
if (action) {
cellContent = (
<Button
variant="de-emphasis"
isCompact
refCallback={setColumnHeaderCellRef}
onClick={action.onClick}
onKeyDown={(event) => handleKeyDown(event, action?.onClick)}
text={action.label}
/>
);
} else {
cellContent = (
<span className={cx('display-text', 'hidden')}>
{intl.formatMessage({ id: 'Terra.table.noAction' })}
</span>
);
}
} else {
cellContent = (
<div
className={cx('header-container')}
{...hasButtonElement && { ref: columnHeaderCellRef, role: 'button' }}
tabIndex={buttonTabIndex}
>
{errorIcon}
<span aria-hidden className={cx('display-text', { hidden: !isDisplayVisible })}>{displayName}</span>
{sortIndicatorIcon}
<VisuallyHiddenText text={headerDescription} />
{columnHighlightIcon}
</div>
);
}
const resizeHandleId = `${tableId}-${columnId}-resizeHandle`;
return (
/* eslint-disable react/forbid-dom-props */
<CellTag
ref={!hasButtonElement ? columnHeaderCellRef : undefined}
id={`${tableId}-${id}`}
key={id}
className={cx('column-header', theme.className, {
'action-cell': isActionCell,
selectable: isSelectable,
pinned: isPinnedColumn,
'last-pinned-column': columnIndex === columnContext.pinnedColumnHeaderOffsets.length - 1,
})}
tabIndex={cellTabIndex}
role={!isActionCell ? 'columnheader' : undefined}
scope={!isActionCell ? 'col' : undefined}
// action Cell has to own a corresponding resize handle to avoid a double announcement on handle focus
aria-owns={ownsResizeHandle ? resizeHandleId : undefined}
title={!isActionCell ? displayName : action?.label}
colSpan={columnSpan}
onMouseDown={isSelectable && onColumnSelect ? handleMouseDown : undefined}
onKeyDown={(isSelectable || isResizable) ? handleKeyDown : undefined}
// eslint-disable-next-line react/forbid-component-props
style={{ height: isActionCell ? 'auto' : headerHeight, left: cellLeftEdge, top: isActionCell ? headerHeight : undefined }}
>
{cellContent}
{ isResizable && !isActionCell && (
<ColumnResizeHandle
id={resizeHandleId}
columnIndex={columnIndex}
columnText={displayName}
columnWidth={width}
columnResizeIncrement={columnResizeIncrement}
isActive={isResizeHandleActive}
setIsActive={setResizeHandleActive}
height={tableHeight}
initialHeight={initialHeight}
minimumWidth={minimumWidth}
maximumWidth={maximumWidth}
onResizeMouseDown={onResizeHandleMouseDown}
onResizeMouseUp={onResizeHandleMouseUp}
onResizeHandleChange={onResizeHandleChange}
/>
)}
</CellTag>
);
};
ColumnHeaderCell.propTypes = propTypes;
ColumnHeaderCell.defaultProps = defaultProps;
export default React.memo(injectIntl(ColumnHeaderCell));