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
23 changes: 23 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Problem
Resolves #295

Field operators using assistive technology may miss status updates because there are no `aria-live` regions set up in the app. Status transitions in campaigns and claim receipts need to be explicitly announced to screen readers.

## What was implemented
- Created a `<LiveRegion>` component in `app/frontend/src/components/LiveRegion.tsx` that uses `aria-live="polite"` and `role="status"` to announce message changes. It includes a small debounce queue (50ms) to ensure consecutive rapid updates are announced clearly.
- Created `getStatusTransitionMessage` utility in `app/frontend/src/lib/status-messages.ts` to map raw status values to verbose, human-readable text (e.g. "Campaign status changed from Active to Paused.").
- Integrated `<LiveRegion>` into `app/frontend/src/app/[locale]/campaigns/page.tsx` and `app/frontend/src/app/[locale]/claim-receipt/page.tsx`, storing previous state with a `useRef` and detecting status transitions to trigger announcements.
- Added `jest-axe` tests for `<LiveRegion>` in `app/frontend/test/live-region.spec.tsx` to verify standard accessibility compliance and ensure text transitions correctly.

## Assumptions made during Phase 1
- `claim-receipt/page.tsx` is the intended target for the "claim-status / claim detail page" mentioned in the requirements, as it's the primary location for viewing a claim's status.
- Campaign transitions happen via optimistic mutation and derive status from the local component list, so `useRef` was used to detect status changes cleanly without modifying the global store or mutation logic.

## How to manually verify
1. Run the application (`pnpm run dev`).
2. Turn on a screen reader (VoiceOver, NVDA, or JAWS).
3. Navigate to the NGO Campaigns page and perform an action like "Pause" or "Resume" on a campaign. The screen reader should announce "Campaign status changed from Active to Paused."
4. Inspect the DOM to verify the visually hidden `aria-live` region updates its text content.

## Follow-up work recommended
- Extend this pattern to other dynamic status indicators in the application (like `VerificationReviewPage` queues or `ActivityCenter` toasts) to ensure universal accessibility coverage.
18 changes: 18 additions & 0 deletions app/frontend/src/app/[locale]/campaigns/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
getUserRole,
getUserRoleLabel,
} from '@/lib/user-role';
import { LiveRegion } from '@/components/LiveRegion';
import { getStatusTransitionMessage } from '@/lib/status-messages';
import type { CampaignStatus } from '@/types/campaign';

const statusStyles: Record<CampaignStatus, string> = {
Expand Down Expand Up @@ -53,6 +55,21 @@ export default function CampaignsPage() {
const [expiry, setExpiry] = useState('');
const [formMessage, setFormMessage] = useState<string | null>(null);

const previousCampaignsRef = React.useRef(campaigns);
const [announcement, setAnnouncement] = useState('');

React.useEffect(() => {
if (campaigns.length > 0 && previousCampaignsRef.current.length > 0) {
for (const campaign of campaigns) {
const prev = previousCampaignsRef.current.find(c => c.id === campaign.id);
if (prev && prev.status !== campaign.status) {
setAnnouncement(getStatusTransitionMessage('Campaign', prev.status, campaign.status));
}
}
}
previousCampaignsRef.current = campaigns;
}, [campaigns]);

function updateParam(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
Expand Down Expand Up @@ -150,6 +167,7 @@ export default function CampaignsPage() {

return (
<div className="min-h-screen bg-linear-to-b from-background to-gray-50 p-6 dark:to-gray-950">
<LiveRegion message={announcement} />
<main className="container mx-auto space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-4xl font-bold">NGO Campaigns</h1>
Expand Down
13 changes: 13 additions & 0 deletions app/frontend/src/app/[locale]/claim-receipt/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ClaimReceipt, ClaimReceiptData } from '@/components/ClaimReceipt';
import { AlertCircle, Loader2 } from 'lucide-react';
import { LiveRegion } from '@/components/LiveRegion';
import { getStatusTransitionMessage } from '@/lib/status-messages';

export default function ClaimReceiptPage() {
const router = useRouter();
Expand All @@ -13,6 +15,16 @@ export default function ClaimReceiptPage() {
const [claim, setClaim] = useState<ClaimReceiptData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const previousClaimRef = React.useRef<ClaimReceiptData | null>(null);
const [announcement, setAnnouncement] = useState('');

useEffect(() => {
if (previousClaimRef.current && claim && previousClaimRef.current.status !== claim.status) {
setAnnouncement(getStatusTransitionMessage('Claim', previousClaimRef.current.status, claim.status));
}
previousClaimRef.current = claim;
}, [claim]);

useEffect(() => {
if (!claimId) {
Expand Down Expand Up @@ -78,6 +90,7 @@ export default function ClaimReceiptPage() {

return (
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 py-8 px-4">
<LiveRegion message={announcement} />
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
Expand Down
49 changes: 49 additions & 0 deletions app/frontend/src/components/LiveRegion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react';

export interface LiveRegionProps {
/** The message to announce. If it changes, the new message will be announced. */
message: string;
/** Whether the announcement should be assertive (interrupting) or polite (queued). Default: polite */
urgency?: 'polite' | 'assertive';
}

/**
* A visually hidden component that announces status changes to assistive technologies.
* Use this to ensure screen reader users are notified of dynamic content changes.
*/
export const LiveRegion: React.FC<LiveRegionProps> = ({ message, urgency = 'polite' }) => {
const [announcedMessage, setAnnouncedMessage] = useState('');

useEffect(() => {
if (!message) return;

// Add a small delay to ensure consecutive rapid updates are announced
// and to ensure it's a distinct DOM update.
const timer = setTimeout(() => {
setAnnouncedMessage(message);
}, 50);

return () => clearTimeout(timer);
}, [message]);

return (
<div
aria-live={urgency}
aria-atomic="true"
role={urgency === 'assertive' ? 'alert' : 'status'}
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{announcedMessage}
</div>
);
};
18 changes: 18 additions & 0 deletions app/frontend/src/lib/status-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function formatStatus(status: string): string {
const map: Record<string, string> = {
requested: 'Requested',
verified: 'Verified',
approved: 'Approved',
disbursed: 'Disbursed',
archived: 'Archived',
draft: 'Draft',
active: 'Active',
paused: 'Paused',
completed: 'Completed',
};
return map[status] ?? status.charAt(0).toUpperCase() + status.slice(1);
}

export function getStatusTransitionMessage(entityType: 'Campaign' | 'Claim', oldStatus: string, newStatus: string): string {
return `${entityType} status changed from ${formatStatus(oldStatus)} to ${formatStatus(newStatus)}.`;
}
49 changes: 49 additions & 0 deletions app/frontend/test/live-region.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { axe, toHaveNoViolations } from 'jest-axe';
import { LiveRegion } from '../src/components/LiveRegion';

expect.extend(toHaveNoViolations);

describe('LiveRegion', () => {
it('renders without accessibility violations', async () => {
const { container } = render(<LiveRegion message="Initial message" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('updates text content when message changes', async () => {
jest.useFakeTimers();

const { getByRole, rerender } = render(<LiveRegion message="Pending" />);

// Initially empty due to timeout
const region = getByRole('status');
expect(region).toHaveTextContent('');

// Advance timer
act(() => {
jest.advanceTimersByTime(50);
});
expect(region).toHaveTextContent('Pending');

rerender(<LiveRegion message="Approved" />);

act(() => {
jest.advanceTimersByTime(50);
});
expect(region).toHaveTextContent('Approved');

jest.useRealTimers();
});

it('sets appropriate aria attributes', () => {
const { container } = render(<LiveRegion message="Test" urgency="assertive" />);
const region = container.firstChild as HTMLElement;

expect(region).toHaveAttribute('aria-live', 'assertive');
expect(region).toHaveAttribute('aria-atomic', 'true');
expect(region).toHaveAttribute('role', 'alert');
});
});
Loading