UNPKG

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
/* 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));