diff --git a/src/components/common/StickyFilterBar.tsx b/src/components/common/StickyFilterBar.tsx index 8f32c12..c0ae83f 100644 --- a/src/components/common/StickyFilterBar.tsx +++ b/src/components/common/StickyFilterBar.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { X } from 'lucide-react'; @@ -24,11 +24,34 @@ const StickyFilterBar: React.FC = ({ onReset, showReset, }) => { + const [announcedCount, setAnnouncedCount] = useState( + resultCount + ); + + // Debounce result count announcements so screen readers don't stutter + // on every individual keystroke during a search. + useEffect(() => { + const timer = setTimeout(() => { + setAnnouncedCount(resultCount); + }, 500); + return () => clearTimeout(timer); + }, [resultCount]); + + const announcementText = + typeof announcedCount === 'number' + ? `${announcedCount} ${announcedCount === 1 ? 'result' : 'results'} found.` + : ''; + return (
+ {/* Hidden live region for search result announcements */} +
+ {announcementText} +
+
@@ -40,7 +63,10 @@ const StickyFilterBar: React.FC = ({ {title} {typeof resultCount === 'number' && ( - + diff --git a/src/components/common/__tests__/StickyFilterBar.test.tsx b/src/components/common/__tests__/StickyFilterBar.test.tsx new file mode 100644 index 0000000..3780fad --- /dev/null +++ b/src/components/common/__tests__/StickyFilterBar.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import StickyFilterBar from '@/components/common/StickyFilterBar'; + +describe('StickyFilterBar accessibility', () => { + it('renders a visually hidden aria-live region for search results', () => { + render( + +
Children
+
+ ); + + const liveRegion = screen.getByRole('status'); + expect(liveRegion).toHaveAttribute('aria-live', 'polite'); + expect(liveRegion).toHaveClass('sr-only'); + }); + + it('debounces the aria-live announcement of result count', async () => { + vi.useFakeTimers(); + const { rerender } = render( + +
Children
+
+ ); + + // Initial render should have the count (or undefined if it's the very first render before effect) + // Actually in my implementation, announcedCount is initialized with resultCount + expect(screen.getByRole('status')).toHaveTextContent('5 results found.'); + + // Change count + rerender( + +
Children
+
+ ); + + // Should still show old count immediately + expect(screen.getByRole('status')).toHaveTextContent('5 results found.'); + + // Fast forward time + act(() => { + vi.advanceTimersByTime(500); + }); + + // Should show new count + expect(screen.getByRole('status')).toHaveTextContent('10 results found.'); + + vi.useRealTimers(); + }); + + it('hides the visual count from screen readers to avoid double-announcement', () => { + render( + +
Children
+
+ ); + + // The visual count span should have aria-hidden="true" + const visualCount = screen.getByText('5 results'); + expect(visualCount).toHaveAttribute('aria-hidden', 'true'); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx index e139913..d6588aa 100644 --- a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx +++ b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx @@ -4,10 +4,10 @@ import TradeDialog from '@/components/common/TradeDialog'; /** * The dialog's focus order is part of its accessibility contract — keyboard - * users tab through `amount input → Cancel → Confirm`, matching the - * left-to-right visual order. These tests guard against future refactors - * that accidentally swap Cancel and Confirm in the DOM (which would also - * swap them in the tab sequence) or remove the marker attributes. + * users wrap through `Close → amount input → Cancel → Confirm`, matching + * the visual top-to-bottom and left-to-right order. These tests guard + * against future refactors that accidentally swap these elements in the + * DOM (which would also swap them in the tab sequence) or remove markers. */ describe('TradeDialog focus order', () => { function renderDialog(overrides: Partial> = {}) { @@ -24,7 +24,7 @@ describe('TradeDialog focus order', () => { ); } - it('renders the focus-order markers on the three primary controls', () => { + it('renders the focus-order markers on the primary controls', () => { renderDialog(); expect(screen.getByTestId('trade-dialog-amount')).toHaveAttribute( @@ -41,20 +41,21 @@ describe('TradeDialog focus order', () => { ); }); - it('orders the controls in DOM as amount → Cancel → Confirm so tab sequence matches', () => { + it('orders the controls in DOM as Close → amount → Cancel → Confirm so tab sequence matches', () => { renderDialog(); - const ordered = Array.from( - document.querySelectorAll('[data-focus-order]') + const elements = Array.from( + document.querySelectorAll('[data-slot="dialog-close"], [data-focus-order]') ).map(el => ({ - testId: el.getAttribute('data-testid'), - order: el.getAttribute('data-focus-order'), + identifier: el.getAttribute('data-testid') || el.getAttribute('data-slot'), + order: el.getAttribute('data-focus-order') || '0', })); - expect(ordered).toEqual([ - { testId: 'trade-dialog-amount', order: '1' }, - { testId: 'trade-dialog-cancel', order: '2' }, - { testId: 'trade-dialog-confirm', order: '3' }, + expect(elements).toEqual([ + { identifier: 'dialog-close', order: '0' }, + { identifier: 'trade-dialog-amount', order: '1' }, + { identifier: 'trade-dialog-cancel', order: '2' }, + { identifier: 'trade-dialog-confirm', order: '3' }, ]); }); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 9ca8867..07d46cf 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -66,15 +66,6 @@ function DialogContent({ )} {...props} > - {children} - {showEscapeHint && ( - - )} {showCloseButton && ( Close
)} + {children} + {showEscapeHint && ( + + )} );