Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions src/components/table/PlainTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ export function PlainTable({
},
[onRowClick],
),
tableRef,
);

// ── Row click handler ─────────────────────────
Expand Down
31 changes: 25 additions & 6 deletions src/components/table/useKeyboardNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,27 @@ import type { TableRow, SelectionState } from './types';
/** Number of rows to jump with PageUp / PageDown. */
const PAGE_SIZE = 10;

/**
* Elements that consume keystrokes themselves. The nav binds bare letters (j/k), Space, Enter,
* Home and End, so any of these inside a cell would otherwise be unusable from the keyboard.
*/
const INTERACTIVE_CONTENT =
'a[href], button, input, select, textarea, [contenteditable]:not([contenteditable="false"])';

/**
* Scroll the active row into view within the table container.
*
* Scoped to the grid that owns the event when a container is supplied. Row numbers restart at 0 in
* every grid, so a document-wide lookup finds the first match on the page rather than this grid's.
*/
function scrollActiveRowIntoView(rowNum: number) {
function scrollActiveRowIntoView(
rowNum: number,
container?: HTMLElement | null,
) {
// Defer to allow React to render the new selection state first
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(
`[data-row-num="${rowNum}"]`,
);
const root: ParentNode = container ?? document;
const el = root.querySelector<HTMLElement>(`[data-row-num="${rowNum}"]`);
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
Expand All @@ -30,11 +42,18 @@ export function useKeyboardNav(
selection: SelectionState,
onSelectionChange?: (selection: SelectionState) => void,
onRowClick?: (row: TableRow, event: React.KeyboardEvent) => void,
containerRef?: React.RefObject<HTMLElement | null>,
) {
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (rows.length === 0) return;

// Let interactive cell content handle its own keys. Without this, Enter on an in-cell link
// activates the row instead of following the link, and typing "j" in an in-cell input moves
// the selection instead of typing a letter.
const target = event.target as HTMLElement | null;
if (target?.closest?.(INTERACTIVE_CONTENT)) return;

const { activeRow } = selection;
let nextRow: number | null = null;

Expand Down Expand Up @@ -126,10 +145,10 @@ export function useKeyboardNav(
: new Set([nextRow]),
};
onSelectionChange?.(newSelection);
scrollActiveRowIntoView(nextRow);
scrollActiveRowIntoView(nextRow, containerRef?.current);
}
},
[rows, selection, onSelectionChange, onRowClick],
[rows, selection, onSelectionChange, onRowClick, containerRef],
);

return { handleKeyDown };
Expand Down