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
21 changes: 17 additions & 4 deletions Dechat/dex_with_fiat_frontend/src/components/BankDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
Clock,
RefreshCw,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import { fetchLockedQuote, type LockedQuote } from '@/lib/cryptoPriceService';
import SkeletonWallet from '@/components/ui/skeleton/SkeletonWallet';
import { useNotifications } from '@/hooks/useNotifications';
Expand Down Expand Up @@ -164,6 +164,7 @@ export default function BankDetailsModal({
xlmAmount,
}: BankDetailsModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const prefersReducedMotion = useReducedMotion();
const {
beneficiaries,
isLoaded: beneficiariesLoaded,
Expand Down Expand Up @@ -665,10 +666,10 @@ export default function BankDetailsModal({
return (
<motion.div
className="theme-overlay fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
initial={{ opacity: 0 }}
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2 }}
>
<motion.div
ref={modalRef}
Expand All @@ -678,10 +679,22 @@ export default function BankDetailsModal({
tabIndex={-1}
className="theme-surface theme-border relative w-full max-w-md mx-4 border rounded-2xl shadow-2xl p-6"
variants={modalVariants}
initial="hidden"
initial={prefersReducedMotion ? false : 'hidden'}
animate="visible"
exit="exit"
>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{step === 1 && 'Bank selection step.'}
{step === 2 && 'Account verification step.'}
{step === 3 && 'Payout confirmation step.'}
{step === 4 && 'Payout status step.'}
</div>

{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
Expand Down
40 changes: 29 additions & 11 deletions Dechat/dex_with_fiat_frontend/src/components/ReceiptQrCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { useMediaQuery } from '@/hooks/useMediaQuery';

interface ReceiptQrCodeProps {
value: string;
Expand All @@ -10,9 +11,12 @@ interface ReceiptQrCodeProps {

export default function ReceiptQrCode({ value, label }: ReceiptQrCodeProps) {
const [dataUrl, setDataUrl] = useState('');
const [isGenerating, setIsGenerating] = useState(true);
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

useEffect(() => {
let cancelled = false;
setIsGenerating(true);

QRCode.toDataURL(value, {
width: 128,
Expand All @@ -21,28 +25,42 @@ export default function ReceiptQrCode({ value, label }: ReceiptQrCodeProps) {
errorCorrectionLevel: 'M',
})
.then((url) => {
if (!cancelled) setDataUrl(url);
if (!cancelled) {
setDataUrl(url);
setIsGenerating(false);
}
})
.catch(() => {
if (!cancelled) setDataUrl('');
if (!cancelled) {
setDataUrl('');
setIsGenerating(false);
}
});

return () => {
cancelled = true;
};
}, [value]);

if (!dataUrl) return null;

return (
<div className="receipt-qr-wrapper flex flex-col items-center gap-1 pt-2 border-t dark:border-gray-700">
<img
src={dataUrl}
alt={label ?? 'Transaction verification QR code'}
className="receipt-qr-code w-32 h-32"
width={128}
height={128}
/>
{isGenerating ? (
<div
role="status"
aria-live="polite"
className="receipt-qr-loading flex h-32 w-32 items-center justify-center rounded border border-dashed border-gray-300 bg-gray-50 text-[10px] uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{prefersReducedMotion ? 'Generating QR code…' : 'Generating QR code…'}
</div>
) : dataUrl ? (
<img
src={dataUrl}
alt={label ?? 'Transaction verification QR code'}
className="receipt-qr-code h-32 w-32"
width={128}
height={128}
/>
) : null}
<span className="receipt-qr-label text-[9px] text-gray-500 uppercase tracking-wide">
Scan to verify
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,14 @@ function StellarChatInterfaceContent() {
{/* Messages */}
<div className="flex-1 min-h-0 flex flex-col">
{!isHydrated || (isLoading && messages.length === 0) ? (
<SkeletonChat />
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="flex-1"
>
<SkeletonChat />
</div>
) : (
<ErrorBoundary
isDarkMode={isDarkMode}
Expand Down
20 changes: 16 additions & 4 deletions Dechat/dex_with_fiat_frontend/src/components/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

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

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

const motionPreference = prefersReducedMotion ? { transition: 'none' } : {};

return (
<div
className={getVariantStyles(toast.variant)}
style={swipeStyle}
style={{ ...swipeStyle, ...motionPreference }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
Expand Down Expand Up @@ -121,10 +125,13 @@ export function ToastProvider({ children }: ToastProviderProps) {
const { isDarkMode } = useTheme();

useEffect(() => {
const unsubscribe = toastStore.subscribe(() => {
const syncToasts = () => {
const currentToasts = toastStore.getToasts();
setToasts(currentToasts.slice(0, MAX_VISIBLE_TOASTS));
});
};

syncToasts();
const unsubscribe = toastStore.subscribe(syncToasts);

return () => unsubscribe();
}, []);
Expand All @@ -136,7 +143,12 @@ export function ToastProvider({ children }: ToastProviderProps) {
return (
<>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"
>
{toasts.map((toast) => (
<ToastItem
key={toast.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import BankDetailsModal from '../BankDetailsModal';

vi.mock('@/lib/chatTelemetry', () => ({
chatTelemetry: {
fiatPayoutStep: vi.fn(),
},
}));

vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
addNotification: vi.fn(),
}),
}));

vi.mock('@/hooks/useBeneficiaries', () => ({
useBeneficiaries: () => ({
beneficiaries: [],
isLoaded: true,
addBeneficiary: vi.fn(),
renameBeneficiary: vi.fn(),
deleteBeneficiary: vi.fn(),
}),
}));

vi.mock('@/hooks/useTxHistory', () => ({
useTxHistory: () => ({
addEntry: vi.fn(),
}),
}));

vi.mock('@/hooks/useIdempotentAction', () => ({
useIdempotentAction: () => ({
execute: async (fn: (key: string) => Promise<void>) => {
await fn('test-key');
return null;
},
isProcessing: false,
}),
}));

vi.mock('@/hooks/useAccessibleModal', () => ({
useAccessibleModal: vi.fn(),
}));

vi.mock('@/lib/clientSession', () => ({
getOrCreateClientSessionId: () => 'test-session-id',
}));

describe('BankDetailsModal accessibility', () => {
it('renders a polite live region for announcements', () => {
render(
<BankDetailsModal isOpen={true} onClose={() => undefined} xlmAmount={100} />,
);

const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ describe('ChatInput - Rapid Click Protection', () => {

expect(submitButton).toHaveAttribute('title', 'Send message (Ctrl+Enter)');
expect(submitButton).toHaveAttribute('aria-keyshortcuts', 'Control+Enter');
expect(textarea).toHaveAttribute('aria-describedby', 'chat-submit-shortcut');
expect(textarea.getAttribute('aria-describedby')).toContain('chat-submit-shortcut');
expect(textarea.getAttribute('aria-describedby')).toContain('chat-input-status');
expect(screen.getByText(/send message with ctrl\+enter/i)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import ReceiptQrCode from '../ReceiptQrCode';

let mockToDataURL: ReturnType<typeof vi.fn>;

vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn(),
},
}));

vi.mock('@/hooks/useMediaQuery', () => ({
useMediaQuery: () => false,
}));

describe('ReceiptQrCode', () => {
beforeEach(async () => {
vi.clearAllMocks();
const qrcode = await import('qrcode');
mockToDataURL = qrcode.default.toDataURL;
mockToDataURL.mockResolvedValue('data:image/png;base64,qr');
});

afterEach(() => {
vi.restoreAllMocks();
});

it('renders an accessible QR placeholder while the code is generating', async () => {
mockToDataURL.mockImplementation(() => new Promise(() => {}));

render(<ReceiptQrCode value="abc" label="Verify transaction" />);

expect(screen.getByRole('status')).toHaveTextContent('Generating QR code…');
});

it('renders the generated QR code image once the data URL is ready', async () => {
render(<ReceiptQrCode value="abc" label="Verify transaction" />);

await waitFor(() => {
expect(screen.getByRole('img', { name: 'Verify transaction' })).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { render, screen, act } from '@testing-library/react';
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { ToastProvider } from '../ToastProvider';
import { toastStore } from '@/lib/toastStore';

vi.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({ isDarkMode: false }),
}));

describe('ToastProvider', () => {
beforeEach(() => {
toastStore.clearToasts();
});

it('renders a polite live region for toast announcements', () => {
act(() => {
toastStore.addToast('Saved successfully', 'success');
});

render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);

const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
expect(screen.getByText('Saved successfully')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ type SkeletonProps = {

export default function Skeleton({ className }: SkeletonProps) {
return (
<div className={cn('animate-pulse rounded-md bg-gray-700/40', className)} />
<div
className={cn(
'animate-pulse motion-reduce:animate-none rounded-md bg-gray-700/40',
className,
)}
/>
);
}
Loading