165
common/resources/client/ui/tables/navigate-grid.ts
Executable file
165
common/resources/client/ui/tables/navigate-grid.ts
Executable file
@@ -0,0 +1,165 @@
|
||||
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<HTMLElement> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user