From ca7044b229a0641977347e33f91d15ea3a291220 Mon Sep 17 00:00:00 2001 From: Taleef Date: Wed, 29 Jul 2026 14:02:52 -0400 Subject: [PATCH 1/2] fix(table): don't hijack keystrokes from interactive cell content handleKeyDown ran for every keydown that reached the grid, and calls preventDefault before deciding whether the key was meant for it. Anything interactive inside a cell is therefore unusable from the keyboard: Enter on a link activates the row instead of following the link, and because the nav binds bare letters, typing "j" or "k" in an in-cell input moves the row selection instead of typing a character. Space, Home and End are taken the same way. Return early when the event originates inside an anchor, button, input, select, textarea or contenteditable, so that content keeps its own keys. Mouse users never saw this, which is what makes it specifically an accessibility bug. --- src/components/table/useKeyboardNav.ts | 31 +++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/components/table/useKeyboardNav.ts b/src/components/table/useKeyboardNav.ts index 3e620c3..3f30eff 100644 --- a/src/components/table/useKeyboardNav.ts +++ b/src/components/table/useKeyboardNav.ts @@ -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( - `[data-row-num="${rowNum}"]`, - ); + const root: ParentNode = container ?? document; + const el = root.querySelector(`[data-row-num="${rowNum}"]`); el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }); } @@ -30,11 +42,18 @@ export function useKeyboardNav( selection: SelectionState, onSelectionChange?: (selection: SelectionState) => void, onRowClick?: (row: TableRow, event: React.KeyboardEvent) => void, + containerRef?: React.RefObject, ) { 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; @@ -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 }; From acca62068e8066b9bbf6ce160943219cee25c78c Mon Sep 17 00:00:00 2001 From: Taleef Date: Wed, 29 Jul 2026 14:02:52 -0400 Subject: [PATCH 2/2] fix(table): scope active-row scrolling to the grid that owns the event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollActiveRowIntoView looked up [data-row-num="N"] against document. Row numbers restart at 0 in every grid, so on a page with more than one grid the lookup finds the first match in document order rather than the grid being navigated — arrow keys scroll a different table than the one with focus. useKeyboardNav now takes an optional containerRef and queries within it, falling back to document when none is supplied so existing callers are unaffected. PlainTable passes the tableRef it already holds. --- src/components/table/PlainTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/table/PlainTable.tsx b/src/components/table/PlainTable.tsx index 8ec8988..0a7fc22 100644 --- a/src/components/table/PlainTable.tsx +++ b/src/components/table/PlainTable.tsx @@ -649,6 +649,7 @@ export function PlainTable({ }, [onRowClick], ), + tableRef, ); // ── Row click handler ─────────────────────────