Skip to content
Open
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
30 changes: 15 additions & 15 deletions src/components/ui/BarChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>("[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(<BarChart data={DATA} title="Spend" />);

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(
<BarChart data={DATA} formatValue={(v) => `$${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(<BarChart data={DATA} title="Spend" />);

const bars = Array.from(container.querySelectorAll<HTMLElement>("[tabindex='0']"));
const bars = Array.from(
container.querySelectorAll<HTMLElement>("div[aria-hidden='true'] .relative > div"),
);
expect(bars[1].style.backgroundImage).toContain("repeating-linear-gradient");
expect(bars[0].style.backgroundImage).toBe("");
});
Expand Down
18 changes: 7 additions & 11 deletions src/components/ui/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,11 +67,10 @@ export function BarChart({
>
<div className="relative flex w-full flex-1 items-end justify-center">
<div
tabIndex={0}
className={
isNegative
? "w-full max-w-8 rounded-t-md bg-rose-600/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 dark:bg-rose-400/70 dark:focus-visible:outline-rose-400"
: "w-full max-w-8 rounded-t-md bg-indigo-500/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:bg-indigo-400/70 dark:focus-visible:outline-indigo-400"
? "w-full max-w-8 rounded-t-md bg-rose-600/80 dark:bg-rose-400/70"
: "w-full max-w-8 rounded-t-md bg-indigo-500/80 dark:bg-indigo-400/70"
}
style={{
height: `${pct}%`,
Expand All @@ -86,7 +82,7 @@ export function BarChart({
: undefined,
}}
/>
<span className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-slate-900 px-1.5 py-0.5 text-[11px] font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 dark:bg-slate-100 dark:text-slate-900">
<span className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-slate-900 px-1.5 py-0.5 text-[11px] font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-slate-100 dark:text-slate-900">
{d.label}: {format(d.value)}
</span>
</div>
Expand Down