terra-data-grid
Version:
Package containing data grid container components that enable users to navigate the grid information using directional navigation keys.
501 lines (434 loc) • 18.4 kB
JSX
import React, {
useState, useCallback, useEffect, useMemo, useRef,
} from 'react';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import classNames from 'classnames/bind';
import * as KeyCode from 'keycode-js';
import VisuallyHiddenText from 'terra-visually-hidden-text';
import { sectionShape, rowShape, columnShape } from 'terra-table';
import DataGrid, { ColumnHighlightColor } from './DataGrid';
import styles from './FlowsheetDataGrid.module.scss';
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. The first cell in each row will be the row header.
*/
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 columns. Columns will be presented in the order given.
* The first column, pinned to the leftmost side of the grid, will contain row headers.
* The remaining columns will be rendered in the horizontal overflow.
*/
columns: 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,
/**
* 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,
/**
* Callback function that is called when a selectable cell is selected. Parameters:
* @param {object} selectedCell object containing rowId, columnId, columnSpanIndex and sectionId.
* @param {object} event JavaScript event object.
*/
onCellSelect: PropTypes.func,
/**
* Function that is called when a collapsible section is selected. Parameters:
* @param {string} sectionId sectionId
*/
onSectionSelect: 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 a range of selectable cells is selected. Parameters:
* @param {array} cells - Array of cells each containing a rowId, columnId, and sectionId, all as strings.
*/
onCellRangeSelect: PropTypes.func,
/**
* Callback function that is called when a row header cell is selected. Parameters:
* @param {object} rowToSelect object containing rowId and sectionId, both as strings.
* @param {boolean} isMetaPressed isMetaPressed
*/
onRowSelect: PropTypes.func,
/**
* @private
* The intl object containing translations. This is retrieved from the context automatically by injectIntl.
*/
intl: PropTypes.shape({ formatMessage: PropTypes.func }).isRequired,
/**
* Boolean to show/hide column headers. By default, it is set to `true` and column headers are visible.
*/
hasVisibleColumnHeaders: 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 = {
defaultColumnWidth: 200,
columnHeaderHeight: '2.5rem',
rows: [],
columns: [],
hasVisibleColumnHeaders: true,
};
function FlowsheetDataGrid(props) {
const {
id,
ariaLabelledBy,
ariaLabel,
rows,
sections,
columns,
defaultColumnWidth,
columnHeaderHeight,
rowHeight,
onCellSelect,
onSectionSelect,
onClearSelectedCells,
onCellRangeSelect,
onRowSelect,
intl,
hasVisibleColumnHeaders,
rowMinimumHeight,
isAutoFocusEnabled,
} = props;
const anchorCell = useRef(null);
const selectedCells = useRef([]);
const [cellSelectionAriaLiveMessage, setCellSelectionAriaLiveMessage] = useState(null);
const inShiftDirectionalMode = useRef(false);
const dataGridFuncRef = useRef();
const flowsheetColumns = useMemo(() => columns.map(column => ({
...column,
isSelectable: false,
isResizable: false,
})), [columns]);
const pinnedColumns = flowsheetColumns.length ? [flowsheetColumns[0]] : [];
const overflowColumns = flowsheetColumns.length > 1 ? flowsheetColumns.slice(1) : [];
const contentHasNoResult = (content) => (content === null || content === '' || content === '--');
const flowsheetRows = useMemo(() => {
const noResultCellContent = (
<>
<span aria-hidden>{intl.formatMessage({ id: 'Terra.flowsheetDataGrid.no-result-display' })}</span>
<VisuallyHiddenText text={intl.formatMessage({ id: 'Terra.flowsheetDataGrid.no-result' })} />
</>
);
const newRows = [...rows];
newRows.forEach((row, rowIndex) => {
const newCells = [...row.cells];
newCells.forEach((cell, cellIndex) => {
// All flowsheet cells are selectable.
newCells[cellIndex].isSelectable = true;
// Cell content has no result and is not a row header (first column), set content to "No result".
if (contentHasNoResult(cell.content) && cellIndex !== 0) {
newCells[cellIndex].content = noResultCellContent;
}
});
newRows[rowIndex].cells = newCells;
});
return newRows;
}, [intl, rows]);
const flowsheetSections = useMemo(() => {
if (!sections) {
return null;
}
const noResultCellContent = (
<>
<span aria-hidden>{intl.formatMessage({ id: 'Terra.flowsheetDataGrid.no-result-display' })}</span>
<VisuallyHiddenText text={intl.formatMessage({ id: 'Terra.flowsheetDataGrid.no-result' })} />
</>
);
const newSections = [...sections];
newSections.forEach((section, sectionIndex) => {
if (section.rows) {
const newRows = [...section.rows];
newRows.forEach((row, rowIndex) => {
const newCells = [...row.cells];
newCells.forEach((cell, cellIndex) => {
newCells[cellIndex].isSelectable = cell.isSelectable !== false;
// Cell content has no result and is not a row header (first column), set content to "No result".
if (contentHasNoResult(cell.content) && cellIndex !== 0) {
newCells[cellIndex].content = noResultCellContent;
}
});
newRows[rowIndex].cells = newCells;
});
newSections[sectionIndex].rows = newRows;
}
if (section.subsections) {
const newSubsections = [...section.subsections];
newSubsections.forEach((subsection, subsectionIndex) => {
const newSubsectionRows = [...subsection.rows];
newSubsectionRows.forEach((subsectionRow, subsectionRowIndex) => {
const newSubsectionCells = [...subsectionRow.cells];
newSubsectionCells.forEach((subsectionCell, subsectionCellIndex) => {
newSubsectionCells[subsectionCellIndex].isSelectable = subsectionCell.isSelectable !== false;
// Cell content has no result and is not a row header (first column), set content to "No result".
if (contentHasNoResult(subsectionCell.content) && subsectionCellIndex !== 0) {
newSubsectionCells[subsectionCellIndex].content = noResultCellContent;
}
});
newSubsectionRows[subsectionRowIndex].cells = newSubsectionCells;
});
newSubsections[subsectionIndex].rows = newSubsectionRows;
});
}
});
return newSections;
}, [intl, sections]);
const rowsToSearch = useMemo(() => {
if (flowsheetSections) {
return flowsheetSections.flatMap((section) => {
if (section.rows) {
return section.rows.map(row => ({
...row,
sectionId: section.id,
}));
}
return section.subsections.flatMap(subsection => subsection.rows.map(row => ({
...row,
sectionId: section.id,
subsectionId: subsection.id,
})));
});
}
return flowsheetRows;
}, [flowsheetSections, flowsheetRows]);
useEffect(() => {
const previousSelectedCells = [...selectedCells.current];
const newSelectedCells = [];
const tableBodyColumnsRef = dataGridFuncRef.current.getTableBodyColumnsRef();
rowsToSearch.forEach((row) => {
row.cells.forEach((cell, cellIndex) => {
if (cell.isSelected) {
newSelectedCells.push({ rowId: row.id, columnId: tableBodyColumnsRef[cellIndex].id, columnSpanIndex: tableBodyColumnsRef[cellIndex].columnSpanIndex });
}
});
});
selectedCells.current = [...newSelectedCells];
if (!selectedCells.current.length) {
anchorCell.current = null;
}
if (previousSelectedCells.length > 0 && selectedCells.current.length === 0) {
setCellSelectionAriaLiveMessage(intl.formatMessage({ id: 'Terra.flowsheetDataGrid.no-cells-selected' }));
} else if (selectedCells.current.length) {
setCellSelectionAriaLiveMessage(intl.formatMessage({ id: 'Terra.flowsheetDataGrid.cells-selected' }, { cellCount: selectedCells.current.length }));
}
}, [intl, rowsToSearch, setCellSelectionAriaLiveMessage]);
const selectCellRange = useCallback((rowId, columnId, columnIndex, sectionId, subsectionId) => {
if (anchorCell.current === null) {
return;
}
const anchorRowIndex = rowsToSearch.findIndex(row => row.id === anchorCell.current.rowId);
const anchorColumnIndex = anchorCell.current.columnIndex;
const rowIndex = rowsToSearch.findIndex(row => row.id === rowId);
const tableBodyColumnsRef = dataGridFuncRef.current.getTableBodyColumnsRef();
// Determine the boundaries of selected region.
let rowIndexTopBound = Math.min(anchorRowIndex, rowIndex);
let rowIndexBottomBound = Math.max(anchorRowIndex, rowIndex);
const columnIndexLeftBound = Math.min(anchorColumnIndex, columnIndex);
const columnIndexRightBound = Math.max(anchorColumnIndex, columnIndex);
if (flowsheetSections) {
const selectedSection = flowsheetSections.find(section => section.id === sectionId);
let sectionTopBound = 0;
let sectionBottomBound = 0;
if (subsectionId) {
const selectedSubsection = selectedSection.subsections.find(subsection => subsection.id === subsectionId);
sectionTopBound = rowsToSearch.findIndex(row => row.subsectionId === subsectionId);
sectionBottomBound = sectionTopBound + selectedSubsection.rows.length - 1;
} else {
sectionTopBound = rowsToSearch.findIndex(row => row.sectionId === sectionId);
sectionBottomBound = sectionTopBound + selectedSection.rows.length - 1;
}
rowIndexTopBound = anchorRowIndex <= sectionTopBound ? sectionTopBound : rowIndexTopBound;
rowIndexBottomBound = anchorRowIndex >= sectionBottomBound ? sectionBottomBound : rowIndexBottomBound;
}
const cellsToSelect = [];
for (let rowIdx = rowIndexTopBound; rowIdx <= rowIndexBottomBound; rowIdx += 1) {
const rowIdToSelect = rowsToSearch[rowIdx].id;
for (let colIdx = columnIndexLeftBound; colIdx <= columnIndexRightBound; colIdx += 1) {
const { columnSpanIndex } = tableBodyColumnsRef[colIdx];
const columnIdToSelect = tableBodyColumnsRef[colIdx].id;
cellsToSelect.push({
rowId: rowIdToSelect, columnId: columnIdToSelect, columnIndex: colIdx, sectionId, columnSpanIndex,
});
}
}
if (onCellRangeSelect) {
onCellRangeSelect(cellsToSelect);
}
}, [rowsToSearch, flowsheetSections, onCellRangeSelect]);
const handleCellSelection = useCallback((selectionDetails, event) => {
// Call onRowSelect for row header column
if (selectionDetails.columnIndex === 0) {
if (onRowSelect) {
onRowSelect({ rowId: selectionDetails.rowId, sectionId: selectionDetails.sectionId, isMetaPressed: selectionDetails.isMetaPressed });
}
} else if (selectionDetails.isShiftPressed && anchorCell.current !== null) {
selectCellRange(selectionDetails.rowId, selectionDetails.columnId, selectionDetails.columnIndex, selectionDetails.sectionId, selectionDetails.subsectionId, selectionDetails.columnSpanIndex);
} else if (onCellSelect) {
anchorCell.current = {
rowId: selectionDetails.rowId, columnId: selectionDetails.columnId, columnIndex: selectionDetails.columnIndex, sectionId: selectionDetails.sectionId,
};
onCellSelect({
rowId: selectionDetails.rowId,
columnId: selectionDetails.columnId,
columnIndex: selectionDetails.columnIndex,
columnSpanIndex: selectionDetails.columnSpanIndex,
sectionId: selectionDetails.sectionId,
subsectionId: selectionDetails.subsectionId,
isMetaPressed: selectionDetails.isMetaPressed,
}, event);
}
}, [onCellSelect, onRowSelect, selectCellRange]);
const handleCellRangeSelection = useCallback((rowIndex, columnIndex, direction) => {
// Exclude the row header column as an eligible anchor/start cell.
if (columnIndex <= 0 && !inShiftDirectionalMode.current) {
return;
}
const gridRef = dataGridFuncRef.current.getGridRef();
const tableBodyColumnsRef = dataGridFuncRef.current.getTableBodyColumnsRef();
let anchorSectionId = '';
if (flowsheetSections) {
for (let rowIdx = rowIndex; rowIdx > 0; rowIdx -= 1) {
const currentSectionId = gridRef.rows[rowIdx].getAttribute('data-section-id');
if (currentSectionId) {
anchorSectionId = currentSectionId;
break;
}
}
}
if (!inShiftDirectionalMode.current) {
// Start of range selection using Shift+Up/Down/Left/Right so save this as the anchor/start for the range if one does not exist.
inShiftDirectionalMode.current = true;
if (anchorCell.current === null && !gridRef.rows[rowIndex].hasAttribute('data-section-id')) {
anchorCell.current = {
rowId: gridRef.rows[rowIndex].getAttribute('data-row-id'),
columnId: tableBodyColumnsRef[columnIndex].id,
columnIndex,
sectionId: anchorSectionId,
};
}
}
let nextRowIndex = rowIndex;
let nextColumnIndex = columnIndex;
switch (direction) {
case KeyCode.KEY_UP:
nextRowIndex -= 1;
break;
case KeyCode.KEY_DOWN:
nextRowIndex += 1;
break;
case KeyCode.KEY_LEFT:
nextColumnIndex -= 1;
break;
case KeyCode.KEY_RIGHT:
nextColumnIndex += 1;
break;
default:
break;
}
// Reset to valid selectable index if outer bounds reached.
if (nextRowIndex <= 0) {
nextRowIndex = 1;
} else if (nextRowIndex >= gridRef.rows.length) {
nextRowIndex = gridRef.rows.length - 1;
}
if (nextColumnIndex <= 0) {
nextColumnIndex = 1;
} else if (nextColumnIndex >= tableBodyColumnsRef.length) {
nextColumnIndex = tableBodyColumnsRef.length - 1;
}
const nextColumnId = tableBodyColumnsRef[nextColumnIndex].id;
const nextRowId = gridRef.rows[nextRowIndex].getAttribute('data-row-id');
if (!gridRef.rows[nextRowIndex].hasAttribute('data-section-id') && !gridRef.rows[nextRowIndex].hasAttribute('data-subsection-id')) {
let nextSectionId = '';
let nextSubsectionId = '';
for (let rowIdx = nextRowIndex; rowIdx > 0; rowIdx -= 1) {
if (gridRef.rows[rowIdx].hasAttribute('data-section-id')) {
nextSectionId = gridRef.rows[rowIdx].getAttribute('data-section-id');
break;
}
if (gridRef.rows[rowIdx].hasAttribute('data-subsection-id')) {
nextSubsectionId = gridRef.rows[rowIdx].getAttribute('data-subsection-id');
nextSectionId = gridRef.rows[rowIdx].getAttribute('data-subsection-section-id');
break;
}
}
selectCellRange(nextRowId, nextColumnId, nextColumnIndex, nextSectionId, nextSubsectionId);
}
}, [flowsheetSections, selectCellRange]);
const handleKeyUp = (event) => {
const key = event.keyCode;
switch (key) {
case KeyCode.KEY_SHIFT:
if (inShiftDirectionalMode.current) {
inShiftDirectionalMode.current = false;
anchorCell.current = null;
}
break;
default:
}
};
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div className={cx('flowsheet-data-grid-container')} onKeyUp={handleKeyUp}>
<DataGrid
id={id}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
rows={flowsheetRows}
sections={flowsheetSections}
rowHeight={rowHeight}
rowHeaderIndex={0}
pinnedColumns={pinnedColumns}
overflowColumns={overflowColumns}
defaultColumnWidth={defaultColumnWidth}
columnHeaderHeight={columnHeaderHeight}
onCellSelect={handleCellSelection}
onSectionSelect={onSectionSelect}
onClearSelection={onClearSelectedCells}
onCellRangeSelect={handleCellRangeSelection}
hasVisibleColumnHeaders={hasVisibleColumnHeaders}
ref={dataGridFuncRef}
rowMinimumHeight={rowMinimumHeight}
isAutoFocusEnabled={isAutoFocusEnabled}
/>
<VisuallyHiddenText aria-live="polite" text={cellSelectionAriaLiveMessage} />
</div>
);
}
FlowsheetDataGrid.propTypes = propTypes;
FlowsheetDataGrid.defaultProps = defaultProps;
export default injectIntl(FlowsheetDataGrid);
export { ColumnHighlightColor };