A keyboard user focuses row 40, column 3. Scrolling recycles that DOM node. Focus falls to <body>, while a screen reader announces the wrong row after sorting. Virtualization saved nodes but broke navigation.
Separate logical and DOM focus
type ActiveCell={rowId:string,columnId:string};
let active:ActiveCell={rowId:'row-0040',columnId:'status'};
Store stable row IDs, not rendered indexes. When the active row leaves the window, keep logical focus and move DOM focus to a grid proxy or newly rendered equivalent. After sorting, locate the same row ID at its new position.
<div role="grid" aria-rowcount="10000" tabindex="0" aria-activedescendant="cell-row-0040-status"></div>
| Event | Expected focus evidence |
|---|---|
| ArrowDown | next logical row announced |
| scroll recycles active row | focus remains in grid |
| sort | same row ID stays active |
| filter removes row | deterministic fallback announced |
| loading error | retry reachable without pointer |
Build Playwright tests for document.activeElement, aria-activedescendant, and row/column attributes. Add manual checks with keyboard, zoom, and at least one browser/screen-reader pair. Automated assertions cannot validate announcement quality alone.
Test cancellation and retry during async page loading; never announce every scroll frame. This pattern covers one virtualization strategy, not all grid libraries. When the active item disappears after filtering, should your product focus the nearest row, the grid heading, or the filter control?
Top comments (0)