From c79b99556119c3c5dc8856159b91cb1f1fa48d7d Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Sat, 11 Jul 2026 20:44:36 -0600 Subject: [PATCH] fix(status): clamp heatmap keyboard focus when the matrix shrinks Derive the effective roving-tabindex cell at render time with Math.min against the current rows/cols, so exactly one gridcell keeps tabIndex=0 after a data refresh shrinks the matrix. Adds a regression test covering the shrink and arrow-key navigation from the clamped cell. Fixes #1443 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../primitives/heatmap-matrix.test.tsx | 40 +++++++++++++++++++ .../analytics/primitives/HeatmapMatrix.tsx | 8 +++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx index d9954564f..8b3965e5c 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx @@ -132,6 +132,46 @@ describe('HeatmapMatrix primitive', () => { expect(legend.getAttribute('aria-label') ?? '').not.toMatch(/count-weighted-mean.*approx/i); }); + it('clamps the roving tabindex when the matrix shrinks (regression #1443)', () => { + const { rerender } = render(); + // Move the active cell to the last row/col via keyboard: End then + // ArrowDown lands on src-2 → dest-c ([1, 2]). + const cells = screen.getAllByRole('gridcell'); + const first = cells.find((c) => /src-1.*dest-a/.test(c.getAttribute('aria-label') ?? ''))!; + expect(first).toBeTruthy(); + (first as HTMLElement).focus(); + fireEvent.keyDown(first, { key: 'End' }); + const rowEnd = cells.find((c) => /src-1.*dest-c/.test(c.getAttribute('aria-label') ?? ''))!; + fireEvent.keyDown(rowEnd, { key: 'ArrowDown' }); + const corner = cells.find((c) => /src-2.*dest-c/.test(c.getAttribute('aria-label') ?? ''))!; + expect(corner.getAttribute('tabindex')).toBe('0'); + + // Shrink to a 1×2 matrix — the stored active cell [1, 2] is now out of range. + const shrunk: HeatmapData = { + ...data, + rows: ['src-1'], + cols: ['dest-a', 'dest-b'], + cells: [ + { row: 'src-1', col: 'dest-a', value: 30, count: 500 }, + { row: 'src-1', col: 'dest-b', value: 60, count: 300 }, + ], + }; + rerender(); + + const shrunkCells = screen.getAllByRole('gridcell'); + expect(shrunkCells.length).toBe(2); + const tabbable = shrunkCells.filter((c) => c.getAttribute('tabindex') === '0'); + expect(tabbable.length, 'exactly one gridcell keeps tabIndex=0').toBe(1); + // Clamped to the last row/col that still exists: src-1 → dest-b. + expect(tabbable[0].getAttribute('aria-label')).toMatch(/src-1.*dest-b/); + + // Keyboard nav still works from the clamped cell. + (tabbable[0] as HTMLElement).focus(); + fireEvent.keyDown(tabbable[0], { key: 'ArrowLeft' }); + const shrunkFirst = shrunkCells.find((c) => /src-1.*dest-a/.test(c.getAttribute('aria-label') ?? '')); + expect(document.activeElement).toBe(shrunkFirst); + }); + it('exposes data-cell-size attribute as a number on the SVG root', () => { const { container } = render(); const svg = container.querySelector('svg[role="grid"]'); diff --git a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx index 04d080034..1fbf80115 100644 --- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx +++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx @@ -293,8 +293,12 @@ export function HeatmapMatrix({ data, theme, title, height }: Props) { const svgWidth = ROW_LABEL_WIDTH + gridWidth + 8; const svgHeight = HEADER_HEIGHT + gridHeight + 8; - // Roving tabindex state: [rowIdx, colIdx] + // Roving tabindex state: [rowIdx, colIdx]. Clamped at render time so the + // active cell stays in range when rows/cols shrink after a data refresh + // (otherwise no cell gets tabIndex=0 and the grid leaves the tab order). const [active, setActive] = useState<[number, number]>([0, 0]); + const activeRow = Math.min(active[0], rows.length - 1); + const activeCol = Math.min(active[1], cols.length - 1); const cellRefs = useRef>(new Map()); const focusCell = useCallback((r: number, c: number) => { @@ -504,7 +508,7 @@ export function HeatmapMatrix({ data, theme, title, height }: Props) { const cy = y; const aria = cellAriaLabel(row, col, cell, confidence, unit, suppressBelow, approx); - const isActive = active[0] === ri && active[1] === ci; + const isActive = activeRow === ri && activeCol === ci; // Visual fill let fill = 'transparent';