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
18 changes: 18 additions & 0 deletions src/components/HealthFactorChart.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}

.hf-chart__badge-glyph {
font-size: 0.65rem;
line-height: 1;
}

.hf-chart__badge--safe {
Expand All @@ -60,6 +68,16 @@
width: 100%;
max-width: 100%;
height: auto;
border-radius: 4px;
}

.hf-chart__svg:focus {
outline: none;
}

.hf-chart__svg:focus-visible {
outline: 2px solid var(--focus-ring-color, #58a6ff);
outline-offset: 3px;
}

.hf-chart__line {
Expand Down
36 changes: 36 additions & 0 deletions src/components/HealthFactorChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,40 @@ describe('HealthFactorChart', () => {
screen.getByText(/no health-factor history yet for empty line/i),
).toBeInTheDocument();
});

it('includes band text (not color alone) and keyboard-focusable SVG', () => {
render(
<HealthFactorChart data={data} current={1.0} lineName="Risk line" />,
);
const badge = screen.getByTestId('hf-band-badge');
expect(badge).toHaveAttribute('data-band', 'risk');
expect(badge).toHaveTextContent(/At risk/i);
const svg = screen.getByTestId('hf-chart-svg');
expect(svg).toHaveAttribute('tabindex', '0');
});

it('SR history table includes a Band column for every sample', () => {
render(
<HealthFactorChart data={data} current={1.8} lineName="Builder line" />,
);
expect(screen.getByRole('columnheader', { name: /^band$/i })).toBeInTheDocument();
expect(screen.getAllByText('Safe').length).toBeGreaterThan(0);
expect(screen.getAllByText('Caution').length).toBeGreaterThan(0);
});

it.each([
[2.5, 'safe', /Safe/i],
[1.5, 'caution', /Caution/i],
[1.0, 'risk', /At risk/i],
] as const)('boundary current=%s maps to band %s', (current, band, label) => {
render(
<HealthFactorChart
data={[{ date: '2026-01-01', value: current }]}
current={current}
lineName="Boundary"
/>,
);
expect(screen.getByTestId('hf-band-badge')).toHaveAttribute('data-band', band);
expect(screen.getByTestId('hf-band-badge')).toHaveTextContent(label);
});
});
25 changes: 19 additions & 6 deletions src/components/HealthFactorChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,12 @@ export function HealthFactorChart({
<span
className={`hf-chart__badge hf-chart__badge--${band} tabular-nums`}
title={`Band: ${healthBandLabel(band)}`}
data-testid="hf-band-badge"
data-band={band}
>
<span className="hf-chart__badge-glyph" aria-hidden="true">
{band === 'safe' ? '●' : band === 'caution' ? '▲' : '■'}
</span>
{current.toFixed(2)} · {healthBandLabel(band)}
</span>
</figcaption>
Expand All @@ -171,13 +176,16 @@ export function HealthFactorChart({
role="img"
aria-labelledby={`${titleId} ${descId}`}
className="hf-chart__svg"
tabIndex={0}
data-testid="hf-chart-svg"
>
<title id={titleId}>
Health factor trend for {lineName}
</title>
<desc id={descId}>
Current health factor {current.toFixed(2)} ({healthBandLabel(band)}).
Range {min.toFixed(2)} to {max.toFixed(2)} across {data.length} samples.
Press Tab to focus this chart; band is also listed in the data table below.
</desc>

{/* Caution threshold guide at HF = 1.25 */}
Expand Down Expand Up @@ -217,15 +225,20 @@ export function HealthFactorChart({
<tr>
<th scope="col">Date</th>
<th scope="col">Health factor</th>
<th scope="col">Band</th>
</tr>
</thead>
<tbody>
{data.map((p) => (
<tr key={p.date}>
<td>{p.date}</td>
<td>{p.value.toFixed(2)}</td>
</tr>
))}
{data.map((p) => {
const pointBand = healthBand(p.value);
return (
<tr key={p.date}>
<td>{p.date}</td>
<td>{p.value.toFixed(2)}</td>
<td>{healthBandLabel(pointBand)}</td>
</tr>
);
})}
</tbody>
</table>
</figure>
Expand Down
34 changes: 34 additions & 0 deletions src/components/RiskGauge.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,40 @@

/* ── Meta row (Trend / Last Updated) ────────────────────────────────────── */

.risk-gauge-band-chip {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin: 0.5rem 0 0.75rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-semibold, 600);
color: var(--text);
text-align: center;
}

.risk-gauge-band-chip__glyph {
font-size: 0.7rem;
line-height: 1;
}

.risk-gauge-band-chip[data-band='high'] .risk-gauge-band-chip__glyph {
color: var(--success);
}

.risk-gauge-band-chip[data-band='medium'] .risk-gauge-band-chip__glyph {
color: var(--warning);
}

.risk-gauge-band-chip[data-band='low'] .risk-gauge-band-chip__glyph {
color: var(--error);
}

.risk-gauge-band-chip:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}

.risk-meta {
display: flex;
gap: var(--space-6, 1.5rem);
Expand Down
25 changes: 25 additions & 0 deletions src/components/RiskGauge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,3 +644,28 @@ describe('in-app reduced-motion toggle ([data-motion="reduced"])', () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[^]*?\.risk-gauge-fill/);
});
});

describe('RiskGauge a11y text equivalents (issue #920)', () => {
it.each([
[100, 'high', /High score zone \(70–100\)/],
[70, 'high', /High score zone \(70–100\)/],
[69, 'medium', /Medium score zone \(50–69\)/],
[50, 'medium', /Medium score zone \(50–69\)/],
[49, 'low', /Low score zone \(0–49\)/],
[0, 'low', /Low score zone \(0–49\)/],
] as const)('score %s exposes visible band chip data-band=%s', (score, band, label) => {
renderGauge({ score });
const chip = screen.getByTestId('risk-gauge-band-chip');
expect(chip).toHaveAttribute('data-band', band);
expect(chip).toHaveTextContent(label);
expect(chip).toHaveAttribute('role', 'status');
});

it('exposes Trend and Last Updated in the accessibility tree (not aria-hidden)', () => {
renderGauge({ trend: 'declining', lastUpdated: '2025-03-01T00:00:00Z' });
const group = screen.getByRole('group', { name: /risk score details/i });
expect(group).toBeInTheDocument();
expect(group).not.toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('Declining')).toBeInTheDocument();
});
});
32 changes: 30 additions & 2 deletions src/components/RiskGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,10 @@ export function RiskGauge({
const activeSector: RiskSector =
normalizedScore >= 70 ? 'high' : normalizedScore >= 50 ? 'medium' : 'low';

const activeSectorDef = SECTORS.find((s) => s.id === activeSector)!;
/** Visible (non-color) band chip — WCAG 1.4.1 text equivalent for the arc fill. */
const bandChipLabel = `${activeSectorDef.label} (${activeSectorDef.range})`;

function handleSectorActivate(sector: RiskSector) {
const sectorDef = SECTORS.find((s) => s.id === sector);
if (sectorDef) {
Expand Down Expand Up @@ -582,14 +586,38 @@ export function RiskGauge({
</text>
</svg>

<div className="risk-meta" aria-hidden="true">
{/*
Visible band chip — text + range, not color alone (WCAG 1.4.1).
Complements the colored arc so keyboard / low-vision users can read
the active band without relying on stroke hue.
*/}
<p
className="risk-gauge-band-chip"
data-band={activeSector}
data-testid="risk-gauge-band-chip"
role="status"
>
<span className="risk-gauge-band-chip__glyph" aria-hidden="true">
{activeSector === 'high' ? '●' : activeSector === 'medium' ? '▲' : '■'}
</span>
<span className="risk-gauge-band-chip__text">{bandChipLabel}</span>
</p>

{/*
Meta row kept in the accessibility tree (not aria-hidden) so keyboard
users inspecting the widget get Trend + Last Updated as text, not only
via the live region / SVG title.
*/}
<div className="risk-meta" role="group" aria-label="Risk score details">
<div className="risk-meta-item">
<span className="rm-label">Trend</span>
<span
className="rm-value"
data-trend={trend}
style={{ color: `var(--${trend === 'improving' ? 'success' : trend === 'declining' ? 'error' : 'muted'})` }}
>
{trendArrow} {trendLabel}
<span aria-hidden="true">{trendArrow} </span>
{trendLabel}
</span>
</div>
<div className="risk-meta-item">
Expand Down