UNPKG

terra-data-grid

Version:

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

408 lines (352 loc) 14.4 kB
/* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useRef, useCallback, useEffect, useMemo, } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames/bind'; import * as KeyCode from 'keycode-js'; import { rowShape, columnShape, validateRowHeaderIndex } from 'terra-table'; import styles from './WorklistDataGrid.module.scss'; import DataGrid from './DataGrid'; import WorklistDataGridUtils from './utils/WorklistDataGridUtils'; 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 pinned columns. Pinned columns are the stickied leftmost columns of the grid. * Columns are rendered in the order in which they are provided. */ pinnedColumns: PropTypes.arrayOf(columnShape), /** * Data for overflow columns. Overflow columns are rendered in the Worklist Data Grid's horizontal overflow. * Columns are rendered in the order in which they are provided. */ 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, /** * 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, /** * Callback function that is called when one or more rows are selected or unselected. Parameters: * @param {arrayOf({string, boolean})} rowsToSelectAndUnSelect rowsToSelectAndUnSelect */ onRowSelect: PropTypes.func, /** * Callback function that is called when all rows are selected. Parameters: none. */ onRowSelectAll: 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 rows need to be unselected. Parameters: none. */ onClearSelectedRows: PropTypes.func, /** * Callback function that is called when all selected cells need to be unselected. Parameters: none. */ onClearSelectedCells: PropTypes.func, /** * Callback function that is called when no row is selected and the row selection mode needs to be disabled. Parameters: none. */ onDisableSelectableRows: PropTypes.func, /** * Callback function to enable row selection mode. Parameters: `none` */ onEnableRowSelection: PropTypes.func, /** * Boolean indicating whether or not the Worklist Data Grid should allow entire rows to be selectable. An additional column will be * rendered to allow for row selection to occur. */ hasSelectableRows: PropTypes.bool, /** * 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', rowHeight: '2.5rem', pinnedColumns: [], overflowColumns: [], rows: [], }; function WorklistDataGrid(props) { const { id, ariaLabelledBy, ariaLabel, rows, pinnedColumns, overflowColumns, onColumnResize, defaultColumnWidth, columnHeaderHeight, rowHeight, onColumnSelect, onCellSelect, onRowSelect, onRowSelectAll, onClearSelectedRows, onClearSelectedCells, onDisableSelectableRows, onEnableRowSelection, hasSelectableRows, rowHeaderIndex, isAutoFocusEnabled, } = props; const inShiftUpDownMode = useRef(false); const multiSelectRange = useRef({ start: null, end: null }); const dataGridFuncRef = useRef(); const gridReceivedFocus = useRef(false); const gridHasFocus = document.getElementById(`${id}-worklist-data-grid-container`)?.contains(document.activeElement); const makeWorklistDataGridColumns = (columns) => columns.map(column => ({ ...column, isResizable: column.isResizable !== false, isSelectable: column.isSelectable !== false, })); const worklistDataGridPinnedColumns = useMemo(() => (makeWorklistDataGridColumns(pinnedColumns)), [pinnedColumns]); const worklistDataGridOverflowColumns = useMemo(() => (makeWorklistDataGridColumns(overflowColumns)), [overflowColumns]); // ------------------------------------- // useEffect Hooks // useEffect for row selection useEffect(() => { if (!hasSelectableRows) { multiSelectRange.current = {}; } if (gridReceivedFocus.current) { let newFocusCell = dataGridFuncRef.current.getFocusedCell(); newFocusCell = { row: newFocusCell.row, col: Math.max(newFocusCell.col + (hasSelectableRows ? 1 : -1), 0) }; dataGridFuncRef.current.setFocusedRowCol(newFocusCell.row, newFocusCell.col, gridHasFocus); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasSelectableRows]); const handleClearSelection = useCallback(() => { if (!hasSelectableRows) { if (onClearSelectedCells) { onClearSelectedCells(); } } else { multiSelectRange.current = {}; // Clear the information used for selecting multiple rows. if (rows.some(row => row.isSelected)) { // Esc (while in row selection mode and rows are selected): Clear selection if (onClearSelectedRows) { onClearSelectedRows(); } } else if (onDisableSelectableRows) { onDisableSelectableRows(); } } }, [hasSelectableRows, onClearSelectedCells, onClearSelectedRows, onDisableSelectableRows, rows]); const selectAllRows = () => { if (onRowSelectAll) { onRowSelectAll(); } }; const getRowsInRangeToUnselect = useCallback((newEndOfRange) => { let rowIdsToUnselect = []; if (!multiSelectRange.current.end) { // short-circuit since there was no previous range, there is nothing to unselect. return rowIdsToUnselect; } if (multiSelectRange.current.start > multiSelectRange.current.end) { // The range extends upward from the anchor row if (newEndOfRange > multiSelectRange.current.end) { // The range was moved down towards the anchor so rows that no longer qualify for the range need to be unselected. rowIdsToUnselect = rows.slice(multiSelectRange.current.end - 1, Math.min(newEndOfRange, multiSelectRange.current.start) - 1).map(row => ({ id: row.id, selected: false })); } } else if (multiSelectRange.current.start < multiSelectRange.current.end) { // The range extends downward from the anchor row if (newEndOfRange < multiSelectRange.current.end) { // The range was moved up towards the anchor so rows that no longer qualify for the range need to be unselected. New endRangeIndex becomes ordered End rowIdsToUnselect = rows.slice(Math.max(multiSelectRange.current.start, newEndOfRange), multiSelectRange.current.end).map(row => ({ id: row.id, selected: false })); } } return rowIdsToUnselect; }, [rows]); const selectMultipleRows = useCallback((newEndOfRange) => { if (!hasSelectableRows) { onEnableRowSelection(); } if (!onRowSelect) { return; } // Rows in range to remain selected const selectionStartRowIndex = Math.min(multiSelectRange.current.start, newEndOfRange); const selectionEndRowIndex = Math.max(multiSelectRange.current.start, newEndOfRange); // We are subtracting 1 to accommodate for the column header in the grid. let rowsToSelectAndUnSelect = rows.slice(selectionStartRowIndex - 1, selectionEndRowIndex).map(row => ({ id: row.id, selected: true })); // Determine if there are rows that are no longer in range that need to be unselected. rowsToSelectAndUnSelect = rowsToSelectAndUnSelect.concat(getRowsInRangeToUnselect(newEndOfRange)); onRowSelect(rowsToSelectAndUnSelect); multiSelectRange.current.end = newEndOfRange; }, [hasSelectableRows, onEnableRowSelection, onRowSelect, rows, getRowsInRangeToUnselect]); const selectRow = useCallback((rowId, rowIndex) => { const rowsToSelectAndUnSelect = []; if (!rows[rowIndex - 1].isSelected) { multiSelectRange.current = { start: rowIndex, end: null }; // Establish new starting point for future range. rowsToSelectAndUnSelect.push({ id: rowId, selected: true }); } else { if (rowIndex === multiSelectRange.current.start) { // The row that denotes the start for multiselect has been cleared. multiSelectRange.current = {}; } rowsToSelectAndUnSelect.push({ id: rowId, selected: false }); } if (onRowSelect) { onRowSelect(rowsToSelectAndUnSelect); } }, [onRowSelect, rows]); const onRangeSelection = useCallback((rowIndex, columnIndex, direction) => { let nextRow = rowIndex; if (direction === KeyCode.KEY_UP) { // Since range is being extended upward, row above the starting row should now // be added to the range of selected rows. nextRow -= 1; if (nextRow === 0) { // If the row above is the header, do not extend the range. nextRow = 1; } } else if (direction === KeyCode.KEY_DOWN) { // Since range is being extended downward, row below the starting row should now // be added to the range of selected rows. nextRow += 1; } if (!inShiftUpDownMode.current) { // Start of range selection using Shift+Up/Down so save this as the anchor/start for the range. inShiftUpDownMode.current = true; multiSelectRange.current = { start: rowIndex, end: null }; } selectMultipleRows(nextRow); }, [selectMultipleRows]); const handleRowSelection = useCallback((selectionDetails) => { if (!hasSelectableRows) { if (selectionDetails.isShiftPressed) { // Shift+Space, Shift+Click, Shift+Up, Shift+Down multiSelectRange.current = { start: selectionDetails.rowIndex, end: null }; selectMultipleRows(selectionDetails.rowIndex); } } else if (selectionDetails.isShiftPressed && multiSelectRange.current.start) { // Select multiple rows based on previously established anchor. selectMultipleRows(selectionDetails.rowIndex); } else { // There is no anchor or the shift key is not pressed so select the row and establish an anchor. selectRow(selectionDetails.rowId, selectionDetails.rowIndex); } }, [hasSelectableRows, selectMultipleRows, selectRow]); const handleCellSelection = useCallback((selectionDetails, event) => { if (hasSelectableRows || selectionDetails.isShiftPressed) { handleRowSelection(selectionDetails); } else if (selectionDetails.isCellSelectable && onCellSelect) { onCellSelect(selectionDetails.rowId, selectionDetails.columnId, event); } }, [handleRowSelection, hasSelectableRows, onCellSelect]); const handleRowSelectionHeaderSelect = useCallback(() => { onColumnSelect(WorklistDataGridUtils.ROW_SELECTION_COLUMN.id); }, [onColumnSelect]); const handleKeyUp = (event) => { const key = event.keyCode; switch (key) { case KeyCode.KEY_SHIFT: inShiftUpDownMode.current = false; break; default: } }; // ------------------------------------- // event handlers const handleKeyDown = (event) => { switch (event.keyCode) { case KeyCode.KEY_A: if (hasSelectableRows && (event.ctrlKey || event.metaKey)) { selectAllRows(); event.preventDefault(); // prevent the default selection of everything on the page. } break; default: } }; const onFocus = () => { gridReceivedFocus.current = true; }; // ------------------------------------- return ( <div id={`${id}-worklist-data-grid-container`} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} className={cx('worklist-data-grid-container')} onFocus={!gridReceivedFocus.current ? onFocus : undefined} > <DataGrid id={id} ariaLabel={ariaLabel} ariaLabelledBy={ariaLabelledBy} rows={rows} rowHeight={rowHeight} rowHeaderIndex={rowHeaderIndex} pinnedColumns={worklistDataGridPinnedColumns} overflowColumns={worklistDataGridOverflowColumns} defaultColumnWidth={defaultColumnWidth} columnHeaderHeight={columnHeaderHeight} onColumnSelect={onColumnSelect} onRowSelectionHeaderSelect={onColumnSelect ? handleRowSelectionHeaderSelect : undefined} onColumnResize={onColumnResize} onCellSelect={handleCellSelection} onClearSelection={handleClearSelection} onRangeSelection={onRangeSelection} hasSelectableRows={hasSelectableRows} isAutoFocusEnabled={isAutoFocusEnabled} ref={dataGridFuncRef} /> </div> ); } WorklistDataGrid.propTypes = propTypes; WorklistDataGrid.defaultProps = defaultProps; export default WorklistDataGrid;