Skip to content

Commit 6b7badc

Browse files
feat(frontend): add live-region announcements to ToastProvider (#1285)
## Summary - add accessible live-region announcements for toast updates - add regression coverage for the new announcement behavior ## Validation - pnpm vitest run src/components/__tests__/ToastProvider.test.tsx - pnpm typecheck - pnpm lint Closes #1195
1 parent ae2887e commit 6b7badc

8 files changed

Lines changed: 143 additions & 11 deletions

File tree

Dechat/dex_with_fiat_frontend/src/components/BankDetailsModal.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
Clock,
1818
RefreshCw,
1919
} from 'lucide-react';
20-
import { motion, AnimatePresence } from 'framer-motion';
20+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
2121
import { fetchLockedQuote, type LockedQuote } from '@/lib/cryptoPriceService';
2222
import SkeletonWallet from '@/components/ui/skeleton/SkeletonWallet';
2323
import { useNotifications } from '@/hooks/useNotifications';
@@ -164,6 +164,7 @@ export default function BankDetailsModal({
164164
xlmAmount,
165165
}: BankDetailsModalProps) {
166166
const modalRef = useRef<HTMLDivElement>(null);
167+
const prefersReducedMotion = useReducedMotion();
167168
const {
168169
beneficiaries,
169170
isLoaded: beneficiariesLoaded,
@@ -665,10 +666,10 @@ export default function BankDetailsModal({
665666
return (
666667
<motion.div
667668
className="theme-overlay fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
668-
initial={{ opacity: 0 }}
669+
initial={prefersReducedMotion ? false : { opacity: 0 }}
669670
animate={{ opacity: 1 }}
670671
exit={{ opacity: 0 }}
671-
transition={{ duration: 0.2 }}
672+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2 }}
672673
>
673674
<motion.div
674675
ref={modalRef}
@@ -678,10 +679,22 @@ export default function BankDetailsModal({
678679
tabIndex={-1}
679680
className="theme-surface theme-border relative w-full max-w-md mx-4 border rounded-2xl shadow-2xl p-6"
680681
variants={modalVariants}
681-
initial="hidden"
682+
initial={prefersReducedMotion ? false : 'hidden'}
682683
animate="visible"
683684
exit="exit"
684685
>
686+
<div
687+
role="status"
688+
aria-live="polite"
689+
aria-atomic="true"
690+
className="sr-only"
691+
>
692+
{step === 1 && 'Bank selection step.'}
693+
{step === 2 && 'Account verification step.'}
694+
{step === 3 && 'Payout confirmation step.'}
695+
{step === 4 && 'Payout status step.'}
696+
</div>
697+
685698
{/* Header */}
686699
<div className="flex items-center justify-between mb-6">
687700
<div className="flex items-center gap-2">

Dechat/dex_with_fiat_frontend/src/components/StellarChatInterface.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -942,7 +942,14 @@ function StellarChatInterfaceContent() {
942942
{/* Messages */}
943943
<div className="flex-1 min-h-0 flex flex-col">
944944
{!isHydrated || (isLoading && messages.length === 0) ? (
945-
<SkeletonChat />
945+
<div
946+
role="status"
947+
aria-live="polite"
948+
aria-atomic="true"
949+
className="flex-1"
950+
>
951+
<SkeletonChat />
952+
</div>
946953
) : (
947954
<ErrorBoundary
948955
isDarkMode={isDarkMode}

Dechat/dex_with_fiat_frontend/src/components/ToastProvider.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
4+
import { useReducedMotion } from 'framer-motion';
45
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
56
import { toastStore, AppToast, ToastVariant } from '@/lib/toastStore';
67
import { useTheme } from '@/contexts/ThemeContext';
@@ -17,6 +18,7 @@ interface ToastItemProps {
1718

1819
function ToastItem({ toast, onDismiss, isDarkMode }: ToastItemProps) {
1920
const touchStartX = useRef(0);
21+
const prefersReducedMotion = useReducedMotion();
2022
const touchStartTime = useRef(0);
2123
const [offsetX, setOffsetX] = useState(0);
2224
const [isSwiping, setIsSwiping] = useState(false);
@@ -89,10 +91,12 @@ function ToastItem({ toast, onDismiss, isDarkMode }: ToastItemProps) {
8991
? { transform: `translateX(${offsetX}px)`, opacity: Math.max(0, 1 - Math.abs(offsetX) / 300) }
9092
: { transform: 'translateX(0)', opacity: 1 };
9193

94+
const motionPreference = prefersReducedMotion ? { transition: 'none' } : {};
95+
9296
return (
9397
<div
9498
className={getVariantStyles(toast.variant)}
95-
style={swipeStyle}
99+
style={{ ...swipeStyle, ...motionPreference }}
96100
onTouchStart={handleTouchStart}
97101
onTouchMove={handleTouchMove}
98102
onTouchEnd={handleTouchEnd}
@@ -121,10 +125,13 @@ export function ToastProvider({ children }: ToastProviderProps) {
121125
const { isDarkMode } = useTheme();
122126

123127
useEffect(() => {
124-
const unsubscribe = toastStore.subscribe(() => {
128+
const syncToasts = () => {
125129
const currentToasts = toastStore.getToasts();
126130
setToasts(currentToasts.slice(0, MAX_VISIBLE_TOASTS));
127-
});
131+
};
132+
133+
syncToasts();
134+
const unsubscribe = toastStore.subscribe(syncToasts);
128135

129136
return () => unsubscribe();
130137
}, []);
@@ -136,7 +143,12 @@ export function ToastProvider({ children }: ToastProviderProps) {
136143
return (
137144
<>
138145
{children}
139-
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
146+
<div
147+
role="status"
148+
aria-live="polite"
149+
aria-atomic="true"
150+
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"
151+
>
140152
{toasts.map((toast) => (
141153
<ToastItem
142154
key={toast.id}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import BankDetailsModal from '../BankDetailsModal';
4+
5+
vi.mock('@/lib/chatTelemetry', () => ({
6+
chatTelemetry: {
7+
fiatPayoutStep: vi.fn(),
8+
},
9+
}));
10+
11+
vi.mock('@/hooks/useNotifications', () => ({
12+
useNotifications: () => ({
13+
addNotification: vi.fn(),
14+
}),
15+
}));
16+
17+
vi.mock('@/hooks/useBeneficiaries', () => ({
18+
useBeneficiaries: () => ({
19+
beneficiaries: [],
20+
isLoaded: true,
21+
addBeneficiary: vi.fn(),
22+
renameBeneficiary: vi.fn(),
23+
deleteBeneficiary: vi.fn(),
24+
}),
25+
}));
26+
27+
vi.mock('@/hooks/useTxHistory', () => ({
28+
useTxHistory: () => ({
29+
addEntry: vi.fn(),
30+
}),
31+
}));
32+
33+
vi.mock('@/hooks/useIdempotentAction', () => ({
34+
useIdempotentAction: () => ({
35+
execute: async (fn: (key: string) => Promise<void>) => {
36+
await fn('test-key');
37+
return null;
38+
},
39+
isProcessing: false,
40+
}),
41+
}));
42+
43+
vi.mock('@/hooks/useAccessibleModal', () => ({
44+
useAccessibleModal: vi.fn(),
45+
}));
46+
47+
vi.mock('@/lib/clientSession', () => ({
48+
getOrCreateClientSessionId: () => 'test-session-id',
49+
}));
50+
51+
describe('BankDetailsModal accessibility', () => {
52+
it('renders a polite live region for announcements', () => {
53+
render(
54+
<BankDetailsModal isOpen={true} onClose={() => undefined} xlmAmount={100} />,
55+
);
56+
57+
const liveRegion = screen.getByRole('status');
58+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
59+
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
60+
});
61+
});

Dechat/dex_with_fiat_frontend/src/components/__tests__/BankDetailsModal.rapid-click.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ vi.mock('@/lib/chatTelemetry', () => ({
4242
chatTelemetry: { fiatPayoutStep: vi.fn() },
4343
}));
4444
vi.mock('framer-motion', () => ({
45+
useReducedMotion: () => false,
4546
motion: {
4647
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
4748
button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { render, screen, act } from '@testing-library/react';
2+
import { describe, expect, it, beforeEach, vi } from 'vitest';
3+
import { ToastProvider } from '../ToastProvider';
4+
import { toastStore } from '@/lib/toastStore';
5+
6+
vi.mock('@/contexts/ThemeContext', () => ({
7+
useTheme: () => ({ isDarkMode: false }),
8+
}));
9+
10+
describe('ToastProvider', () => {
11+
beforeEach(() => {
12+
toastStore.clearToasts();
13+
});
14+
15+
it('renders a polite live region for toast announcements', () => {
16+
act(() => {
17+
toastStore.addToast('Saved successfully', 'success');
18+
});
19+
20+
render(
21+
<ToastProvider>
22+
<div>children</div>
23+
</ToastProvider>,
24+
);
25+
26+
const liveRegion = screen.getByRole('status');
27+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
28+
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
29+
expect(screen.getByText('Saved successfully')).toBeTruthy();
30+
});
31+
});

Dechat/dex_with_fiat_frontend/src/components/ui/skeleton/Skeleton.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ type SkeletonProps = {
77

88
export default function Skeleton({ className }: SkeletonProps) {
99
return (
10-
<div className={cn('animate-pulse rounded-md bg-gray-700/40', className)} />
10+
<div
11+
className={cn(
12+
'animate-pulse motion-reduce:animate-none rounded-md bg-gray-700/40',
13+
className,
14+
)}
15+
/>
1116
);
1217
}

Dechat/dex_with_fiat_frontend/tests/e2e/bank-details-modal.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@ test.describe('BankDetailsModal — Step 3: confirm payout', () => {
205205
});
206206

207207
test('shows confirm payout screen with quote details', async ({ page }) => {
208-
await expect(page.getByText(/confirm/i)).toBeVisible();
208+
await expect(
209+
page.getByRole('button', { name: 'Confirm Payout' }),
210+
).toBeVisible();
209211
});
210212

211213
test('payout note field accepts text up to 160 characters', async ({ page }) => {

0 commit comments

Comments
 (0)