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
11 changes: 9 additions & 2 deletions src/components/AIPollutionForecast.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useMemo, useCallback } from "react";

Check warning on line 1 in src/components/AIPollutionForecast.jsx

View workflow job for this annotation

GitHub Actions / Lint

'useCallback' is defined but never used. Allowed unused vars must match /^_/u
import { SelectionButton } from "./ui/PressableCard";

// ─── Forecast Data Generator ────────────────────────────────────────────────
const POLLUTANTS = [
Expand Down Expand Up @@ -32,7 +33,7 @@
{ label: "Storm", icon: "⛈️", dispersal: 1.5 },
];

const SEASONS = ["Spring", "Summer", "Autumn", "Winter"];

Check warning on line 36 in src/components/AIPollutionForecast.jsx

View workflow job for this annotation

GitHub Actions / Lint

'SEASONS' is assigned a value but never used. Allowed unused vars must match /^_/u

function generateHourlyForecast(baseAqi = 85) {
const now = new Date();
Expand Down Expand Up @@ -883,7 +884,7 @@
<div style={styles.card}>
<div style={styles.cardTitle}><span>🧪</span> Pollutant Forecast Comparison</div>
<svg viewBox="0 0 800 300" style={{ width: "100%", height: "320px" }}>
{POLLUTANTS.map((p, pi) => {

Check warning on line 887 in src/components/AIPollutionForecast.jsx

View workflow job for this annotation

GitHub Actions / Lint

'pi' is defined but never used. Allowed unused args must match /^_/u
const data = hourly.slice(0, 48).map(h => (h[p.id] / p.max) * 100);
const step = 760 / (data.length - 1);
const pts = data.map((v, i) => `${40 + i * step},${260 - v * 2.2}`).join(" ");
Expand Down Expand Up @@ -1040,13 +1041,19 @@
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))", gap: "12px" }}>
{WEATHER_CONDITIONS.map((w, i) => (
<div key={i} style={styles.weatherTile(selectedWeather === i)} onClick={() => setSelectedWeather(selectedWeather === i ? null : i)}>
<SelectionButton
key={i}
selected={selectedWeather === i}
onSelect={() => setSelectedWeather(selectedWeather === i ? null : i)}
label={`Weather condition: ${w.label}, dispersal ${w.dispersal.toFixed(1)}x`}
style={styles.weatherTile(selectedWeather === i)}
>
<div style={{ fontSize: "1.5rem", marginBottom: "4px" }}>{w.icon}</div>
<div style={{ fontSize: "0.8rem", fontWeight: 600, color: "#e2e8f0" }}>{w.label}</div>
<div style={{ fontSize: "0.7rem", color: w.dispersal > 1 ? "#22c55e" : w.dispersal > 0.5 ? "#eab308" : "#ef4444", marginTop: "4px" }}>
Dispersal: {w.dispersal.toFixed(1)}x
</div>
</div>
</SelectionButton>
))}
</div>
</div>
Expand Down
28 changes: 21 additions & 7 deletions src/components/HealthImpactDashboard.jsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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 <circle r="40"> 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";

Expand Down Expand Up @@ -182,24 +187,33 @@ 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 (
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-700/40 cursor-pointer hover:border-slate-600/60 transition-all"
onClick={() => setExpanded(!expanded)}>
<div className="flex items-center justify-between">
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-700/40 hover:border-slate-600/60 transition-all">
{/* 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. */}
<DisclosureButton
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
controls={panelId}
label={`${pollutant.pollutant} health effects, exposure risk ${risk}%`}
className="flex items-center justify-between"
>
<div>
<p className="text-sm font-semibold text-slate-200">{pollutant.pollutant}</p>
<p className="text-[10px] text-slate-500">Exposure risk: <span style={{ color: riskColor }}>{risk}%</span></p>
</div>
<div className="h-2 w-20 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${risk}%`, backgroundColor: riskColor }} />
</div>
</div>
</DisclosureButton>

{expanded && (
<div className="mt-3 pt-3 border-t border-slate-700/30 space-y-3">
<div id={panelId} className="mt-3 pt-3 border-t border-slate-700/30 space-y-3">
<div>
<p className="text-xs font-medium text-slate-300 mb-1">Short-term Effects</p>
<ul className="space-y-0.5">
Expand Down
17 changes: 13 additions & 4 deletions src/components/HealthRiskCards.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useId, useState } from 'react';
import { motion } from 'framer-motion';
import {
TrendingUp,
Expand Down Expand Up @@ -27,6 +27,7 @@ import {
getAQIBand,
getRiskScore,
} from './healthRiskTypes';
import { DisclosureButton } from './ui/PressableCard';

/**
* Stat card with icon, value, and optional trend.
Expand Down Expand Up @@ -228,6 +229,7 @@ const VulnerableGroupCard = ({ group, delay = 0 }) => {
*/
const PollutantEffectCard = ({ pollutant, delay = 0 }) => {
const [expanded, setExpanded] = useState(false);
const panelId = useId();
const riskColor = pollutant.riskLevel === 'high' ? '#ef4444' : pollutant.riskLevel === 'moderate' ? '#f59e0b' : '#22c55e';

return (
Expand All @@ -243,7 +245,13 @@ const PollutantEffectCard = ({ pollutant, delay = 0 }) => {
borderLeft: `3px solid ${riskColor}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
<DisclosureButton
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
controls={panelId}
label={`${pollutant.label} health risks, ${pollutant.riskLevel} risk`}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<div style={{ flex: 1 }}>
<p style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--text-primary, #1e293b)', margin: 0 }}>{pollutant.label}</p>
<p style={{ fontSize: '0.6rem', color: 'var(--muted, #94a3b8)', margin: '0.1rem 0 0' }}>
Expand All @@ -256,10 +264,11 @@ const PollutantEffectCard = ({ pollutant, delay = 0 }) => {
}}>
{pollutant.riskLevel.toUpperCase()}
</span>
<ChevronDown size={14} color="#94a3b8" style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</div>
<ChevronDown aria-hidden="true" size={14} color="#94a3b8" style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</DisclosureButton>
{expanded && (
<motion.div
id={panelId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
style={{ marginTop: '0.75rem', borderTop: '1px solid #f1f5f9', paddingTop: '0.75rem' }}
Expand Down
25 changes: 18 additions & 7 deletions src/components/OceanAcidificationMonitor.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from "react";
import { SelectionButton } from "./ui/PressableCard";

// ─── Constants ──────────────────────────────────────────────────────────────
const OCEAN_REGIONS = [
Expand Down Expand Up @@ -359,9 +360,14 @@ export default function OceanAcidificationMonitor() {
{OCEAN_REGIONS.map((r, i) => {
const cat = pH_SCALE.find(c => r.avgPh >= c.min && c.max > r.avgPh) || pH_SCALE[4];
return (
<div key={i} style={{ ...s.regionRow, borderColor: selectedRegion === i ? cat.color : "#1e3a5f", background: selectedRegion === i ? `${cat.color}10` : undefined }}
onClick={() => setSelectedRegion(i)}>
<span style={{ width: "24px", textAlign: "center" }}>{i === selectedRegion ? "🔵" : "⚪"}</span>
<SelectionButton
key={i}
selected={selectedRegion === i}
onSelect={() => setSelectedRegion(i)}
label={`Ocean region: ${r.name}, average pH ${r.avgPh}`}
style={{ ...s.regionRow, borderColor: selectedRegion === i ? cat.color : "#1e3a5f", background: selectedRegion === i ? `${cat.color}10` : undefined }}
>
<span aria-hidden="true" style={{ width: "24px", textAlign: "center" }}>{i === selectedRegion ? "🔵" : "⚪"}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e2e8f0", fontSize: "0.9rem" }}>{r.name}</div>
<div style={{ fontSize: "0.75rem", color: "#64748b" }}>{r.lat > 0 ? `${r.lat}°N` : `${Math.abs(r.lat)}°S`} · {Math.abs(r.lon)}°{r.lon > 0 ? "E" : "W"}</div>
Expand All @@ -370,7 +376,7 @@ export default function OceanAcidificationMonitor() {
<div style={{ fontWeight: 700, color: cat.color, fontSize: "1.1rem" }}>{r.avgPh}</div>
<div style={{ fontSize: "0.7rem", color: "#ef4444" }}>{r.trend}/yr</div>
</div>
</div>
</SelectionButton>
);
})}
</div>
Expand Down Expand Up @@ -615,8 +621,13 @@ export default function OceanAcidificationMonitor() {
{/* Scenario details */}
<div style={s.grid2}>
{CO2_SCENARIOS.map(sc => (
<div key={sc.id} style={{ ...s.insightCard, borderLeft: `4px solid ${sc.color}`, cursor: "pointer", opacity: selectedScenario === sc.id ? 1 : 0.6 }}
onClick={() => setSelectedScenario(sc.id)}>
<SelectionButton
key={sc.id}
selected={selectedScenario === sc.id}
onSelect={() => setSelectedScenario(sc.id)}
label={`CO2 scenario: ${sc.name}, peaking at ${sc.peakPpm} ppm in ${sc.peakYear}`}
style={{ ...s.insightCard, borderLeft: `4px solid ${sc.color}`, cursor: "pointer", opacity: selectedScenario === sc.id ? 1 : 0.6 }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700, color: sc.color }}>{sc.name}</span>
{selectedScenario === sc.id && <span style={s.badge(sc.color)}>Active</span>}
Expand All @@ -625,7 +636,7 @@ export default function OceanAcidificationMonitor() {
<span>Peak: {sc.peakPpm} ppm ({sc.peakYear})</span>
<span>End: {sc.endPpm} ppm</span>
</div>
</div>
</SelectionButton>
))}
</div>
</div>
Expand Down
20 changes: 14 additions & 6 deletions src/components/ReportCards.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
TrendingDown,
TrendingUp
} from 'lucide-react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { formatReportTimestamp } from '../utils/localDay';

import { COMPLIANCE_STATUSES, formatCurrency } from './reportTypes';
import { DisclosureButton } from './ui/PressableCard';

/**
* Stat card with icon, value, label, and trend.
Expand Down Expand Up @@ -123,6 +124,7 @@ const ReportItemCard = ({ report, delay = 0, isSelected, onSelect, timeZone }) =
*/
const ComplianceCard = ({ item, delay = 0 }) => {
const [expanded, setExpanded] = useState(false);
const panelId = useId();
const statusConfig = COMPLIANCE_STATUSES[item.status] || COMPLIANCE_STATUSES.pending_review;

return (
Expand All @@ -135,8 +137,14 @@ const ComplianceCard = ({ item, delay = 0 }) => {
borderRadius: '0.75rem', padding: '0.75rem 1rem', borderLeft: `3px solid ${statusConfig.color}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
<span style={{ fontSize: '1rem' }}>{statusConfig.icon}</span>
<DisclosureButton
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
controls={panelId}
label={`${item.label} compliance detail, ${item.percentOfStandard || 'unknown'}% of standard`}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<span aria-hidden="true" style={{ fontSize: '1rem' }}>{statusConfig.icon}</span>
<div style={{ flex: 1 }}>
<p style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--text-primary, #1e293b)', margin: 0 }}>{item.label}</p>
<p style={{ fontSize: '0.6rem', color: 'var(--muted, #94a3b8)', margin: '0.1rem 0 0' }}>
Expand All @@ -147,10 +155,10 @@ const ComplianceCard = ({ item, delay = 0 }) => {
<span style={{ fontSize: '0.8rem', fontWeight: 800, color: statusConfig.color }}>{item.percentOfStandard || '—'}%</span>
<p style={{ fontSize: '0.5rem', color: '#94a3b8', margin: 0 }}>of standard</p>
</div>
<ChevronDown size={14} color="#94a3b8" style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</div>
<ChevronDown aria-hidden="true" size={14} color="#94a3b8" style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</DisclosureButton>
{expanded && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #f1f5f9' }}>
<motion.div id={panelId} initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #f1f5f9' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.55rem', padding: '0.15rem 0.4rem', background: '#f8fafc', borderRadius: '9999px', color: '#64748b' }}>
Trend: {item.trend === 'improving' ? '📈' : item.trend === 'worsening' ? '📉' : '➡️'} {item.trend}
Expand Down
122 changes: 122 additions & 0 deletions src/components/keyboardCardControls.test.jsx
Original file line number Diff line number Diff line change
@@ -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 `<div onClick>`: 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 `<div>` 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(<HealthImpactDashboard currentAQI={165} cityName="Delhi" />);
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(<HealthImpactDashboard currentAQI={165} cityName="Delhi" />);
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(<HealthImpactDashboard currentAQI={165} cityName="Delhi" />);
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(<AIPollutionForecast />);
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(<AIPollutionForecast />);
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(<OceanAcidificationMonitor />);
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(<OceanAcidificationMonitor />);
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(<OceanAcidificationMonitor />);
openTab(/Regions/);
for (const region of screen.getAllByRole('button', { name: /^Ocean region:/ })) {
region.focus();
expect(region).toHaveFocus();
}
});
});
Loading
Loading