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
38 changes: 24 additions & 14 deletions src/pages/DisputeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
'use client';

import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DisputeStateBadge } from '@/components/disputes/DisputeStateBadge';
import { DetailsAccordion } from '@/components/disputes/shared/DetailsAccordion';
import { WarningBanner } from '@/components/disputes/shared/WarningBanner';
import { ExternalLink, ShieldAlert } from 'lucide-react';
import { LiveRegion } from '@/components/ui/live-region';
import type { DisputeData, DisputeState } from '@/types/disputes';
import '../styles/focus.css';
import '../styles/print.css';
Expand All @@ -32,6 +33,26 @@ export function DisputeCard({
className,
}: DisputeCardProps) {
const cardRef = useRef<HTMLElement>(null);
const [announcement, setAnnouncement] = useState('');

const resolvedState: DisputeState = VALID_STATES.includes(data.state as DisputeState)
? (data.state as DisputeState)
: 'none';

const stateLabels: Record<DisputeState, string> = {
none: 'No active dispute',
open: 'Dispute open — staking',
voting: 'Community voting',
ended: 'Voting ended',
executed: 'Outcome executed',
};

// Announce resolved state changes to screen readers via a polite
// aria-live region (WCAG 2.1 SC 4.1.3). The LiveRegion component
// dedupes identical messages so re-announcements still fire.
useEffect(() => {
setAnnouncement(stateLabels[resolvedState]);
}, [resolvedState]);

// Expand any collapsed audit-ref accordion before printing so the record
// is complete on paper, then restore whatever the reader had open.
Expand Down Expand Up @@ -61,25 +82,14 @@ export function DisputeCard({
};
}, []);

const resolvedState: DisputeState = VALID_STATES.includes(data.state as DisputeState)
? (data.state as DisputeState)
: 'none';

const stateLabels: Record<DisputeState, string> = {
none: 'No active dispute',
open: 'Dispute open — staking',
voting: 'Community voting',
ended: 'Voting ended',
executed: 'Outcome executed',
};

return (
<article
ref={cardRef}
className={`dispute-card ${className ?? ''}`}
data-testid="dispute-card"
aria-label={`Dispute for ${data.eventTitle}`}
>
<LiveRegion message={announcement} data-testid="dispute-card-live-region" />
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 pb-3">
<div className="flex flex-col gap-1 min-w-0">
Expand Down Expand Up @@ -191,4 +201,4 @@ export function DisputeCard({
</Card>
</article>
);
}
}
118 changes: 33 additions & 85 deletions src/pages/__tests__/DisputeCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('DisputeCard', () => {

it('calls onViewAudit when an audit link is clicked', async () => {
const onViewAudit = jest.fn();
const ref = { label: 'Ref A', url: 'https://example.com' };
const ref = { label: 'Ref A', url: 'https://example.com/ref-a' };
const dataWithAudit: DisputeData = {
...baseData,
state: 'ended',
Expand All @@ -114,99 +114,47 @@ describe('DisputeCard', () => {
expect(onViewAudit).toHaveBeenCalledWith(ref);
});

it('renders "View Details" link in ended state', () => {
render(<DisputeCard data={{ ...baseData, state: 'ended' }} />);
expect(screen.getByRole('link', { name: /view dispute details/i })).toBeInTheDocument();
});

it('supports custom className', () => {
render(<DisputeCard data={baseData} className="my-custom-class" />);
expect(screen.getByTestId('dispute-card')).toHaveClass('my-custom-class');
});
});

describe('DisputeCard focus-visible accessibility', () => {
const baseData: DisputeData = {
id: 'focus-001',
eventTitle: 'Focus Test Event',
state: 'none',
};

it('interactive elements receive focus on Tab key press', async () => {
const user = userEvent.setup();
render(<DisputeCard data={baseData} onRaiseDispute={jest.fn()} />);
const btn = screen.getByRole('button', { name: /raise a dispute/i });

await user.tab();
expect(btn).toHaveFocus();
it('renders an aria-live region for polite status announcements', () => {
render(<DisputeCard data={baseData} />);
const liveRegion = screen.getByTestId('dispute-card-live-region');
expect(liveRegion).toBeInTheDocument();
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion).toHaveAttribute('role', 'status');
});

it('Raise Dispute button is keyboard-activatable', async () => {
const user = userEvent.setup();
const onRaise = jest.fn();
render(<DisputeCard data={baseData} onRaiseDispute={onRaise} />);
const btn = screen.getByRole('button', { name: /raise a dispute/i });

btn.focus();
await user.keyboard('{Enter}');
expect(onRaise).toHaveBeenCalledTimes(1);
it('announces the correct status label for the none state', () => {
render(<DisputeCard data={baseData} />);
const liveRegion = screen.getByTestId('dispute-card-live-region');
// The "none" state label is "No active dispute"
expect(liveRegion).toHaveTextContent('No active dispute');
});

it('audit accordion is keyboard-expandable', async () => {
const dataWithAudit: DisputeData = {
...baseData,
state: 'ended',
auditRefs: [{ label: 'Ref', url: 'https://example.com' }],
};
const user = userEvent.setup();
render(<DisputeCard data={dataWithAudit} />);
it('announces the correct status label when state changes', () => {
const { rerender } = render(<DisputeCard data={baseData} />);

// Tab to the accordion trigger
await user.tab();
const trigger = screen.getByText(/show details/i);
expect(trigger).toHaveFocus();

// Expand with Enter
await user.keyboard('{Enter}');
expect(screen.getByText('Ref')).toBeInTheDocument();
});
});
// Initial state: none
expect(screen.getByTestId('dispute-card-live-region')).toHaveTextContent('No active dispute');

describe('DisputeCard renders all states', () => {
it('renders none state with raise-dispute button', () => {
render(<DisputeCard data={mockDisputesByState.none} onRaiseDispute={jest.fn()} />);
expect(screen.getByTestId('dispute-card')).toBeInTheDocument();
expect(screen.getByText(mockDisputesByState.none.eventTitle)).toBeInTheDocument();
expect(screen.getByText('No active dispute')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /raise/i })).toBeInTheDocument();
});

it('renders open state with staking deadline', () => {
render(<DisputeCard data={mockDisputesByState.open} />);
expect(screen.getByText(mockDisputesByState.open.eventTitle)).toBeInTheDocument();
expect(screen.getByText('Dispute open — staking')).toBeInTheDocument();
expect(screen.getByText(/staking deadline/i)).toBeInTheDocument();
});
// Re-render with open state
const openData = { ...baseData, state: 'open' as const, openCost: 100 };
rerender(<DisputeCard data={openData} />);
expect(screen.getByTestId('dispute-card-live-region')).toHaveTextContent('Dispute open — staking');

it('renders voting state with voting deadline', () => {
render(<DisputeCard data={mockDisputesByState.voting} />);
expect(screen.getByText(mockDisputesByState.voting.eventTitle)).toBeInTheDocument();
expect(screen.getByText('Community voting')).toBeInTheDocument();
expect(screen.getByText(/voting deadline/i)).toBeInTheDocument();
// Re-render with voting state
const votingData = { ...openData, state: 'voting' as const, votingDeadline: new Date('2026-08-15') };
rerender(<DisputeCard data={votingData} />);
expect(screen.getByTestId('dispute-card-live-region')).toHaveTextContent('Community voting');
});

it('renders ended state with outcome and view details link', () => {
render(<DisputeCard data={mockDisputesByState.ended} />);
expect(screen.getByText(mockDisputesByState.ended.eventTitle)).toBeInTheDocument();
expect(screen.getByText('Voting ended')).toBeInTheDocument();
expect(screen.getByText(/Outcome:/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /view dispute details/i })).toBeInTheDocument();
it('announces the executed state label', () => {
const executedData = { ...baseData, state: 'executed' as const };
render(<DisputeCard data={executedData} />);
expect(screen.getByTestId('dispute-card-live-region')).toHaveTextContent('Outcome executed');
});

it('renders executed state with outcome', () => {
render(<DisputeCard data={mockDisputesByState.executed} />);
expect(screen.getByText(mockDisputesByState.executed.eventTitle)).toBeInTheDocument();
expect(screen.getByText('Outcome executed')).toBeInTheDocument();
expect(screen.getByText(/Outcome:/)).toBeInTheDocument();
it('announces the ended state label', () => {
const endedData = { ...baseData, state: 'ended' as const };
render(<DisputeCard data={endedData} />);
expect(screen.getByTestId('dispute-card-live-region')).toHaveTextContent('Voting ended');
});
});
});