Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DataGrid] Rework keyboard navigation #3193

Merged
merged 3 commits into from
Nov 19, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,73 +1,26 @@
import * as React from 'react';
import { GridEvents } from '../../../constants/eventsConstants';
import { GridApiRef } from '../../../models/api/gridApiRef';
import {
GridCellIndexCoordinates,
GridColumnHeaderIndexCoordinates,
} from '../../../models/gridCell';
import { GridCellParams } from '../../../models/params/gridCellParams';
import { GridColumnHeaderParams } from '../../../models/params/gridColumnHeaderParams';
import {
isArrowKeys,
isEnterKey,
isHomeOrEndKeys,
isPageKeys,
isSpaceKey,
isTabKey,
} from '../../../utils/keyboardUtils';
import { visibleGridColumnsLengthSelector } from '../columns/gridColumnsSelector';
import { useGridSelector } from '../../utils/useGridSelector';
import { useGridLogger } from '../../utils/useGridLogger';
import { useGridApiEventHandler } from '../../utils/useGridApiEventHandler';
import { GridComponentProps } from '../../../GridComponentProps';
import { gridVisibleSortedRowEntriesSelector } from '../filter/gridFilterSelector';
import { useCurrentPageRows } from '../../utils/useCurrentPageRows';
import { clamp } from '../../../utils/utils';

const getNextCellIndexes = (key: string, indexes: GridCellIndexCoordinates) => {
if (!isArrowKeys(key)) {
throw new Error('MUI: The first argument (key) should be an arrow key code.');
}

switch (key) {
case 'ArrowLeft':
return { ...indexes, colIndex: indexes.colIndex - 1 };
case 'ArrowRight':
return { ...indexes, colIndex: indexes.colIndex + 1 };
case 'ArrowUp':
return { ...indexes, rowIndex: indexes.rowIndex - 1 };
default:
// Last option key === 'ArrowDown'
return { ...indexes, rowIndex: indexes.rowIndex + 1 };
}
};

const getNextColumnHeaderIndexes = (key: string, indexes: GridColumnHeaderIndexCoordinates) => {
if (!isArrowKeys(key)) {
throw new Error('MUI: The first argument (key) should be an arrow key code.');
}

switch (key) {
case 'ArrowLeft':
return { colIndex: indexes.colIndex - 1 };
case 'ArrowRight':
return { colIndex: indexes.colIndex + 1 };
case 'ArrowDown':
return null;
default:
// Last option key === 'ArrowUp'
return { ...indexes };
}
};

/**
* @requires useGridPage (state)
* @requires useGridPageSize (state)
* @requires useGridFilter (state)
* @requires useGridColumns (state, method)
* @requires useGridRows (state, method)
* @requires useGridSorting (method) - can be after
* @requires useGridDimensions (method) - can be after
* @requires useGridFocus (method)
* @requires useGridScroll (method)
* @requires useGridFocus (method) - can be after
* @requires useGridScroll (method) - can be after
*/
export const useGridKeyboardNavigation = (
apiRef: GridApiRef,
Expand All @@ -78,137 +31,196 @@ export const useGridKeyboardNavigation = (
const visibleSortedRows = useGridSelector(apiRef, gridVisibleSortedRowEntriesSelector);
const currentPage = useCurrentPageRows(apiRef, props);

const mapKey = (event: React.KeyboardEvent) => {
if (isEnterKey(event.key)) {
return 'ArrowDown';
}
if (isTabKey(event.key)) {
return event.shiftKey ? 'ArrowLeft' : 'ArrowRight';
}
return event.key;
};

const navigateCells = React.useCallback(
const goToCell = React.useCallback(
(colIndex: number, rowIndex: number) => {
logger.debug(`Navigating to cell row ${rowIndex}, col ${colIndex}`);
apiRef.current.scrollToIndexes({ colIndex, rowIndex });
const field = apiRef.current.getVisibleColumns()[colIndex].field;
const node = visibleSortedRows[rowIndex];
apiRef.current.setCellFocus(node.id, field);
},
[apiRef, logger, visibleSortedRows],
);

const goToHeader = React.useCallback(
(colIndex: number, event: React.SyntheticEvent<Element>) => {
logger.debug(`Navigating to header col ${colIndex}`);
apiRef.current.scrollToIndexes({ colIndex });
const field = apiRef.current.getVisibleColumns()[colIndex].field;
apiRef.current.setColumnHeaderFocus(field, event);
},
[apiRef, logger],
);

const handleCellNavigationKeyDown = React.useCallback(
(params: GridCellParams, event: React.KeyboardEvent) => {
event.preventDefault();
const dimensions = apiRef.current.getRootDimensions();

if (!currentPage.range || !dimensions) {
return;
}

const colIndex = apiRef.current.getColumnIndex(params.field);
const rowIndex = visibleSortedRows.findIndex((row) => row.id === params.id);

const key = mapKey(event);
const isCtrlPressed = event.ctrlKey || event.metaKey || event.shiftKey;

let nextCellIndexes: GridCellIndexCoordinates;
if (isArrowKeys(key)) {
nextCellIndexes = getNextCellIndexes(key, {
colIndex,
rowIndex,
});
} else if (isHomeOrEndKeys(key)) {
const colIdx = key === 'Home' ? 0 : colCount - 1;

if (!isCtrlPressed) {
// we go to the current row, first col, or last col!
nextCellIndexes = { colIndex: colIdx, rowIndex };
} else {
// In that case we go to first row, first col, or last row last col!
let newRowIndex = 0;
if (colIdx === 0) {
newRowIndex = currentPage.range.firstRowIndex;
const viewportPageSize = apiRef.current.unstable_getViewportPageSize();
const colIndexBefore = params.field ? apiRef.current.getColumnIndex(params.field) : 0;
Copy link
Member

Choose a reason for hiding this comment

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

Why the ternary operator?

Copy link
Member Author

@flaviendelangle flaviendelangle Nov 18, 2021

Choose a reason for hiding this comment

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

Because there is one case where this event is triggered after comiting a row edit and we don't have a field.
It was working because getColumnIndex returned -1 and the clamp behavior set it back to 0, but it's not great calling it that way.
I was one of the inconsistencies I found while typing the event system.

Copy link
Member

Choose a reason for hiding this comment

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

const rowIndexBefore = visibleSortedRows.findIndex((row) => row.id === params.id);
const firstRowIndexInPage = currentPage.range.firstRowIndex;
const lastRowIndexInPage = currentPage.range.lastRowIndex;
const firstColIndex = 0;
const lastColIndex = colCount - 1;

// eslint-disable-next-line default-case
switch (event.key) {
case 'ArrowDown':
case 'Enter': {
// "Enter" is only triggered by the row / cell editing feature
if (rowIndexBefore < lastRowIndexInPage) {
goToCell(colIndexBefore, rowIndexBefore + 1);
}
break;
}

case 'ArrowUp': {
if (rowIndexBefore > firstRowIndexInPage) {
goToCell(colIndexBefore, rowIndexBefore - 1);
} else {
newRowIndex = currentPage.range.lastRowIndex;
goToHeader(colIndexBefore, event);
}
nextCellIndexes = { colIndex: colIdx, rowIndex: newRowIndex };
break;
}
} else if (isPageKeys(key) || isSpaceKey(key)) {
const viewportPageSize = apiRef.current.unstable_getViewportPageSize();
const nextRowIndex =
rowIndex +
(key.indexOf('Down') > -1 || isSpaceKey(key) ? viewportPageSize : -1 * viewportPageSize);
nextCellIndexes = { colIndex, rowIndex: nextRowIndex };
} else {
throw new Error('MUI: Key not mapped to navigation behavior.');
}

if (nextCellIndexes.rowIndex < currentPage.range.firstRowIndex) {
const field = apiRef.current.getVisibleColumns()[nextCellIndexes.colIndex].field;
apiRef.current.setColumnHeaderFocus(field, event);
return;
}
case 'ArrowRight': {
if (colIndexBefore < lastColIndex) {
goToCell(colIndexBefore + 1, rowIndexBefore);
}
break;
}

nextCellIndexes.rowIndex = clamp(nextCellIndexes.rowIndex, 0, currentPage.range.lastRowIndex);
nextCellIndexes.colIndex = clamp(nextCellIndexes.colIndex, 0, colCount - 1);
logger.debug(
`Navigating to next cell row ${nextCellIndexes.rowIndex}, col ${nextCellIndexes.colIndex}`,
);
apiRef.current.scrollToIndexes(nextCellIndexes);
const field = apiRef.current.getVisibleColumns()[nextCellIndexes.colIndex].field;
const node = visibleSortedRows[nextCellIndexes.rowIndex];
apiRef.current.setCellFocus(node.id, field);
case 'ArrowLeft': {
if (colIndexBefore > firstColIndex) {
goToCell(colIndexBefore - 1, rowIndexBefore);
}
break;
}

case 'Tab': {
// "Tab" is only triggered by the row / cell editing feature
if (event.shiftKey && colIndexBefore > firstColIndex) {
goToCell(colIndexBefore - 1, rowIndexBefore);
} else if (!event.shiftKey && colIndexBefore < lastColIndex) {
goToCell(colIndexBefore + 1, rowIndexBefore);
}
break;
}

case 'PageDown':
case ' ': {
if (rowIndexBefore < lastRowIndexInPage) {
goToCell(
colIndexBefore,
Math.min(rowIndexBefore + viewportPageSize, lastRowIndexInPage),
);
}
break;
}

case 'PageUp': {
if (rowIndexBefore - viewportPageSize >= firstRowIndexInPage) {
goToCell(colIndexBefore, rowIndexBefore - viewportPageSize);
} else {
goToHeader(colIndexBefore, event);
}
break;
}

case 'Home': {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
goToCell(firstColIndex, firstRowIndexInPage);
} else {
goToCell(firstColIndex, rowIndexBefore);
}
break;
}

case 'End': {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
goToCell(lastColIndex, lastRowIndexInPage);
} else {
goToCell(lastColIndex, rowIndexBefore);
}
break;
}
}
},
[apiRef, visibleSortedRows, colCount, logger, currentPage],
[apiRef, visibleSortedRows, colCount, currentPage, goToCell, goToHeader],
);

const navigateColumnHeaders = React.useCallback(
const handleColumnHeaderNavigationKeyDown = React.useCallback(
(params: GridColumnHeaderParams, event: React.KeyboardEvent) => {
event.preventDefault();

let nextColumnHeaderIndexes: GridColumnHeaderIndexCoordinates | null;
const colIndex = apiRef.current.getColumnIndex(params.field);
const key = mapKey(event);
const dimensions = apiRef.current.getRootDimensions();
if (!dimensions) {
return;
}

if (isArrowKeys(key)) {
nextColumnHeaderIndexes = getNextColumnHeaderIndexes(key, {
colIndex,
});
} else if (isHomeOrEndKeys(key)) {
const colIdx = key === 'Home' ? 0 : colCount - 1;

nextColumnHeaderIndexes = { colIndex: colIdx };
} else if (isPageKeys(key)) {
// Handle only Page Down key, Page Up should keep the current position
if (key.indexOf('Down') > -1 && currentPage.rows.length) {
const viewportPageSize = apiRef.current.unstable_getViewportPageSize();
const field = apiRef.current.getVisibleColumns()[colIndex].field;
const id = currentPage.rows[Math.min(viewportPageSize, currentPage.rows.length - 1)].id;
apiRef.current.setCellFocus(id, field);
const viewportPageSize = apiRef.current.unstable_getViewportPageSize();
const colIndexBefore = params.field ? apiRef.current.getColumnIndex(params.field) : 0;
const firstRowIndexInPage = currentPage.range?.firstRowIndex ?? null;
const lastRowIndexInPage = currentPage.range?.lastRowIndex ?? null;
const firstColIndex = 0;
const lastColIndex = colCount - 1;

// eslint-disable-next-line default-case
switch (event.key) {
case 'ArrowDown':
case 'Enter': {
if (firstRowIndexInPage !== null) {
goToCell(colIndexBefore, firstRowIndexInPage);
}
break;
}
return;
} else {
throw new Error('MUI: Key not mapped to navigation behavior.');
}

if (!nextColumnHeaderIndexes) {
const field = apiRef.current.getVisibleColumns()[colIndex].field;
if (currentPage.rows.length) {
apiRef.current.setCellFocus(currentPage.rows[0].id, field);
case 'ArrowRight': {
if (colIndexBefore < lastColIndex) {
goToHeader(colIndexBefore + 1, event);
}
break;
}
return;
}

nextColumnHeaderIndexes!.colIndex = Math.max(0, nextColumnHeaderIndexes!.colIndex);
nextColumnHeaderIndexes!.colIndex =
nextColumnHeaderIndexes!.colIndex >= colCount
? colCount - 1
: nextColumnHeaderIndexes!.colIndex;
case 'ArrowLeft': {
if (colIndexBefore > firstColIndex) {
goToHeader(colIndexBefore - 1, event);
}
break;
}

logger.debug(`Navigating to next column row ${nextColumnHeaderIndexes.colIndex}`);
apiRef.current.scrollToIndexes(nextColumnHeaderIndexes);
const field = apiRef.current.getVisibleColumns()[nextColumnHeaderIndexes.colIndex].field;
apiRef.current.setColumnHeaderFocus(field, event);
case 'PageDown': {
if (firstRowIndexInPage !== null && lastRowIndexInPage !== null) {
goToCell(
colIndexBefore,
Math.min(firstRowIndexInPage + viewportPageSize, lastRowIndexInPage),
);
}
break;
}

case 'Home': {
goToHeader(firstColIndex, event);
break;
}

case 'End': {
goToHeader(lastColIndex, event);
break;
}
}
},
[apiRef, colCount, logger, currentPage.rows],
[apiRef, colCount, currentPage, goToCell, goToHeader],
);

useGridApiEventHandler(apiRef, GridEvents.cellNavigationKeyDown, navigateCells);
useGridApiEventHandler(apiRef, GridEvents.columnHeaderNavigationKeyDown, navigateColumnHeaders);
useGridApiEventHandler(apiRef, GridEvents.cellNavigationKeyDown, handleCellNavigationKeyDown);
useGridApiEventHandler(
apiRef,
GridEvents.columnHeaderNavigationKeyDown,
handleColumnHeaderNavigationKeyDown,
);
};