import React, {KeyboardEventHandler} from 'react'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {focusWithoutScrolling} from '@react-aria/utils'; import {isCtrlKeyPressed} from '../../utils/keybinds/is-ctrl-key-pressed'; interface Props { cellCount: number; rowCount: number; } export function useGridNavigation(props: Props) { const {cellCount, rowCount} = props; const onKeyDown: KeyboardEventHandler = e => { switch (e.key) { case 'ArrowLeft': focusSiblingCell(e, {cell: {op: 'decrement'}}, props); break; case 'ArrowRight': focusSiblingCell(e, {cell: {op: 'increment'}}, props); break; case 'ArrowUp': focusSiblingCell(e, {row: {op: 'decrement'}}, props); break; case 'ArrowDown': focusSiblingCell(e, {row: {op: 'increment'}}, props); break; case 'PageUp': focusSiblingCell(e, {row: {op: 'decrement', count: 5}}, props); break; case 'PageDown': focusSiblingCell(e, {row: {op: 'increment', count: 5}}, props); break; case 'Tab': focusFirstElementAfterGrid(e); break; case 'Home': if (isCtrlKeyPressed(e)) { // move to first cell in first row focusSiblingCell( e, { row: {op: 'decrement', count: rowCount}, cell: {op: 'decrement', count: cellCount}, }, props ); } else { // move to first cell in current row focusSiblingCell( e, {cell: {op: 'decrement', count: cellCount}}, props ); } break; case 'End': if (isCtrlKeyPressed(e)) { // move to last cell in last row focusSiblingCell( e, { row: {op: 'increment', count: rowCount}, cell: {op: 'increment', count: cellCount}, }, props ); } else { // move to last cell in current row focusSiblingCell( e, {cell: {op: 'increment', count: cellCount}}, props ); } break; } }; return {onKeyDown}; } interface Operations { cell?: { op: 'increment' | 'decrement'; count?: number; }; row?: { op: 'increment' | 'decrement'; count?: number; }; } function focusSiblingCell( e: React.KeyboardEvent, operations: Operations, {cellCount, rowCount}: Props ) { if (document.activeElement?.tagName === 'input') return; e.preventDefault(); const grid = e.currentTarget as HTMLElement; // focused element might be inside the cell and not the cell itself const currentCell = (e.target as HTMLElement).closest('[aria-colindex]'); if (!currentCell || !grid) return; const row = currentCell.closest('[aria-rowindex]'); if (!row) return; // grab row and cell index from aria attributes let rowIndex = parseInt(row.getAttribute('aria-rowindex') as string); let cellIndex = parseInt(currentCell.getAttribute('aria-colindex') as string); if (Number.isNaN(rowIndex) || Number.isNaN(cellIndex)) return; // adjust row index for next cell selector const rowOpCount = operations.row?.count ?? 1; if (operations.row?.op === 'increment') { rowIndex = Math.min(rowCount, rowIndex + rowOpCount); } else if (operations.row?.op === 'decrement') { rowIndex = Math.max(1, rowIndex - rowOpCount); } // adjust cell index for next cell selector const cellOpCount = operations.cell?.count ?? 1; if (operations.cell?.op === 'increment') { cellIndex = Math.min(cellCount, cellIndex + cellOpCount); } else if (operations.cell?.op === 'decrement') { cellIndex = Math.max(1, cellIndex - cellOpCount); } // find the next cell that should be focused const nextCell = grid.querySelector( `[aria-rowindex="${rowIndex}"] [aria-colindex="${cellIndex}"]` ) as HTMLElement | undefined; if (!nextCell) return; // find any focusable elements inside the cell const walker = getFocusableTreeWalker(nextCell); const nextFocusableElement = (walker.nextNode() || nextCell) as HTMLElement; // adjust tab index on current and next cells and focus either next cell or first focusable element inside that cell currentCell.setAttribute('tabindex', '-1'); nextFocusableElement.setAttribute('tabindex', '0'); nextFocusableElement.focus(); } // grid is treated as a single tab stop, focus first element after grid, instead of moving focus withing grid on tab press function focusFirstElementAfterGrid(e: React.KeyboardEvent) { const grid = e.currentTarget as HTMLElement; if (e.shiftKey) { grid.focus(); } else { const walker = getFocusableTreeWalker(grid, {tabbable: true}); let next: HTMLElement; let last: HTMLElement; do { last = walker.lastChild() as HTMLElement; if (last) { next = last; } } while (last); // @ts-ignore if (next && !next.contains(document.activeElement)) { focusWithoutScrolling(next); } } }