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