diff --git a/src/components/HealthImpactDashboard.jsx b/src/components/HealthImpactDashboard.jsx
index a803f9f..97c6f8e 100644
--- a/src/components/HealthImpactDashboard.jsx
+++ b/src/components/HealthImpactDashboard.jsx
@@ -1,4 +1,5 @@
-import React, { useState, useMemo, useCallback } from "react";
+import React, { useCallback, useId, useMemo, useState } from "react";
+import { DisclosureButton } from "./ui/PressableCard";
// ─── AQI & Health Data ──────────────────────────────────────────────────────
const AQI_CATEGORIES = [
@@ -147,7 +148,11 @@ function AQIGauge({ aqi }) {
}
function HealthScoreRing({ score }) {
- const circumference = 2 * Math.PI(40);
+ // Math.PI(40) -- `Math.PI` is a number, so calling it threw
+ // "TypeError: Math.PI is not a function" and took the whole dashboard down on render.
+ // Fixed here because the keyboard fix below cannot be verified on a component that
+ // cannot mount. r=40 matches the this dash array is drawn onto.
+ const circumference = 2 * Math.PI * 40;
const offset = circumference - (score / 100) * circumference;
const color = score >= 80 ? "#22c55e" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
@@ -182,13 +187,22 @@ function StatCard({ icon, label, value, color, subtext }) {
function PollutantCard({ pollutant, aqi, hours }) {
const [expanded, setExpanded] = useState(false);
+ const panelId = useId();
const risk = calculateExposureRisk(aqi, hours, "outdoor");
const riskColor = risk > 70 ? "#ef4444" : risk > 40 ? "#f97316" : risk > 20 ? "#eab308" : "#22c55e";
return (
-
setExpanded(!expanded)}>
-
+
+ {/* The header is the control; the panel it opens sits beside it rather than inside
+ it, so the button's accessible name stays the pollutant rather than becoming the
+ whole card's text once expanded. */}
+ setExpanded(!expanded)}
+ controls={panelId}
+ label={`${pollutant.pollutant} health effects, exposure risk ${risk}%`}
+ className="flex items-center justify-between"
+ >
Trend: {item.trend === 'improving' ? '📈' : item.trend === 'worsening' ? '📉' : '➡️'} {item.trend}
diff --git a/src/components/keyboardCardControls.test.jsx b/src/components/keyboardCardControls.test.jsx
new file mode 100644
index 0000000..ba24da4
--- /dev/null
+++ b/src/components/keyboardCardControls.test.jsx
@@ -0,0 +1,122 @@
+import { describe, it, expect } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import AIPollutionForecast from './AIPollutionForecast';
+import HealthImpactDashboard from './HealthImpactDashboard';
+import OceanAcidificationMonitor from './OceanAcidificationMonitor';
+
+/**
+ * Every card and tile control on these dashboards used to be a `
`: no tab
+ * stop, no Enter or Space, no role. These assert the controls are reachable and operable
+ * without a mouse, at the components rather than at the shared button — a `
` that
+ * merely imports `PressableCard` would still fail them. See #1140.
+ *
+ * HealthRiskCards and ReportCards get the same fix but are not rendered here: both import
+ * `framer-motion`, which is not in package.json and not installed, so they cannot be
+ * mounted at all in this repo today. That is a separate problem from #1140 and not one
+ * this change takes on. Their controls are covered by PressableCard.test.jsx and by the
+ * jsx-a11y rules, which no longer flag either file.
+ */
+
+/** Opens a tab on one of the tabbed dashboards. */
+function openTab(name) {
+ fireEvent.click(screen.getByRole('button', { name }));
+}
+
+describe('HealthImpactDashboard — pollutant cards (#1140)', () => {
+ it('gives every pollutant card a keyboard-operable header', () => {
+ render();
+ openTab(/Health Effects/);
+
+ const buttons = screen.getAllByRole('button', { name: /health effects, exposure risk/i });
+ expect(buttons.length).toBeGreaterThan(0);
+ for (const button of buttons) {
+ expect(button.tagName).toBe('BUTTON');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ }
+ });
+
+ it('opens one card without opening the rest', () => {
+ render();
+ openTab(/Health Effects/);
+ const buttons = screen.getAllByRole('button', { name: /health effects, exposure risk/i });
+
+ fireEvent.click(buttons[0]);
+
+ expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
+ expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.getByText('Short-term Effects')).toBeInTheDocument();
+ });
+
+ it('keeps the accessible name to the pollutant once the card is open', () => {
+ // The panel sits beside the button rather than inside it, so opening the card does
+ // not fold the whole expanded body into the button's name.
+ render();
+ openTab(/Health Effects/);
+ const [button] = screen.getAllByRole('button', { name: /health effects, exposure risk/i });
+ const nameBefore = button.getAttribute('aria-label');
+
+ fireEvent.click(button);
+ expect(button.getAttribute('aria-label')).toBe(nameBefore);
+ });
+});
+
+describe('AIPollutionForecast — weather condition tiles (#1140)', () => {
+ it('makes each weather tile a pressable button', () => {
+ render();
+ openTab(/Weather/);
+
+ const tiles = screen.getAllByRole('button', { name: /^Weather condition:/ });
+ expect(tiles.length).toBeGreaterThan(0);
+ for (const tile of tiles) {
+ expect(tile).toHaveAttribute('aria-pressed');
+ }
+ });
+
+ it('presses the chosen tile and releases it when chosen again', () => {
+ render();
+ openTab(/Weather/);
+ const [tile] = screen.getAllByRole('button', { name: /^Weather condition:/ });
+
+ expect(tile).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(tile);
+ expect(tile).toHaveAttribute('aria-pressed', 'true');
+ fireEvent.click(tile);
+ expect(tile).toHaveAttribute('aria-pressed', 'false');
+ });
+});
+
+describe('OceanAcidificationMonitor — region and scenario selectors (#1140)', () => {
+ it('makes each ocean region a pressable button, one chosen at a time', () => {
+ render();
+ openTab(/Regions/);
+
+ const regions = screen.getAllByRole('button', { name: /^Ocean region:/ });
+ expect(regions.length).toBeGreaterThan(1);
+ expect(regions.filter((r) => r.getAttribute('aria-pressed') === 'true')).toHaveLength(1);
+
+ fireEvent.click(regions[1]);
+
+ expect(regions[0]).toHaveAttribute('aria-pressed', 'false');
+ expect(regions[1]).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('names each region rather than announcing it as plain text', () => {
+ render();
+ openTab(/Regions/);
+ const regions = screen.getAllByRole('button', { name: /^Ocean region:/ });
+
+ for (const region of regions) {
+ expect(region.getAttribute('aria-label')).toMatch(/average pH/);
+ }
+ });
+
+ it('puts every region in the tab order', () => {
+ render();
+ openTab(/Regions/);
+ for (const region of screen.getAllByRole('button', { name: /^Ocean region:/ })) {
+ region.focus();
+ expect(region).toHaveFocus();
+ }
+ });
+});
diff --git a/src/components/ui/PressableCard.jsx b/src/components/ui/PressableCard.jsx
new file mode 100644
index 0000000..0506b45
--- /dev/null
+++ b/src/components/ui/PressableCard.jsx
@@ -0,0 +1,133 @@
+import PropTypes from 'prop-types';
+
+/**
+ * Card and tile controls that a keyboard can actually operate.
+ *
+ * Five dashboards implemented their primary controls as `
`. A mouse
+ * could operate them; nothing else could. They were not in the tab order, Enter and Space
+ * did nothing, and a screen reader announced them as plain text with no hint that they did
+ * anything at all — a WCAG 2.1.1 (Keyboard) failure, and a 4.1.2 (Name, Role, Value) one
+ * for good measure. In each case the clickable div *was* the feature: selecting a weather
+ * condition, an ocean region or a CO2 scenario, or expanding a card to reveal the health
+ * guidance behind it. See #1140.
+ *
+ * Both components below render a real `