Skip to content
Merged
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
159 changes: 118 additions & 41 deletions core/ui/AccessibleGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/core/lib/cn";

export interface AccessibleGridColumn<T> {
Expand All @@ -25,6 +25,8 @@ export interface AccessibleGridProps<T> {
};
}

const cellId = (row: number, col: number) => `grid-cell-${row}-${col}`;

export function AccessibleGrid<T>({
columns,
rows,
Expand All @@ -39,29 +41,47 @@ export function AccessibleGrid<T>({
const [focusedCell, setFocusedCell] = useState<{ row: number; col: number } | null>(null);
const gridRef = useRef<HTMLTableElement>(null);

/**
* Key of the row that currently owns DOM focus, tracked separately from the
* row *index*. A virtualized list recycles DOM nodes and a sorted/filtered
* dataset moves rows from one index to another; the key is what stays stable,
* so it is what focus restoration must follow.
*/
const focusedRowKeyRef = useRef<string | null>(null);
/** Whether focus was inside the grid just before the current commit. */
const gridHadFocusRef = useRef(false);

const clampRow = (row: number) => Math.max(0, Math.min(rows.length - 1, row));
const clampCol = (col: number) => Math.max(0, Math.min(columns.length - 1, col));

const moveFocus = useCallback((row: number, col: number) => {
setFocusedCell({ row, col });
document.getElementById(cellId(row, col))?.focus();
}, []);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTableElement>) => {
if (!focusedCell) return;
if (!focusedCell || rows.length === 0 || columns.length === 0) return;

const { row, col } = focusedCell;
let newRow = row;
let newCol = col;

switch (event.key) {
case "ArrowUp":
newRow = Math.max(0, row - 1);
newRow = clampRow(row - 1);
event.preventDefault();
break;
case "ArrowDown":
newRow = Math.min(rows.length - 1, row + 1);
newRow = clampRow(row + 1);
event.preventDefault();
break;
case "ArrowLeft":
newCol = Math.max(0, col - 1);
newCol = clampCol(col - 1);
event.preventDefault();
break;
case "ArrowRight":
newCol = Math.min(columns.length - 1, col + 1);
newCol = clampCol(col + 1);
event.preventDefault();
break;
case "Home":
Expand All @@ -84,30 +104,76 @@ export function AccessibleGrid<T>({
break;
case " ":
case "Enter":
if (columns[newCol]?.sortable && sortBy !== columns[newCol]?.key) {
onSort?.(columns[newCol].key, true);
} else if (columns[newCol]?.sortable && sortBy === columns[newCol]?.key) {
onSort?.(columns[newCol].key, !sortAscending);
if (columns[col]?.sortable && sortBy !== columns[col].key) {
onSort?.(columns[col].key, true);
} else if (columns[col]?.sortable && sortBy === columns[col].key) {
onSort?.(columns[col].key, !sortAscending);
}
event.preventDefault();
break;
default:
return;
}

setFocusedCell({ row: newRow, col: newCol });

const cellId = `grid-cell-${newRow}-${newCol}`;
const cell = document.getElementById(cellId);
cell?.focus();
// A boundary key press still lands on the same cell: focus it again so a
// recycled/remounted node cannot silently lose the caret.
moveFocus(newRow, newCol);
},
[focusedCell, rows.length, columns, sortBy, sortAscending, onSort]
[focusedCell, rows.length, columns.length, sortBy, sortAscending, onSort, moveFocus, clampRow, clampCol]
);

const handleCellFocus = (row: number, col: number) => {
const handleCellFocus = (row: number, col: number, key: string) => {
gridHadFocusRef.current = true;
focusedRowKeyRef.current = key;
setFocusedCell({ row, col });
};

const handleGridBlur = (event: React.FocusEvent<HTMLTableElement>) => {
const next = event.relatedTarget as Node | null;
if (next && event.currentTarget.contains(next)) return; // focus moved within the grid
if (next) {
// Focus genuinely moved to another widget: stop tracking this row so a
// background data update cannot yank the caret back.
gridHadFocusRef.current = false;
focusedRowKeyRef.current = null;
}
// `relatedTarget === null` also happens when the focused node is removed by
// a data update. Keep the tracked row so the effect below can move focus to
// a still-rendered cell instead of dropping it on <body>.
};

/**
* Keep the roving tabindex (and, when the grid has focus, the DOM focus) on
* the same logical row after the data changes. Without this, sorting or a
* windowed update that unmounts the focused node drops focus to <body>, which
* is the "visually present but unreachable row" defect this grid guards
* against.
*/
useEffect(() => {
const key = focusedRowKeyRef.current;
if (!key || rows.length === 0) return;

const rowIndex = rows.findIndex((row, index) => rowKey(row, index) === key);
const col = clampCol(focusedCell?.col ?? 0);

let next: { row: number; col: number };
if (rowIndex === -1) {
// The focused row was removed; clamp to a row that still exists.
const fallbackRow = clampRow(focusedCell?.row ?? 0);
focusedRowKeyRef.current = rowKey(rows[fallbackRow], fallbackRow);
next = { row: fallbackRow, col };
} else {
next = { row: rowIndex, col };
}

if (focusedCell && next.row === focusedCell.row && next.col === focusedCell.col) return;

setFocusedCell(next);
if (gridHadFocusRef.current) {
document.getElementById(cellId(next.row, next.col))?.focus();
}
}, [rows, rowKey, focusedCell, clampCol, clampRow]);

const handleHeaderClick = (key: string) => {
if (onSort) {
if (sortBy === key) {
Expand All @@ -125,14 +191,17 @@ export function AccessibleGrid<T>({
className="w-full min-w-[36rem] border-collapse text-left text-sm"
role="grid"
aria-label={caption}
aria-rowcount={rows.length + 1}
aria-colcount={columns.length}
onKeyDown={handleKeyDown}
onBlur={handleGridBlur}
>
<caption className="sr-only">
{caption}. {rows.length} rows total. Use arrow keys to navigate, Home/End to jump, Space to sort.
</caption>
<thead>
<tr className="border-b border-[#e3ebf5]" role="row">
{columns.map((column, colIndex) => (
<tr className="border-b border-[#e3ebf5]" role="row" aria-rowindex={1}>
{columns.map((column) => (
<th
key={column.key}
scope="col"
Expand Down Expand Up @@ -160,28 +229,36 @@ export function AccessibleGrid<T>({
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr
key={rowKey(row, rowIndex)}
className="border-b border-[#f0f4f9] last:border-0"
role="row"
aria-rowindex={rowIndex + 2}
>
{columns.map((column, colIndex) => (
<td
key={`${rowKey(row, rowIndex)}-${column.key}`}
className="py-3 pr-4 text-[#172033]"
role="gridcell"
aria-colindex={colIndex + 1}
id={`grid-cell-${rowIndex}-${colIndex}`}
tabIndex={focusedCell?.row === rowIndex && focusedCell?.col === colIndex ? 0 : -1}
onFocus={() => handleCellFocus(rowIndex, colIndex)}
>
{column.render(row, rowIndex)}
</td>
))}
</tr>
))}
{rows.map((row, rowIndex) => {
const key = rowKey(row, rowIndex);
return (
<tr
key={key}
className="border-b border-[#f0f4f9] last:border-0"
role="row"
aria-rowindex={rowIndex + 2}
>
{columns.map((column, colIndex) => {
const isFocused = focusedCell
? focusedCell.row === rowIndex && focusedCell.col === colIndex
: rowIndex === 0 && colIndex === 0;
return (
<td
key={`${key}-${column.key}`}
className="py-3 pr-4 text-[#172033]"
role="gridcell"
aria-colindex={colIndex + 1}
id={cellId(rowIndex, colIndex)}
tabIndex={isFocused ? 0 : -1}
onFocus={() => handleCellFocus(rowIndex, colIndex, key)}
>
{column.render(row, rowIndex)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
<div className="sr-only" role="status">
Expand Down
Loading