From 1d119ea684f790ebea6300708d2366ab22d13709 Mon Sep 17 00:00:00 2001 From: dezzy-music Date: Tue, 13 Oct 2026 10:22:59 -0600 Subject: [PATCH] fix(a11y): remove tabIndex on BarChart bars inside aria-hidden to eliminate silent tab stops (#708) --- src/components/ui/BarChart.test.tsx | 30 ++++++++++++++--------------- src/components/ui/BarChart.tsx | 18 +++++++---------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/components/ui/BarChart.test.tsx b/src/components/ui/BarChart.test.tsx index 1c4f934..2bbf5e3 100644 --- a/src/components/ui/BarChart.test.tsx +++ b/src/components/ui/BarChart.test.tsx @@ -44,39 +44,39 @@ describe("BarChart accessibility", () => { const bars = container.querySelectorAll("div[aria-hidden='true'] > div"); expect(bars.length).toBeGreaterThan(0); - // The bars are focusable for sighted keyboard users, but sit inside an - // aria-hidden subtree so assistive tech relies on the table alone. - const firstBar = container.querySelector("[tabindex='0']"); - expect(firstBar).toBeInTheDocument(); - expect(firstBar!.closest("[aria-hidden='true']")).not.toBeNull(); + // The bars sit inside an aria-hidden subtree with no focusable elements (#708), + // so assistive tech relies on the table alone and SR users don't encounter silent tab stops. + const focusable = container.querySelectorAll("[tabindex='0']"); + expect(focusable).toHaveLength(0); }); - it("makes each bar keyboard focusable with a visible focus ring", () => { + it("ensures visual bars do not contain focusable elements inside aria-hidden (#708)", () => { const { container } = render(); - const focusable = container.querySelectorAll("[tabindex='0']"); - expect(focusable).toHaveLength(DATA.length); - for (const bar of Array.from(focusable)) { - expect(bar.className).toContain("focus-visible:outline-2"); - } + const ariaHiddenContainer = container.querySelector("[aria-hidden='true']"); + expect(ariaHiddenContainer).toBeInTheDocument(); + const focusableInside = ariaHiddenContainer!.querySelectorAll( + "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])", + ); + expect(focusableInside).toHaveLength(0); }); - it("reveals the same tooltip on keyboard focus as on hover", () => { + it("reveals the tooltip on hover", () => { const { container } = render( `$${v}`} title="Spend" />, ); const tooltip = container.querySelector("[aria-hidden='true'] span"); expect(tooltip).toHaveTextContent("Week 1: $120"); - // group-focus-within is what makes the focus-reachable tooltip visible. - expect(tooltip!.className).toContain("group-focus-within:opacity-100"); expect(tooltip!.className).toContain("group-hover:opacity-100"); }); it("draws a stripe pattern on negative bars as a non-colour sign cue", () => { const { container } = render(); - const bars = Array.from(container.querySelectorAll("[tabindex='0']")); + const bars = Array.from( + container.querySelectorAll("div[aria-hidden='true'] .relative > div"), + ); expect(bars[1].style.backgroundImage).toContain("repeating-linear-gradient"); expect(bars[0].style.backgroundImage).toBe(""); }); diff --git a/src/components/ui/BarChart.tsx b/src/components/ui/BarChart.tsx index eabc6c8..10cdc49 100644 --- a/src/components/ui/BarChart.tsx +++ b/src/components/ui/BarChart.tsx @@ -15,13 +15,10 @@ export interface BarChartPoint { * widely-supported role) and it means every figure is available as real text * to a screen reader rather than being reconstructed from bar heights. * - * Hover-only information used to live in a `title` attribute, which is not - * reachable by keyboard and is not reliably announced. Each bar is now - * focusable with a visible focus ring and reveals the same tooltip on focus - * (group-focus-within) as on hover, so a keyboard user gets the identical - * detail a mouse user does. The tooltip lives inside the aria-hidden subtree - * on purpose: sighted keyboard users need to *see* it, while assistive tech - * should read the table once rather than announce every bar again. + * Sighted users receive hover tooltips on each bar. The visual bars subtree + * is marked aria-hidden and contains no focusable elements (#708), avoiding + * axe's aria-hidden-focus rule and silent tab stops for screen reader users. + * Assistive tech reaches all data via the visually-hidden (sr-only) table. * * Colour is never the only signal: negative bars are drawn with a diagonal * stripe as well as a different hue, and the value keeps its sign in both the @@ -70,11 +67,10 @@ export function BarChart({ >
- + {d.label}: {format(d.value)}