UNPKG

terra-data-grid

Version:

Package containing data grid container components that enable users to navigate the grid information using directional navigation keys.

666 lines (566 loc) 24 kB
import React, { useState, useRef, useCallback, forwardRef, useImperativeHandle, useMemo, } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames/bind'; import * as KeyCode from 'keycode-js'; import Table, { GridConstants, GridContext, sectionShape, rowShape, columnShape, validateRowHeaderIndex, hasColumnActions, ColumnHighlightColor, } from 'terra-table'; import getFocusableElements from 'terra-table/lib/utils/focusManagement'; import VisuallyHiddenText from 'terra-visually-hidden-text'; import styles from './DataGrid.module.scss'; import './_elementPolyfill'; const cx = classNames.bind(styles); const propTypes = { /** * String that identifies the element (or elements) that labels the grid. */ ariaLabelledBy: PropTypes.string, /** * String that labels the grid for accessibility. If ariaLabelledBy is specified, ariaLabel will not be used. */ ariaLabel: PropTypes.string, /** * String that will be used to identify the Grid. If multiple grids are on the same page, each grid should have * a unique id. */ id: PropTypes.string.isRequired, /** * Data for content in the body of the Grid. Rows will be rendered in the order given. */ rows: PropTypes.arrayOf(rowShape), /** * Data for content in the body of the table. Sections will be rendered in the order given. */ sections: PropTypes.arrayOf(sectionShape), /** * Data for pinned columns. Pinned columns are the stickied leftmost columns of the grid. * Columns will be presented in the order given. */ pinnedColumns: PropTypes.arrayOf(columnShape), /** * Data for overflow columns. Overflow columns are rendered in the Data Grid's horizontal overflow. * Columns will be presented in the order given. */ overflowColumns: PropTypes.arrayOf(columnShape), /** * Number indicating the default column width in px. This value will be used if no overriding width value is provided on a per-column basis. */ defaultColumnWidth: PropTypes.number, /** * String that specifies the column height. Any valid CSS height value is accepted. */ columnHeaderHeight: PropTypes.string, /** * Numeric increment in pixels to adjust column width when resizing via the keyboard. */ columnResizeIncrement: PropTypes.number, /** * String that specifies the height for the rows in the grid. Any valid CSS value is accepted. */ rowHeight: PropTypes.string, /** * Number indicating the index of the column that represents row header. Index is 0 based and cannot exceed one less than the number of columns in the grid. */ rowHeaderIndex: validateRowHeaderIndex, /** * Function that is called when a resizable column is resized. Parameters: * @param {string} columnId columnId * @param {string} requestedWidth requestedWidth */ onColumnResize: PropTypes.func, /** * Callback function that is called when a selectable cell is selected. Parameters: * @param {string} rowId rowId * @param {string} columnId columnId * @param {object} event event */ onCellSelect: PropTypes.func, /** * Function that is called when a collapsible section is selected. Parameters: `onSectionSelect(sectionId)` */ onSectionSelect: PropTypes.func, /** * Callback function that is called when a selectable column is selected. Parameters: * @param {string} columnId columnId */ onColumnSelect: PropTypes.func, /** * Callback function that is called when all selected cells need to be unselected. Parameters: none. */ onClearSelection: PropTypes.func, /** * Callback function that is called when a range selection occurs. Parameters: * @param {number} rowIndex RowIndex of the cell from which the range selection was triggered. * @param {number} columnIndex ColumnIndex of the cell from which the range selection was triggered. * @param {number} direction Direction keycode representing the direction of the selection. */ onRangeSelection: PropTypes.func, /** * Callback function that is called when you click on the row selection header. */ onRowSelectionHeaderSelect: PropTypes.func, /** * Callback function that is called when a cell range selection occurs. Parameters: * @param {number} rowIndex RowIndex of the cell from which the range selection was triggered. * @param {number} columnIndex ColumnIndex of the cell from which the range selection was triggered. * @param {number} direction Direction keycode representing the direction of the selection. */ onCellRangeSelect: PropTypes.func, /** * Boolean indicating whether or not the DataGrid should allow entire rows to be selectable. An additional column will be * rendered to allow for row selection to occur. */ hasSelectableRows: PropTypes.bool, /** * Boolean indicating whether or not the DataGrid should hide the column headers. */ hasVisibleColumnHeaders: PropTypes.bool, /** * 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, /** * Determines if focus is moved to the interactive element of a cell when a single button or hyperlink element is the only interactive element. */ isAutoFocusEnabled: PropTypes.bool, }; const defaultProps = { rowHeaderIndex: 0, defaultColumnWidth: 200, columnHeaderHeight: '2.5rem', pinnedColumns: [], overflowColumns: [], rows: [], hasVisibleColumnHeaders: true, }; const DataGrid = forwardRef((props, ref) => { const { ariaLabel, ariaLabelledBy, columnHeaderHeight, columnResizeIncrement, defaultColumnWidth, hasVisibleColumnHeaders, hasSelectableRows, id, onCellRangeSelect, onCellSelect, onClearSelection, onColumnResize, onColumnSelect, onRangeSelection, onRowSelectionHeaderSelect, onSectionSelect, overflowColumns, pinnedColumns, rowHeaderIndex, rowHeight, rows, sections, rowMinimumHeight, isAutoFocusEnabled, } = props; // By default, all grid-based components have selectable cells. const dataGridRows = useMemo(() => (rows.map((row) => ({ ...row, cells: row.cells.map((cell) => ({ ...cell, isSelectable: cell.isSelectable !== false, })), }))), [rows]); // Reference variable for WorklistDataGrid table element const grid = useRef(); const gridContainerRef = useRef(); const tableContainerRef = useRef(); const tableBodyColumnsRef = useRef(); const handleFocus = useRef(true); const focusedCellRef = useRef({ rowId: '', columnId: '' }); const [checkResizable, setCheckResizable] = useState(false); // check if at least one column has an action prop // same check is done in Table, but as Table can be a stand-alone component, it can't rely on a passed prop. const hasColumnHeaderActions = hasColumnActions(pinnedColumns) || hasColumnActions(overflowColumns); // eslint-disable-next-line no-nested-ternary const firstRowIndex = hasVisibleColumnHeaders ? 0 : 1; // if columns are not visible then set the first selectable row index to 1 or 2 const [focusedRow, setFocusedRow] = useState(firstRowIndex); const [focusedCol, setFocusedCol] = useState(0); // Aria live region message management const [cellAriaLiveMessage, setCellAriaLiveMessage] = useState(null); const gridContextValue = useMemo(() => ({ role: GridConstants.GRID, setCellAriaLiveMessage, tableRef: grid, tableContainerRef, tableBodyColumnsRef, isAutoFocusEnabled, }), [grid, isAutoFocusEnabled, tableContainerRef]); // ------------------------------------- // functions const isRowSelectionCell = useCallback((columnIndex) => ( hasSelectableRows && columnIndex === 0 ), [hasSelectableRows]); const isSection = useCallback((rowIndex) => ( grid.current.rows[rowIndex].hasAttribute('data-section-id') || grid.current.rows[rowIndex].hasAttribute('data-subsection-id') ), []); const setFocusedRowCol = useCallback((newRowIndex, newColIndex, makeActiveElement) => { setCellAriaLiveMessage(null); setFocusedRow(newRowIndex); setFocusedCol(newColIndex); if (newColIndex < tableBodyColumnsRef.current.length) { focusedCellRef.current = { rowId: grid.current.rows[newRowIndex].getAttribute('data-row-id'), columnId: tableBodyColumnsRef.current[newColIndex].id, }; } if (!makeActiveElement) { return; } let focusedCell; if (isSection(newRowIndex)) { [focusedCell] = grid.current.rows[newRowIndex].cells; if (!focusedCell.hasAttribute('tabindex')) { focusedCell = grid.current.rows[newRowIndex].querySelector('button'); } focusedCell?.focus(); return; } focusedCell = grid.current.rows[newRowIndex].cells[newColIndex]; // If there are multiple focusable elements, set focus on the cell if (getFocusableElements(focusedCell).length > 1) { focusedCell?.focus(); return; } // Check if cell is in header row (for focusing on resize handles) const isHeaderRow = newRowIndex === 0 || (hasColumnHeaderActions && newRowIndex === 1); // Set focus to a single header button or hyperlink if they are the only content in cell const cellButtonOrHyperlink = focusedCell.querySelector('a, button'); if ((isHeaderRow && !focusedCell.hasAttribute('tabindex')) || (isAutoFocusEnabled && cellButtonOrHyperlink)) { focusedCell = focusedCell.querySelector('a, button, [role="button"]'); focusedCell?.focus(); return; } // Set focus on input field (checkbox) of row selection cells. const rowSelectionCheckbox = focusedCell.querySelector('input'); if (isRowSelectionCell(newColIndex) && rowSelectionCheckbox) { focusedCell = rowSelectionCheckbox; focusedCell?.focus(); return; } focusedCell?.focus(); }, [isSection, hasColumnHeaderActions, isAutoFocusEnabled, isRowSelectionCell]); // The focus is handled by the DataGrid. However, there are times // when the other components may want to change the currently focus // cells. In order to do so, these datagrid methods will be exposed to // allow those components to request focus change. useImperativeHandle( ref, () => ({ setFocusedRowCol, getFocusedCell() { return { row: focusedRow, col: focusedCol }; }, getGridRef() { return grid.current; }, getTableBodyColumnsRef() { return tableBodyColumnsRef.current; }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [focusedCol, focusedRow], ); // ------------------------------------- const handleMoveCellFocus = (fromCell, toCell) => { if (!isSection(toCell.row)) { // Obtain coordinate rectangles for grid container, column header, and new cell selection const gridContainerRect = tableContainerRef.current.getBoundingClientRect(); const columnHeaderRect = grid.current.rows[0].getBoundingClientRect(); const nextCellRect = grid.current.rows[toCell.row].cells[toCell.col].getBoundingClientRect(); // Calculate horizontal scroll offset for right boundary if (nextCellRect.right > gridContainerRect.right) { tableContainerRef.current.scrollBy(nextCellRect.right - gridContainerRect.right, 0); } else { // Calculate horizontal scroll offset for left boundary let scrollOffsetX = 0; const pinnedColumnOffset = hasSelectableRows ? 1 : 0; const lastPinnedColumnIndex = pinnedColumns.length - 1 + pinnedColumnOffset; if (lastPinnedColumnIndex >= 0) { if (toCell.col > lastPinnedColumnIndex) { const lastPinnedColumnRect = grid.current.rows[toCell.row].cells[lastPinnedColumnIndex].getBoundingClientRect(); scrollOffsetX = nextCellRect.left - lastPinnedColumnRect.right; } } else { scrollOffsetX = nextCellRect.left - gridContainerRect.left; } if (scrollOffsetX < 0) { tableContainerRef.current.scrollBy(scrollOffsetX, 0); } } // Calculate vertical scroll offset const scrollOffsetY = nextCellRect.top - columnHeaderRect.bottom; if (scrollOffsetY < 0) { tableContainerRef.current.scrollBy(0, scrollOffsetY); } } setFocusedRowCol(toCell.row, toCell.col, true); }; // callBack to trigger re-focusing when focused row or col didn't change, but focus update is needed const triggerFocus = useCallback(() => ( setFocusedRowCol(focusedRow, focusedCol, true) ), [setFocusedRowCol, focusedRow, focusedCol]); // ------------------------------------- // event handlers /** * Move focus to next focusable element outside the data grid */ const moveFocusFromGrid = (moveForward) => { // add all elements we want to include in our selection const focusableElementSelector = `#${id}, a[href]:not([tabindex='-1']), area[href]:not([tabindex='-1']), input:not([disabled]):not([tabindex='-1']), ` + 'select:not([disabled]):not([tabindex=\'-1\']), textarea:not([disabled]):not([tabindex=\'-1\']), button:not([disabled]):not([tabindex=\'-1\']), ' + 'iframe:not([tabindex=\'-1\']), [tabindex]:not([tabindex=\'-1\']), [contentEditable=true]:not([tabindex=\'-1\'])'; const focusableElements = [...document.body.querySelectorAll(`${focusableElementSelector}`)].filter( element => !element.hasAttribute('disabled') && !element.getAttribute('aria-hidden') && !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length) && window.getComputedStyle(element).visibility !== 'hidden' && element.closest('[inert]') === null && (element.id === id || !gridContainerRef.current.contains(element)), ); // Identify index of the active element in the DOM excluding data grid children const index = focusableElements.indexOf(gridContainerRef.current); if (index > -1) { // Move focus outside data grid const indexOffset = moveForward ? 1 : -1; let newFocusElement; if (index + indexOffset < focusableElements.length) { newFocusElement = focusableElements[index + indexOffset]; } else if (focusableElements[0].id !== id) { // eslint-disable-next-line prefer-destructuring newFocusElement = focusableElements[0]; } if (newFocusElement) { newFocusElement.focus(); } } }; /** * * @param {HTMLElement} element - The element to check if it is a text input * @returns True if the element is a text input. Otherwise, false. */ const isTextInput = (element) => { const { tagName } = element; if (tagName.toLowerCase() === 'input') { const validTypes = ['text', 'password', 'number', 'email', 'tel', 'url', 'search', 'date', 'datetime', 'datetime-local', 'time', 'month', 'week']; const inputType = element.type; return validTypes.indexOf(inputType) >= 0; } return false; }; const handleKeyDown = (event) => { const cellCoordinates = { row: focusedRow, col: focusedCol }; let nextRow = cellCoordinates.row; let nextCol = cellCoordinates.col; setCheckResizable(false); const targetElement = event.target; const key = event.keyCode; // Allow default behavior if the event target is an editable field if (key !== KeyCode.KEY_TAB && (isTextInput(targetElement) || ['textarea', 'select'].indexOf(targetElement.tagName.toLowerCase()) >= 0 || (targetElement.hasAttribute('contentEditable') && targetElement.getAttribute('contentEditable') !== false))) { return; } // Disable horizontal navigation when section has focus if ((key === KeyCode.KEY_RIGHT || key === KeyCode.KEY_LEFT) && isSection(cellCoordinates.row)) { event.preventDefault(); return; } // Get grid row count const gridRowCount = grid.current.rows.length; switch (key) { case KeyCode.KEY_UP: nextRow -= 1; // Select proper header cell column if ((nextRow === 0 || (hasColumnHeaderActions && nextRow === 1)) && !(cellCoordinates.row === 1 && hasColumnHeaderActions)) { // Account for navigation to column header and action header rows nextCol = tableBodyColumnsRef.current[nextCol].columnHeaderIndex; } break; case KeyCode.KEY_DOWN: nextRow += 1; // Select starting cell for column header when navigating to table body if ((cellCoordinates.row === 0 && !hasColumnHeaderActions) || (hasColumnHeaderActions && cellCoordinates.row === 1)) { // Account for navigation from column header and action header rows let startCellIndex = 0; for (let headerIndex = 0; headerIndex < cellCoordinates.col; headerIndex += 1) { startCellIndex += grid.current.rows[0].cells[headerIndex].colSpan || 1; } nextCol = startCellIndex; } break; case KeyCode.KEY_LEFT: if (event.metaKey) { // Mac: Ctrl + Cmd + Left // Win: Ctrl + Home nextCol = 0; if (event.ctrlKey) { // Mac: Ctrl + Cmd + Left // Windows: Ctrl + Home nextRow = 1; // Assumption is that the first row is the column Heading. } } else { // Left key nextCol -= 1; setCheckResizable(cellCoordinates.row === 0 || (hasColumnHeaderActions && cellCoordinates.row === 1)); } break; case KeyCode.KEY_RIGHT: if (event.metaKey) { // Mac: Cmd + Right // Win: End nextCol = grid.current.rows[nextRow].cells.length - 1; if (event.ctrlKey) { // Mac: Ctrl + Cmd + Right // Windows: Ctrl + End nextRow = gridRowCount - 1; } } else { // Right key nextCol += 1; } break; case KeyCode.KEY_HOME: nextCol = 0; if (event.ctrlKey) { nextRow = 1; // Assumption is that the first row is the column Heading. } break; case KeyCode.KEY_END: nextCol = grid.current.rows[nextRow].cells.length - 1; // Col are zero based. if (event.ctrlKey) { // Though rows are zero based, the header is the first row so the rowsLength will // always be one more than then actual number of data rows. nextRow = gridRowCount - 1; } break; case KeyCode.KEY_ESCAPE: if (onClearSelection) { onClearSelection(); } event.preventDefault(); return; case KeyCode.KEY_TAB: moveFocusFromGrid(!event.shiftKey); event.preventDefault(); return; default: return; } const upDownKeys = new Set([KeyCode.KEY_UP, KeyCode.KEY_DOWN]); const directionalKeys = new Set([KeyCode.KEY_UP, KeyCode.KEY_DOWN, KeyCode.KEY_LEFT, KeyCode.KEY_RIGHT]); if (onRangeSelection && event.shiftKey && upDownKeys.has(event.keyCode)) { onRangeSelection(cellCoordinates.row, cellCoordinates.col, event.keyCode); } if (onCellRangeSelect && event.shiftKey && directionalKeys.has(event.keyCode)) { onCellRangeSelect(cellCoordinates.row, cellCoordinates.col, event.keyCode); } if (nextRow >= gridRowCount || (!isSection(nextRow) && nextCol >= grid.current.rows[nextRow].cells.length)) { event.preventDefault(); // prevent the page from moving with the arrow keys. return; } if (nextCol < 0 || nextRow < (firstRowIndex)) { event.preventDefault(); // prevent the page from moving with the arrow keys. return; } handleMoveCellFocus(cellCoordinates, { row: nextRow, col: nextCol }); event.preventDefault(); // prevent the page from moving with the arrow keys. }; const handleMouseDown = (event) => { // Determine cell containing click event const clickTarget = event.target; const targetCell = clickTarget.closest('td, th'); // Store focused cell position if (targetCell) { // Prevent focus event updates when triggered by mouse handleFocus.current = false; setCheckResizable(false); setFocusedRowCol(targetCell.parentElement.rowIndex, targetCell.cellIndex); } }; /** * Establishes selection state when the WorklistDataGrid gains focus * @param {*} event focus event data */ const onFocus = (event) => { if (!event.currentTarget.contains(event.relatedTarget)) { // Not triggered when swapping focus between children if (handleFocus.current) { let newRowIndex = focusedRow; let newColumnIndex = focusedCol; // Check for last focused row ID. If found set the index. Otherwise set it to the last focused row or last index. if (focusedCellRef.current.rowId) { newRowIndex = [...grid.current.rows].findIndex(row => row.getAttribute('data-row-id') === focusedCellRef.current.rowId); newRowIndex = newRowIndex === -1 ? Math.min(focusedRow, grid.current.rows.length - 1) : newRowIndex; } // Check for last focused column ID. If found set the index. Otherwise set it to the last focused column or last index. if (focusedCellRef.current.columnId) { newColumnIndex = [...grid.current.rows[newRowIndex].cells].findIndex(column => column.getAttribute('data-cell-column-id') === focusedCellRef.current.columnId); newColumnIndex = newColumnIndex === -1 ? Math.min(focusedCol, grid.current.rows[newRowIndex].cells.length - 1) : newColumnIndex; } setFocusedRowCol(newRowIndex, newColumnIndex, true); } } handleFocus.current = true; }; // ------------------------------------- const isGridActive = grid.current?.contains(document.activeElement); const isOneOfHeaderRows = focusedRow === 0 || (hasColumnHeaderActions && focusedRow === 1); return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions <div ref={gridContainerRef} onKeyDown={handleKeyDown} onMouseDown={handleMouseDown} onFocus={onFocus} id={id} className={cx('data-grid-container')} > <GridContext.Provider value={gridContextValue}> <Table id={`${id}-table`} rows={dataGridRows} sections={sections} ariaLabelledBy={ariaLabelledBy} ariaLabel={ariaLabel} activeColumnIndex={(isGridActive && (focusedRow === 0 || (hasColumnHeaderActions && focusedRow === 1))) ? focusedCol : undefined} focusedRowIndex={focusedRow} triggerFocus={triggerFocus} isActiveColumnResizing={isOneOfHeaderRows && checkResizable} columnResizeIncrement={columnResizeIncrement} pinnedColumns={pinnedColumns} overflowColumns={overflowColumns} defaultColumnWidth={defaultColumnWidth} columnHeaderHeight={columnHeaderHeight} rowHeight={rowHeight} rowHeaderIndex={rowHeaderIndex} onColumnResize={onColumnResize} onColumnSelect={onColumnSelect} onSectionSelect={onSectionSelect} onRowSelectionHeaderSelect={onRowSelectionHeaderSelect} onCellSelect={onCellSelect} rowSelectionMode={hasSelectableRows ? 'multiple' : undefined} hasVisibleColumnHeaders={hasVisibleColumnHeaders} isStriped rowMinimumHeight={rowMinimumHeight} /> </GridContext.Provider> <VisuallyHiddenText aria-live="polite" aria-atomic="true" text={cellAriaLiveMessage} /> </div> ); }); DataGrid.defaultProps = defaultProps; DataGrid.propTypes = propTypes; export default DataGrid; export { ColumnHighlightColor };