diff --git a/core/ui/AccessibleGrid.tsx b/core/ui/AccessibleGrid.tsx index 47ae0f9..d023ab0 100644 --- a/core/ui/AccessibleGrid.tsx +++ b/core/ui/AccessibleGrid.tsx @@ -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 { @@ -25,6 +25,8 @@ export interface AccessibleGridProps { }; } +const cellId = (row: number, col: number) => `grid-cell-${row}-${col}`; + export function AccessibleGrid({ columns, rows, @@ -39,9 +41,27 @@ export function AccessibleGrid({ const [focusedCell, setFocusedCell] = useState<{ row: number; col: number } | null>(null); const gridRef = useRef(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(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) => { - if (!focusedCell) return; + if (!focusedCell || rows.length === 0 || columns.length === 0) return; const { row, col } = focusedCell; let newRow = row; @@ -49,19 +69,19 @@ export function AccessibleGrid({ 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": @@ -84,10 +104,10 @@ export function AccessibleGrid({ 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; @@ -95,19 +115,65 @@ export function AccessibleGrid({ 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) => { + 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 . + }; + + /** + * 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 , 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) { @@ -125,14 +191,17 @@ export function AccessibleGrid({ 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}. {rows.length} rows total. Use arrow keys to navigate, Home/End to jump, Space to sort. - - {columns.map((column, colIndex) => ( + + {columns.map((column) => ( ({ - {rows.map((row, rowIndex) => ( - - {columns.map((column, colIndex) => ( - handleCellFocus(rowIndex, colIndex)} - > - {column.render(row, rowIndex)} - - ))} - - ))} + {rows.map((row, rowIndex) => { + const key = rowKey(row, rowIndex); + return ( + + {columns.map((column, colIndex) => { + const isFocused = focusedCell + ? focusedCell.row === rowIndex && focusedCell.col === colIndex + : rowIndex === 0 && colIndex === 0; + return ( + handleCellFocus(rowIndex, colIndex, key)} + > + {column.render(row, rowIndex)} + + ); + })} + + ); + })}
diff --git a/core/ui/__tests__/AccessibleGrid.test.tsx b/core/ui/__tests__/AccessibleGrid.test.tsx index 85f591e..2e2df86 100644 --- a/core/ui/__tests__/AccessibleGrid.test.tsx +++ b/core/ui/__tests__/AccessibleGrid.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { AccessibleGrid } from "@/core/ui/AccessibleGrid"; +import axe from "axe-core"; import { describe, it, expect, vi } from "vitest"; +import { AccessibleGrid, type AccessibleGridProps } from "@/core/ui/AccessibleGrid"; interface TestRow { id: string; @@ -15,31 +16,63 @@ const testData: TestRow[] = [ { id: "3", name: "Item C", value: 300 } ]; -describe("AccessibleGrid", () => { - const columns = [ - { - key: "name", - header: "Name", - sortable: true, - render: (row: TestRow) => row.name - }, - { - key: "value", - header: "Value", - sortable: true, - render: (row: TestRow) => row.value.toString() - } - ]; +const columns = [ + { + key: "name", + header: "Name", + sortable: true, + render: (row: TestRow) => row.name + }, + { + key: "value", + header: "Value", + sortable: true, + render: (row: TestRow) => row.value.toString() + } +]; + +const rowKey = (row: TestRow) => row.id; + +/** Fetch a rendered cell by grid coordinates. */ +function cell(row: number, col: number): HTMLTableCellElement { + const el = document.getElementById(`grid-cell-${row}-${col}`); + if (!el) throw new Error(`missing cell ${row}-${col}`); + return el as HTMLTableCellElement; +} + +/** + * Focus a cell the way a pointer/keyboard user would, flushing the React + * focus handler so the grid has recorded the roving position before a + * subsequent key press. + */ +function focusCell(row: number, col: number): HTMLTableCellElement { + const el = cell(row, col); + act(() => { + el.focus(); + }); + return el; +} + +/** Press a key on whichever cell currently owns focus (events bubble to the grid). */ +function press(key: string, init: KeyboardEventInit = {}): void { + const target = (document.activeElement as HTMLElement | null) ?? document.body; + fireEvent.keyDown(target, { key, ...init }); +} +function renderGrid(overrides: Partial> = {}) { + const props: AccessibleGridProps = { + columns, + rows: testData, + rowKey, + caption: "Test table", + ...overrides + }; + return render(); +} + +describe("AccessibleGrid", () => { it("renders table with proper ARIA roles", () => { - render( - row.id} - caption="Test table" - /> - ); + renderGrid(); const table = screen.getByRole("grid"); expect(table).toBeInTheDocument(); @@ -47,28 +80,14 @@ describe("AccessibleGrid", () => { }); it("renders caption with accessibility help", () => { - render( - row.id} - caption="Test table" - /> - ); + renderGrid(); const caption = screen.getByText(/arrow keys to navigate/i); expect(caption).toBeInTheDocument(); }); it("renders all rows and columns", () => { - render( - row.id} - caption="Test table" - /> - ); + renderGrid(); expect(screen.getByText("Item A")).toBeInTheDocument(); expect(screen.getByText("Item B")).toBeInTheDocument(); @@ -79,50 +98,211 @@ describe("AccessibleGrid", () => { }); it("announces row count in status region", () => { - render( + renderGrid(); + + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("Showing 3 of 3 rows"); + }); + + it("exposes total row/column counts so virtualized windows keep a stable contract", () => { + renderGrid(); + + const table = screen.getByRole("grid"); + // Header row + 3 data rows. + expect(table).toHaveAttribute("aria-rowcount", "4"); + expect(table).toHaveAttribute("aria-colcount", "2"); + }); + + it("numbers the header row first and keeps aria-colindex monotonic", () => { + const { container } = renderGrid(); + + const rows = container.querySelectorAll('[role="row"]'); + expect(rows[0]).toHaveAttribute("aria-rowindex", "1"); // header + expect(rows[1]).toHaveAttribute("aria-rowindex", "2"); // first data row + expect(rows[2]).toHaveAttribute("aria-rowindex", "3"); // second data row + + expect(cell(0, 0)).toHaveAttribute("aria-colindex", "1"); + expect(cell(0, 1)).toHaveAttribute("aria-colindex", "2"); + }); + + it("keeps exactly one cell in the tab order (roving tabindex)", () => { + renderGrid(); + + const tabbable = [cell(0, 0), cell(0, 1), cell(1, 0), cell(1, 1)].filter((el) => el.tabIndex === 0); + expect(tabbable).toHaveLength(1); + expect(tabbable[0]).toBe(cell(0, 0)); + + focusCell(1, 1); + expect(cell(1, 1)).toHaveProperty("tabIndex", 0); + expect(cell(0, 0)).toHaveProperty("tabIndex", -1); + }); + + it("moves focus with the arrow keys and stops at the top/left boundary", () => { + renderGrid(); + + focusCell(0, 0); + expect(cell(0, 0)).toHaveFocus(); + + press("ArrowRight"); + expect(cell(0, 1)).toHaveFocus(); + + press("ArrowLeft"); + expect(cell(0, 0)).toHaveFocus(); + + // Already at row 0 / col 0: the boundary press must not wrap or throw. + press("ArrowUp"); + expect(cell(0, 0)).toHaveFocus(); + press("ArrowLeft"); + expect(cell(0, 0)).toHaveFocus(); + + press("ArrowDown"); + expect(cell(1, 0)).toHaveFocus(); + }); + + it("stops at the bottom/right boundary", () => { + renderGrid(); + + focusCell(2, 1); + press("ArrowDown"); + expect(cell(2, 1)).toHaveFocus(); + press("ArrowRight"); + expect(cell(2, 1)).toHaveFocus(); + press("ArrowUp"); + expect(cell(1, 1)).toHaveFocus(); + press("ArrowLeft"); + expect(cell(1, 0)).toHaveFocus(); + }); + + it("uses Home/End within a row and Ctrl+Home/Ctrl+End for the grid corners", () => { + renderGrid(); + + focusCell(1, 1); + press("Home"); + expect(cell(1, 0)).toHaveFocus(); + + press("End"); + expect(cell(1, 1)).toHaveFocus(); + + press("Home", { ctrlKey: true }); + expect(cell(0, 0)).toHaveFocus(); + + press("End", { ctrlKey: true }); + expect(cell(2, 1)).toHaveFocus(); + }); + + it("preserves focus on the same row when the data is reordered", () => { + const { rerender } = renderGrid(); + + // Row "1" is the focused row; give it a distinctive column value to track. + focusCell(0, 1); + expect(cell(0, 1)).toHaveTextContent("100"); + + const reordered = [testData[2], testData[1], testData[0]]; // ids 3, 2, 1 + rerender( + + ); + + // Row "1" moved from index 0 to index 2; focus moved with it. + expect(cell(2, 1)).toHaveFocus(); + expect(cell(2, 1)).toHaveTextContent("100"); + }); + + it("clamps focus to a live row when the focused row is removed", () => { + const { rerender } = renderGrid(); + + focusCell(1, 0); // row id "2" + + rerender(); + + // Row id "2" is gone; focus lands on an existing cell instead of . + expect(cell(0, 0)).toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + }); + + it("does not steal focus from a column header when rows re-render with the same order", async () => { + const onSort = vi.fn(); + const { rerender } = renderGrid({ onSort }); + + focusCell(0, 0); + const header = screen.getByRole("columnheader", { name: "Name" }); + act(() => { + header.focus(); + }); + expect(header).toHaveFocus(); + + // Same order, brand new array instance: a data refresh must leave the + // keyboard user on the header they are sorting from. + rerender( row.id} + rows={testData.map((row) => ({ ...row }))} + rowKey={rowKey} caption="Test table" + onSort={onSort} /> ); - const status = screen.getByRole("status"); - expect(status).toHaveTextContent("Showing 3 of 3 rows"); + expect(header).toHaveFocus(); }); - it("handles keyboard navigation with arrow keys", async () => { - const user = userEvent.setup(); - const { container } = render( + it("recycles DOM nodes without emitting duplicate cell ids", () => { + const { rerender, container } = renderGrid(); + + const idsOf = () => + Array.from(container.querySelectorAll('[id^="grid-cell-"]')).map((el) => el.id); + + const first = idsOf(); + expect(new Set(first).size).toBe(first.length); + + rerender( row.id} + rows={[testData[2], testData[0], testData[1]]} + rowKey={rowKey} caption="Test table" /> ); - const grid = container.querySelector('[role="grid"]') as HTMLElement; - await user.click(grid); - - // The grid should be keyboard navigable (detailed behavior tested via focus management) - expect(grid).toBeInTheDocument(); + const second = idsOf(); + expect(new Set(second).size).toBe(second.length); + expect(second).toHaveLength(first.length); }); - it("calls onSort callback when header is clicked", async () => { + it("triggers sorting from the keyboard for a sortable cell", () => { const onSort = vi.fn(); - const user = userEvent.setup(); + const { rerender } = renderGrid({ onSort }); - render( + focusCell(0, 0); + press("Enter"); + expect(onSort).toHaveBeenCalledWith("name", true); + + // The parent applies the sort and re-renders with the new sort state. + rerender( row.id} + rowKey={rowKey} caption="Test table" onSort={onSort} + sortBy="name" + sortAscending /> ); + act(() => { + cell(0, 0).focus(); + }); + + onSort.mockClear(); + press(" "); + // Same column, currently ascending: the next press toggles the direction. + expect(onSort).toHaveBeenCalledWith("name", false); + }); + + it("calls onSort callback when header is clicked", async () => { + const onSort = vi.fn(); + const user = userEvent.setup(); + + renderGrid({ onSort }); const nameHeader = screen.getByText("Name"); await user.click(nameHeader); @@ -134,17 +314,7 @@ describe("AccessibleGrid", () => { const onSort = vi.fn(); const user = userEvent.setup(); - const { rerender } = render( - row.id} - caption="Test table" - onSort={onSort} - sortBy="name" - sortAscending={true} - /> - ); + const { rerender } = renderGrid({ onSort, sortBy: "name", sortAscending: true }); const nameHeader = screen.getByText("Name"); await user.click(nameHeader); @@ -155,7 +325,7 @@ describe("AccessibleGrid", () => { row.id} + rowKey={rowKey} caption="Test table" onSort={onSort} sortBy="name" @@ -168,16 +338,7 @@ describe("AccessibleGrid", () => { }); it("sets aria-sort on sorted column header", () => { - render( - row.id} - caption="Test table" - sortBy="name" - sortAscending={true} - /> - ); + renderGrid({ sortBy: "name", sortAscending: true }); const nameHeader = screen.getByRole("columnheader", { name: "Name" }); expect(nameHeader).toHaveAttribute("aria-sort", "ascending"); @@ -187,46 +348,34 @@ describe("AccessibleGrid", () => { }); it("renders empty rows list correctly", () => { - render( - row.id} - caption="Empty table" - /> - ); + renderGrid({ rows: [] }); const status = screen.getByRole("status"); expect(status).toHaveTextContent("Showing 0 of 0 rows"); - }); - - it("sets proper row and column indices for ARIA", () => { - const { container } = render( - row.id} - caption="Test table" - /> - ); - - const rows = container.querySelectorAll('[role="row"]'); - expect(rows[1]).toHaveAttribute("aria-rowindex", "2"); // First data row - expect(rows[2]).toHaveAttribute("aria-rowindex", "3"); // Second data row + expect(screen.getByRole("grid")).toHaveAttribute("aria-rowcount", "1"); }); it("applies custom className", () => { - const { container } = render( - row.id} - caption="Test table" - className="custom-class" - /> - ); + const { container } = renderGrid({ className: "custom-class" }); const wrapper = container.querySelector(".custom-class"); expect(wrapper).toBeInTheDocument(); }); + + it("has no serious or critical axe violations", async () => { + const { container } = renderGrid(); + + const results = await axe.run(container, { + rules: { + // jsdom has no layout engine, so contrast cannot be computed. + "color-contrast": { enabled: false } + } + }); + + const blocking = results.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious" + ); + expect(blocking).toEqual([]); + expect(results.passes.length).toBeGreaterThan(0); + }); });