Skip to content
Merged
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
30 changes: 28 additions & 2 deletions src/components/common/StickyFilterBar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,11 +24,34 @@ const StickyFilterBar: React.FC<StickyFilterBarProps> = ({
onReset,
showReset,
}) => {
const [announcedCount, setAnnouncedCount] = useState<number | undefined>(
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 (
<div className={cn('sticky top-4 z-20 mb-10 md:top-6', className)}>
<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">
<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))]" />

{/* Hidden live region for search result announcements */}
<div className="sr-only" aria-live="polite" role="status">
{announcementText}
</div>

<div className="relative flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
Expand All @@ -40,7 +63,10 @@ const StickyFilterBar: React.FC<StickyFilterBarProps> = ({
{title}
</h2>
{typeof resultCount === 'number' && (
<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">
<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"
aria-hidden="true"
>
{resultCount}{' '}
{resultCount === 1 ? 'result' : 'results'}
</span>
Expand Down
62 changes: 62 additions & 0 deletions src/components/common/__tests__/StickyFilterBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StickyFilterBar title="Test Bar" resultCount={5}>
<div>Children</div>
</StickyFilterBar>
);

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(
<StickyFilterBar title="Test Bar" resultCount={5}>
<div>Children</div>
</StickyFilterBar>
);

// 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(
<StickyFilterBar title="Test Bar" resultCount={10}>
<div>Children</div>
</StickyFilterBar>
);

// 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(
<StickyFilterBar title="Test Bar" resultCount={5}>
<div>Children</div>
</StickyFilterBar>
);

// The visual count span should have aria-hidden="true"
const visualCount = screen.getByText('5 results');
expect(visualCount).toHaveAttribute('aria-hidden', 'true');
});
});
29 changes: 15 additions & 14 deletions src/components/common/__tests__/TradeDialog.focusOrder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ComponentProps<typeof TradeDialog>> = {}) {
Expand All @@ -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(
Expand All @@ -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' },
]);
});

Expand Down
18 changes: 9 additions & 9 deletions src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,6 @@ function DialogContent({
)}
{...props}
>
{children}
{showEscapeHint && (
<p
aria-hidden="true"
className="pointer-events-none absolute right-4 bottom-3 select-none text-[11px] text-white/45"
>
Esc to close
</p>
)}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
Expand All @@ -84,6 +75,15 @@ function DialogContent({
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
{children}
{showEscapeHint && (
<p
aria-hidden="true"
className="pointer-events-none absolute right-4 bottom-3 select-none text-[11px] text-white/45"
>
Esc to close
</p>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
Expand Down
Loading