Skip to content

Commit 415081f

Browse files
authored
Merge pull request #280 from soomtochukwu/fix/accessibility-audits
feat: implement accessibility audits for trade modal and search results
2 parents bf61067 + 49aed6a commit 415081f

4 files changed

Lines changed: 114 additions & 25 deletions

File tree

‎src/components/common/StickyFilterBar.tsx‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ReactNode } from 'react';
1+
import { useEffect, useState, type ReactNode } from 'react';
22
import { cn } from '@/lib/utils';
33
import { Button } from '@/components/ui/button';
44
import { X } from 'lucide-react';
@@ -24,11 +24,34 @@ const StickyFilterBar: React.FC<StickyFilterBarProps> = ({
2424
onReset,
2525
showReset,
2626
}) => {
27+
const [announcedCount, setAnnouncedCount] = useState<number | undefined>(
28+
resultCount
29+
);
30+
31+
// Debounce result count announcements so screen readers don't stutter
32+
// on every individual keystroke during a search.
33+
useEffect(() => {
34+
const timer = setTimeout(() => {
35+
setAnnouncedCount(resultCount);
36+
}, 500);
37+
return () => clearTimeout(timer);
38+
}, [resultCount]);
39+
40+
const announcementText =
41+
typeof announcedCount === 'number'
42+
? `${announcedCount} ${announcedCount === 1 ? 'result' : 'results'} found.`
43+
: '';
44+
2745
return (
2846
<div className={cn('sticky top-4 z-20 mb-10 md:top-6', className)}>
2947
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/10 bg-slate-950/78 px-4 py-4 text-white shadow-[0_20px_80px_rgba(0,0,0,0.28)] backdrop-blur-xl md:px-5 md:py-4">
3048
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.08),rgba(255,255,255,0.02)_48%,rgba(245,158,11,0.08))]" />
3149

50+
{/* Hidden live region for search result announcements */}
51+
<div className="sr-only" aria-live="polite" role="status">
52+
{announcementText}
53+
</div>
54+
3255
<div className="relative flex flex-col gap-4">
3356
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
3457
<div className="min-w-0">
@@ -40,7 +63,10 @@ const StickyFilterBar: React.FC<StickyFilterBarProps> = ({
4063
{title}
4164
</h2>
4265
{typeof resultCount === 'number' && (
43-
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/8 px-3 py-1 text-xs font-medium text-white/75">
66+
<span
67+
className="inline-flex items-center rounded-full border border-white/10 bg-white/8 px-3 py-1 text-xs font-medium text-white/75"
68+
aria-hidden="true"
69+
>
4470
{resultCount}{' '}
4571
{resultCount === 1 ? 'result' : 'results'}
4672
</span>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { render, screen, act } from '@testing-library/react';
3+
import StickyFilterBar from '@/components/common/StickyFilterBar';
4+
5+
describe('StickyFilterBar accessibility', () => {
6+
it('renders a visually hidden aria-live region for search results', () => {
7+
render(
8+
<StickyFilterBar title="Test Bar" resultCount={5}>
9+
<div>Children</div>
10+
</StickyFilterBar>
11+
);
12+
13+
const liveRegion = screen.getByRole('status');
14+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
15+
expect(liveRegion).toHaveClass('sr-only');
16+
});
17+
18+
it('debounces the aria-live announcement of result count', async () => {
19+
vi.useFakeTimers();
20+
const { rerender } = render(
21+
<StickyFilterBar title="Test Bar" resultCount={5}>
22+
<div>Children</div>
23+
</StickyFilterBar>
24+
);
25+
26+
// Initial render should have the count (or undefined if it's the very first render before effect)
27+
// Actually in my implementation, announcedCount is initialized with resultCount
28+
expect(screen.getByRole('status')).toHaveTextContent('5 results found.');
29+
30+
// Change count
31+
rerender(
32+
<StickyFilterBar title="Test Bar" resultCount={10}>
33+
<div>Children</div>
34+
</StickyFilterBar>
35+
);
36+
37+
// Should still show old count immediately
38+
expect(screen.getByRole('status')).toHaveTextContent('5 results found.');
39+
40+
// Fast forward time
41+
act(() => {
42+
vi.advanceTimersByTime(500);
43+
});
44+
45+
// Should show new count
46+
expect(screen.getByRole('status')).toHaveTextContent('10 results found.');
47+
48+
vi.useRealTimers();
49+
});
50+
51+
it('hides the visual count from screen readers to avoid double-announcement', () => {
52+
render(
53+
<StickyFilterBar title="Test Bar" resultCount={5}>
54+
<div>Children</div>
55+
</StickyFilterBar>
56+
);
57+
58+
// The visual count span should have aria-hidden="true"
59+
const visualCount = screen.getByText('5 results');
60+
expect(visualCount).toHaveAttribute('aria-hidden', 'true');
61+
});
62+
});

‎src/components/common/__tests__/TradeDialog.focusOrder.test.tsx‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import TradeDialog from '@/components/common/TradeDialog';
44

55
/**
66
* The dialog's focus order is part of its accessibility contract — keyboard
7-
* users tab through `amount input → Cancel → Confirm`, matching the
8-
* left-to-right visual order. These tests guard against future refactors
9-
* that accidentally swap Cancel and Confirm in the DOM (which would also
10-
* swap them in the tab sequence) or remove the marker attributes.
7+
* users wrap through `Close → amount input → Cancel → Confirm`, matching
8+
* the visual top-to-bottom and left-to-right order. These tests guard
9+
* against future refactors that accidentally swap these elements in the
10+
* DOM (which would also swap them in the tab sequence) or remove markers.
1111
*/
1212
describe('TradeDialog focus order', () => {
1313
function renderDialog(overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}) {
@@ -24,7 +24,7 @@ describe('TradeDialog focus order', () => {
2424
);
2525
}
2626

27-
it('renders the focus-order markers on the three primary controls', () => {
27+
it('renders the focus-order markers on the primary controls', () => {
2828
renderDialog();
2929

3030
expect(screen.getByTestId('trade-dialog-amount')).toHaveAttribute(
@@ -41,20 +41,21 @@ describe('TradeDialog focus order', () => {
4141
);
4242
});
4343

44-
it('orders the controls in DOM as amount → Cancel → Confirm so tab sequence matches', () => {
44+
it('orders the controls in DOM as Close → amount → Cancel → Confirm so tab sequence matches', () => {
4545
renderDialog();
4646

47-
const ordered = Array.from(
48-
document.querySelectorAll('[data-focus-order]')
47+
const elements = Array.from(
48+
document.querySelectorAll('[data-slot="dialog-close"], [data-focus-order]')
4949
).map(el => ({
50-
testId: el.getAttribute('data-testid'),
51-
order: el.getAttribute('data-focus-order'),
50+
identifier: el.getAttribute('data-testid') || el.getAttribute('data-slot'),
51+
order: el.getAttribute('data-focus-order') || '0',
5252
}));
5353

54-
expect(ordered).toEqual([
55-
{ testId: 'trade-dialog-amount', order: '1' },
56-
{ testId: 'trade-dialog-cancel', order: '2' },
57-
{ testId: 'trade-dialog-confirm', order: '3' },
54+
expect(elements).toEqual([
55+
{ identifier: 'dialog-close', order: '0' },
56+
{ identifier: 'trade-dialog-amount', order: '1' },
57+
{ identifier: 'trade-dialog-cancel', order: '2' },
58+
{ identifier: 'trade-dialog-confirm', order: '3' },
5859
]);
5960
});
6061

‎src/components/ui/dialog.tsx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,6 @@ function DialogContent({
6666
)}
6767
{...props}
6868
>
69-
{children}
70-
{showEscapeHint && (
71-
<p
72-
aria-hidden="true"
73-
className="pointer-events-none absolute right-4 bottom-3 select-none text-[11px] text-white/45"
74-
>
75-
Esc to close
76-
</p>
77-
)}
7869
{showCloseButton && (
7970
<DialogPrimitive.Close
8071
data-slot="dialog-close"
@@ -84,6 +75,15 @@ function DialogContent({
8475
<span className="sr-only">Close</span>
8576
</DialogPrimitive.Close>
8677
)}
78+
{children}
79+
{showEscapeHint && (
80+
<p
81+
aria-hidden="true"
82+
className="pointer-events-none absolute right-4 bottom-3 select-none text-[11px] text-white/45"
83+
>
84+
Esc to close
85+
</p>
86+
)}
8787
</DialogPrimitive.Content>
8888
</DialogPortal>
8989
);

0 commit comments

Comments
 (0)