Skip to content
This repository has been archived by the owner on May 24, 2024. It is now read-only.

[terra-data-grid] Flowsheet keyboard navigation #1927

Merged
merged 10 commits into from
Dec 7, 2023
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/terra-data-grid/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

* Added
* Added keyboard navigation support for sections.

* Changed
* Removed console warning message when no pinned columns exist.

Expand Down
166 changes: 85 additions & 81 deletions packages/terra-data-grid/src/DataGrid.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,6 @@ const DataGrid = forwardRef((props, ref) => {
// if columns are not visible then set the first selectable row index to 1
const [focusedRow, setFocusedRow] = useState(hasVisibleColumnHeaders ? 0 : 1);
const [focusedCol, setFocusedCol] = useState(0);
const [gridHasFocus, setGridHasFocus] = useState(false);

// Aria live region message management
const [cellAriaLiveMessage, setCellAriaLiveMessage] = useState(null);
Expand All @@ -221,6 +220,10 @@ const DataGrid = forwardRef((props, ref) => {
hasSelectableRows && columnIndex < displayedColumns.length && displayedColumns[columnIndex].id === WorklistDataGridUtils.ROW_SELECTION_COLUMN.id
), [displayedColumns, hasSelectableRows]);

const isSection = useCallback((rowIndex) => (
grid.current.rows[rowIndex].hasAttribute('data-section-id')
), []);

const setFocusedRowCol = useCallback((newRowIndex, newColIndex, makeActiveElement) => {
setCellAriaLiveMessage(null);
setFocusedRow(newRowIndex);
Expand All @@ -232,20 +235,29 @@ const DataGrid = forwardRef((props, ref) => {
};

if (makeActiveElement) {
// Set focus on input field (checkbox) of row selection cells.
let focusedCell = grid.current.rows[newRowIndex].cells[newColIndex];
if (isRowSelectionCell(newColIndex) && focusedCell.getElementsByTagName('input').length > 0) {
[focusedCell] = focusedCell.getElementsByTagName('input');
}
let focusedCell;
if (isSection(newRowIndex)) {
[focusedCell] = grid.current.rows[newRowIndex].cells;

if (!focusedCell.hasAttribute('tabindex')) {
focusedCell = grid.current.rows[newRowIndex].querySelector('button');
}
} else {
// Set focus on input field (checkbox) of row selection cells.
focusedCell = grid.current.rows[newRowIndex].cells[newColIndex];
if (isRowSelectionCell(newColIndex) && focusedCell.getElementsByTagName('input').length > 0) {
[focusedCell] = focusedCell.getElementsByTagName('input');
}

// Set focus to column header button, if it exists
if (newRowIndex === 0 && !focusedCell.hasAttribute('tabindex')) {
focusedCell = focusedCell.querySelector('[role="button"]');
// Set focus to column header button, if it exists
if (newRowIndex === 0 && !focusedCell.hasAttribute('tabindex')) {
focusedCell = focusedCell.querySelector('[role="button"]');
}
}

focusedCell?.focus();
}
}, [isRowSelectionCell, displayedColumns]);
}, [displayedColumns, isSection, isRowSelectionCell]);

// The focus is handled by the DataGrid. However, there are times
// when the other components may want to change the currently focus
Expand All @@ -264,66 +276,44 @@ const DataGrid = forwardRef((props, ref) => {
// -------------------------------------

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].cells[toCell.col].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 {
const gridContainerRect = tableContainerRef.current.getBoundingClientRect();
const columnHeaderRect = grid.current.rows[0].cells[toCell.col].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;
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;
}
} else {
scrollOffsetX = nextCellRect.left - gridContainerRect.left;
}

if (scrollOffsetX < 0) {
tableContainerRef.current.scrollBy(scrollOffsetX, 0);
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);
// Calculate vertical scroll offset
const scrollOffsetY = nextCellRect.top - columnHeaderRect.bottom;
if (scrollOffsetY < 0) {
tableContainerRef.current.scrollBy(0, scrollOffsetY);
}
}

setFocusedRowCol(toCell.row, toCell.col, true);
};

const handleColumnSelect = useCallback((columnId) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Will we have to account for scenarios where a cell/column header can be selected without having been reached from a mousedown or keyboard navigation? I would not think so but removing these could affect that scenario if it is a concern. I know that JAWS has a quick navigation ability so I was wondering about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event only triggered for the mouse down and spacebar being pressed. I think this new implementation is equivalent.

const columnIndex = displayedColumns.findIndex(column => column.id === columnId);
setFocusedRowCol(0, columnIndex);

if (onColumnSelect) {
onColumnSelect(columnId);
}
}, [onColumnSelect, displayedColumns, setFocusedRowCol]);

const handleRowSelectionHeaderSelect = useCallback(() => {
setFocusedRowCol(0, 0);
if (onRowSelectionHeaderSelect) {
onRowSelectionHeaderSelect();
}
}, [onRowSelectionHeaderSelect, setFocusedRowCol]);

const handleCellSelection = useCallback((selectionDetails) => {
const { columnIndex, rowIndex } = selectionDetails;
setFocusedRowCol(rowIndex, columnIndex);
if (onCellSelect) {
onCellSelect(selectionDetails);
}
}, [onCellSelect, setFocusedRowCol]);

// -------------------------------------
// event handlers

Expand Down Expand Up @@ -388,17 +378,26 @@ const DataGrid = forwardRef((props, ref) => {
setCheckResizable(false);

const targetElement = event.target;
const key = event.keyCode;

// Allow default behavior if the event target is an editable field

if (event.keyCode !== KeyCode.KEY_TAB
if (key !== KeyCode.KEY_TAB
&& (isTextInput(targetElement)
|| ['textarea', 'select'].indexOf(targetElement.tagName.toLowerCase()) >= 0
|| (targetElement.hasAttribute('contentEditable') && targetElement.getAttribute('contentEditable') !== false))) {
return;
}

const key = event.keyCode;
// 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;
Expand Down Expand Up @@ -432,7 +431,7 @@ const DataGrid = forwardRef((props, ref) => {
if (event.ctrlKey) {
// Mac: Ctrl + Cmd + Right
// Windows: Ctrl + End
nextRow = rows.length;
nextRow = gridRowCount - 1;
}
} else {
// Right key
Expand All @@ -450,7 +449,7 @@ const DataGrid = forwardRef((props, ref) => {
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 = rows.length;
nextRow = gridRowCount - 1;
}
break;
case KeyCode.KEY_ESCAPE:
Expand All @@ -476,7 +475,7 @@ const DataGrid = forwardRef((props, ref) => {
onCellRangeSelect(cellCoordinates.row, cellCoordinates.col, event.keyCode);
}

if (nextRow > rows.length || nextCol >= displayedColumns.length) {
if (nextRow >= gridRowCount || nextCol >= displayedColumns.length) {
event.preventDefault(); // prevent the page from moving with the arrow keys.
return;
}
Expand All @@ -489,9 +488,20 @@ const DataGrid = forwardRef((props, ref) => {
event.preventDefault(); // prevent the page from moving with the arrow keys.
};

const onMouseDown = () => {
// Prevent focus event updates when triggered by mouse
handleFocus.current = false;
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);
}
};

/**
Expand Down Expand Up @@ -522,31 +532,25 @@ const DataGrid = forwardRef((props, ref) => {
}

setFocusedRowCol(newRowIndex, newColumnIndex, true);
setGridHasFocus(true);
}
}

handleFocus.current = true;
};

const onBlur = (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setGridHasFocus(false);
}
};

// -------------------------------------

const isGridActive = grid.current?.contains(document.activeElement);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💪 Nice use of JS to track the grid active state!


return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={gridContainerRef}
onKeyDown={handleKeyDown}
onMouseDown={onMouseDown}
onMouseDown={handleMouseDown}
onFocus={onFocus}
onBlur={onBlur}
id={id}
className={cx('data-grid-container')}
>
Expand All @@ -557,7 +561,7 @@ const DataGrid = forwardRef((props, ref) => {
sections={sections}
ariaLabelledBy={ariaLabelledBy}
ariaLabel={ariaLabel}
activeColumnIndex={(gridHasFocus && focusedRow === 0) ? focusedCol : undefined}
activeColumnIndex={(isGridActive && focusedRow === 0) ? focusedCol : undefined}
isActiveColumnResizing={focusedRow === 0 && checkResizable}
columnResizeIncrement={columnResizeIncrement}
pinnedColumns={pinnedColumns}
Expand All @@ -567,10 +571,10 @@ const DataGrid = forwardRef((props, ref) => {
rowHeight={rowHeight}
rowHeaderIndex={rowHeaderIndex}
onColumnResize={onColumnResize}
onColumnSelect={handleColumnSelect}
onColumnSelect={onColumnSelect}
onSectionSelect={onSectionSelect}
onCellSelect={handleCellSelection}
onRowSelectionHeaderSelect={handleRowSelectionHeaderSelect}
onRowSelectionHeaderSelect={onRowSelectionHeaderSelect}
onCellSelect={onCellSelect}
rowSelectionMode={hasSelectableRows ? 'multiple' : undefined}
hasVisibleColumnHeaders={hasVisibleColumnHeaders}
isStriped
Expand Down
6 changes: 1 addition & 5 deletions packages/terra-data-grid/src/WorklistDataGrid.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,6 @@ function WorklistDataGrid(props) {
}
}, [handleRowSelection, hasSelectableRows, onCellSelect]);

const handleColumnSelect = useCallback((columnId) => {
onColumnSelect(columnId);
}, [onColumnSelect]);

const handleRowSelectionHeaderSelect = useCallback(() => {
onColumnSelect(WorklistDataGridUtils.ROW_SELECTION_COLUMN.id);
}, [onColumnSelect]);
Expand Down Expand Up @@ -384,7 +380,7 @@ function WorklistDataGrid(props) {
overflowColumns={worklistDataGridOverflowColumns}
defaultColumnWidth={defaultColumnWidth}
columnHeaderHeight={columnHeaderHeight}
onColumnSelect={onColumnSelect ? handleColumnSelect : undefined}
onColumnSelect={onColumnSelect}
onRowSelectionHeaderSelect={onColumnSelect ? handleRowSelectionHeaderSelect : undefined}
onColumnResize={onColumnResize}
onCellSelect={handleCellSelection}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,10 @@ describe('Multi-cell selection', () => {
/>,
);

const initialCell = wrapper.find('Row').at(2).find('td.selectable').at(1);
initialCell.simulate('mouseDown');
initialCell.simulate('keydown', { keyCode: DOWN_ARROW_KEY });

const selectableCell = wrapper.find('Row').at(3).find('td.selectable').at(1);
selectableCell.simulate('keydown', { keyCode: SPACE_KEY });
expect(mockOnCellSelect).toHaveBeenCalledWith('4', 'Column-2');
Expand Down
Loading
Loading