Skip to content

Commit 1ba84b2

Browse files
feat(frontend): add accessibility and loading states
1 parent 6ed7aab commit 1ba84b2

6 files changed

Lines changed: 141 additions & 10 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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import SkeletonChat from '@/components/ui/skeleton/SkeletonChat';
44
import SkeletonSidebar from '@/components/ui/skeleton/SkeletonSidebar';
55
import { useState, useCallback, useEffect, useRef } from 'react';
6+
import { useReducedMotion } from 'framer-motion';
67
import {
78
Wallet,
89
LogOut,
@@ -67,6 +68,7 @@ const HEALTH_POLL_INTERVAL_MS = 60_000;
6768

6869
function StellarChatInterfaceContent() {
6970
const { t } = useTranslation();
71+
const prefersReducedMotion = useReducedMotion();
7072
const {
7173
connection,
7274
connect,
@@ -942,7 +944,14 @@ function StellarChatInterfaceContent() {
942944
{/* Messages */}
943945
<div className="flex-1 min-h-0 flex flex-col">
944946
{!isHydrated || (isLoading && messages.length === 0) ? (
945-
<SkeletonChat />
947+
<div
948+
role="status"
949+
aria-live="polite"
950+
aria-atomic="true"
951+
className="flex-1"
952+
>
953+
<SkeletonChat />
954+
</div>
946955
) : (
947956
<ErrorBoundary
948957
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+
});
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
}

0 commit comments

Comments
 (0)