diff --git a/package-lock.json b/package-lock.json
index 1bfbec5..70d29c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.2.1",
+ "axe-core": "^4.13.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
@@ -518,6 +519,7 @@
"os": [
"aix"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -535,6 +537,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -552,6 +555,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -569,6 +573,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -586,6 +591,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -603,6 +609,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -620,6 +627,7 @@
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -637,6 +645,7 @@
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -654,6 +663,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -671,6 +681,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -688,6 +699,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -705,6 +717,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -722,6 +735,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -739,6 +753,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -756,6 +771,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -773,6 +789,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -790,6 +807,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -807,6 +825,7 @@
"os": [
"netbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -824,6 +843,7 @@
"os": [
"netbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -841,6 +861,7 @@
"os": [
"openbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -858,6 +879,7 @@
"os": [
"openbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -875,6 +897,7 @@
"os": [
"openharmony"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -892,6 +915,7 @@
"os": [
"sunos"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -909,6 +933,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -926,6 +951,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -943,6 +969,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1738,9 +1765,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1758,9 +1782,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1778,9 +1799,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1798,9 +1816,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1818,9 +1833,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1838,9 +1850,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2036,9 +2045,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2053,9 +2059,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2070,9 +2073,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2087,9 +2087,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2104,9 +2101,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2121,9 +2115,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2138,9 +2129,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2155,9 +2143,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2172,9 +2157,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2189,9 +2171,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2206,9 +2185,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2223,9 +2199,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2240,9 +2213,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4370,7 +4340,9 @@
}
},
"node_modules/axe-core": {
- "version": "4.12.1",
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
@@ -5643,6 +5615,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -8033,9 +8006,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8057,9 +8027,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8081,9 +8048,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8105,9 +8069,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/package.json b/package.json
index b0428a0..1181c7e 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"lint": "eslint .",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
- "test": "vitest run test/integration test/unit test/lighthouse-config.test.js",
+ "test": "vitest run test/integration test/unit test/components test/services test/lighthouse-config.test.js",
"test:lighthouse": "vitest run test/lighthouse-config.test.js",
"test:watch": "vitest"
},
@@ -32,6 +32,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.2.1",
+ "axe-core": "^4.13.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
diff --git a/src/components/Button.jsx b/src/components/Button.jsx
index 967a8c3..35141b7 100644
--- a/src/components/Button.jsx
+++ b/src/components/Button.jsx
@@ -1,3 +1,4 @@
+import { forwardRef } from 'react';
import { Link } from 'react-router-dom';
import './Button.css';
@@ -10,17 +11,23 @@ import './Button.css';
* @param {'button'|'submit'} [props.type]
* @param {string} [props.ariaLabel] - accessible label for icon-only buttons
* @param {string} [props.title] - native tooltip text
+ * @param {string} [props.ariaHasPopup] - ARIA popup type when the button
+ * opens a dialog or menu (e.g. "dialog")
*/
-export default function Button({
- children,
- variant = 'primary',
- disabled = false,
- type = 'button',
- onClick,
- to,
- ariaLabel,
- title,
-}) {
+const Button = forwardRef(function Button(
+ {
+ children,
+ variant = 'primary',
+ disabled = false,
+ type = 'button',
+ onClick,
+ to,
+ ariaLabel,
+ title,
+ ariaHasPopup,
+ },
+ ref,
+) {
const className = `btn btn-${variant}`;
if (to) {
@@ -47,8 +54,12 @@ export default function Button({
onClick={onClick}
aria-label={ariaLabel}
title={title}
+ aria-haspopup={ariaHasPopup}
+ ref={ref}
>
{children}
);
-}
+});
+
+export default Button;
diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx
index 8159ad9..f565b59 100644
--- a/src/components/Modal.jsx
+++ b/src/components/Modal.jsx
@@ -1,19 +1,79 @@
-import { useRef } from 'react';
+import { useEffect, useId, useRef } from 'react';
import { useKeyPress } from '../hooks/useKeyPress.js';
import { useOnClickOutside } from '../hooks/useOnClickOutside.js';
import './Modal.css';
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+function getFocusableElements(container) {
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+}
+
/**
* Accessible dialog rendered over an overlay.
* Closes on Escape, overlay click, or the close button.
+ *
+ * Keyboard behaviour:
+ * - On open, focus moves to the dialog panel so screen readers announce it.
+ * - Tab / Shift+Tab cycle within the dialog (focus is trapped while open).
+ * - On close, focus returns to the element that was focused before opening.
* @param {object} props
* @param {boolean} props.open - whether the dialog is visible
* @param {Function} props.onClose - called when the dialog should close
- * @param {string} [props.title] - heading shown in the header
+ * @param {string} [props.title] - heading shown in the header; used as the
+ * dialog's accessible name
* @param {React.ReactNode} props.children - dialog body content
*/
export default function Modal({ open, onClose, title, children }) {
const panelRef = useRef(null);
+ const previouslyFocusedRef = useRef(null);
+ const titleId = useId();
+
+ // Move focus into the dialog while it is open, and restore focus to the
+ // invoker when it closes or unmounts.
+ useEffect(() => {
+ if (!open) return undefined;
+
+ previouslyFocusedRef.current = document.activeElement;
+ panelRef.current?.focus();
+
+ return () => {
+ const previous = previouslyFocusedRef.current;
+ if (previous && typeof previous.focus === 'function') {
+ previous.focus();
+ }
+ previouslyFocusedRef.current = null;
+ };
+ }, [open]);
+
+ function handleKeyDown(event) {
+ if (event.key !== 'Tab') return;
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ const focusables = getFocusableElements(panel);
+ if (focusables.length === 0) {
+ event.preventDefault();
+ panel.focus();
+ return;
+ }
+
+ const activeElement = document.activeElement;
+ const currentIndex = focusables.indexOf(activeElement);
+
+ if (event.shiftKey) {
+ // Backwards from the first stop (or an unfocused panel) wraps to the end.
+ if (currentIndex <= 0) {
+ event.preventDefault();
+ focusables[focusables.length - 1].focus();
+ }
+ } else if (currentIndex === -1 || currentIndex === focusables.length - 1) {
+ // Forwards past the last stop (or from the unfocused panel) wraps round.
+ event.preventDefault();
+ focusables[0].focus();
+ }
+ }
useKeyPress('Escape', () => {
if (open) onClose();
@@ -25,16 +85,22 @@ export default function Modal({ open, onClose, title, children }) {
if (!open) return null;
return (
-
+
- {title &&
{title}
}
+ {/* h2: dialogs sit under the page's h1 in the heading outline. */}
+ {title && (
+
+ {title}
+
+ )}
+
+ {phase === 'confirm' && pendingQuote && (
+
+
+
+
- To
+ - {recipient}
+
+
+
+
+ {submitting ? 'Submitting your transfer…' : ''}
+
+
+
+ Back
+
+
+ {submitting ? 'Sending…' : 'Confirm transfer'}
+
+
+
+ )}
+
+ {phase === 'success' && submittedTransfer && (
+
setPhase(null)} title="Transfer submitted">
+
+ Your transfer was submitted successfully. Track its progress under
+ Transfers.
+
+
+
+
- To
+ - {submittedTransfer.recipient}
+
+
+
- You send
+ -
+ {formatAmount(
+ submittedTransfer.sendAmount,
+ submittedTransfer.from,
+ locale,
+ )}
+
+
+
+
- Recipient gets
+ -
+ {formatAmount(
+ submittedTransfer.receiveAmount,
+ submittedTransfer.to,
+ locale,
+ )}
+
+
+
+
- Status
+ - {submittedTransfer.status}
+
+
+
+ setPhase(null)}>
+ Close
+
+ navigate('/transfers')}>
+ View transfers
+
+
+
+ )}
);
}
diff --git a/test/components/Modal.test.jsx b/test/components/Modal.test.jsx
new file mode 100644
index 0000000..d509dd5
--- /dev/null
+++ b/test/components/Modal.test.jsx
@@ -0,0 +1,146 @@
+import { useState } from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import Modal from '../../src/components/Modal.jsx';
+
+function DialogHarness({ onClose }) {
+ const [open, setOpen] = useState(false);
+ const close = onClose ?? (() => setOpen(false));
+ return (
+
+
setOpen(true)}>
+ Open dialog
+
+
+ Dialog body
+ First action
+ Second action
+
+
+ );
+}
+
+async function openDialog(user) {
+ await user.click(screen.getByRole('button', { name: /open dialog/i }));
+ return screen.findByRole('dialog');
+}
+
+describe('Modal accessibility and focus management', () => {
+ it('exposes a modal dialog labelled by its visible title', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const dialog = await openDialog(user);
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+
+ // The accessible name comes from the visible heading via aria-labelledby.
+ const title = screen.getByRole('heading', { name: 'Example dialog' });
+ expect(dialog).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
+ });
+
+ it('moves focus into the dialog when it opens', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const dialog = await openDialog(user);
+ await waitFor(() => {
+ expect(dialog).toHaveFocus();
+ });
+ });
+
+ it('keeps Tab cycling inside the dialog (focus trap)', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const dialog = await openDialog(user);
+ const closeButton = screen.getByRole('button', { name: /close dialog/i });
+ const firstAction = screen.getByRole('button', { name: /first action/i });
+ const secondAction = screen.getByRole('button', { name: /second action/i });
+
+ // From the panel, Tab enters the dialog's focus cycle.
+ await user.tab();
+ expect(document.activeElement).toBe(closeButton);
+ await user.tab();
+ expect(document.activeElement).toBe(firstAction);
+ await user.tab();
+ expect(document.activeElement).toBe(secondAction);
+
+ // Past the last control, focus wraps back to the first.
+ await user.tab();
+ expect(document.activeElement).not.toBe(secondAction);
+ expect(
+ [dialog, closeButton, firstAction].includes(document.activeElement),
+ ).toBe(true);
+ });
+
+ it('wraps Shift+Tab backwards from the first control to the last', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const dialog = await openDialog(user);
+ const closeButton = screen.getByRole('button', { name: /close dialog/i });
+ const secondAction = screen.getByRole('button', { name: /second action/i });
+
+ await user.tab(); // panel -> close button (first stop)
+ expect(document.activeElement).toBe(closeButton);
+
+ await user.tab({ shift: true });
+ expect(document.activeElement).toBe(secondAction);
+ });
+
+ it('returns focus to the invoking element when Escape closes the dialog', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const trigger = screen.getByRole('button', { name: /open dialog/i });
+ await user.click(trigger);
+ await screen.findByRole('dialog');
+
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ // Regression: focus must not be lost to when the dialog closes.
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('returns focus to the invoking element on programmatic close', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const trigger = screen.getByRole('button', { name: /open dialog/i });
+ await user.click(trigger);
+ await screen.findByRole('dialog');
+
+ await user.click(screen.getByRole('button', { name: /close dialog/i }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('closes when the overlay outside the panel is clicked', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await openDialog(user);
+ // Clicks inside the body must not dismiss it…
+ await user.click(screen.getByText(/dialog body/i));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // …but a click on the overlay does. The overlay fills the viewport, so
+ // click near its edge by targeting a point left of the centred panel.
+ const overlay = document.querySelector('.modal-overlay');
+ overlay.getBoundingClientRect = () => ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ width: 1000,
+ height: 800,
+ right: 1000,
+ bottom: 800,
+ toJSON: () => {},
+ });
+ await user.click(overlay, undefined, { skipPointerEventsCheck: true });
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+});
diff --git a/test/integration/send-flow-accessibility.test.jsx b/test/integration/send-flow-accessibility.test.jsx
new file mode 100644
index 0000000..e8161bc
--- /dev/null
+++ b/test/integration/send-flow-accessibility.test.jsx
@@ -0,0 +1,184 @@
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it } from 'vitest';
+import App from '../../src/App.jsx';
+
+/**
+ * Keyboard-only acceptance tests for the complete Send Money flow.
+ * No pointer events are used anywhere: every step is performed with
+ * focus(), typing and key presses only, mirroring how a keyboard or
+ * switch-device user operates the app.
+ */
+describe('Send Money flow without a pointer', () => {
+ beforeEach(() => {
+ window.history.pushState({}, '', '/send');
+ localStorage.clear();
+ });
+
+ async function fillFormWithKeyboard(user) {
+ const recipient = screen.getByLabelText(/recipient/i);
+ recipient.focus();
+ await user.keyboard('amina@example.com');
+
+ // Tab moves to the amount field next in DOM/tab order.
+ await user.keyboard('{Tab}');
+ expect(document.activeElement).toBe(screen.getByLabelText(/amount/i));
+ await user.keyboard('15');
+ // Default USD -> EUR pair differs, so currency edits aren't needed here.
+ }
+
+ it('reviews, confirms and reaches the result using only the keyboard', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await screen.findByRole('heading', { name: /send money/i });
+ await fillFormWithKeyboard(user);
+
+ // Enter anywhere in the form submits it, opening the review dialog.
+ await user.keyboard('{Enter}');
+ const confirmDialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ timeout: 5000,
+ });
+
+ // Focus moves into the dialog so screen readers announce its title.
+ await waitFor(() => {
+ expect(confirmDialog).toHaveFocus();
+ });
+
+ // The quote breakdown is part of the review content.
+ expect(
+ within(confirmDialog).getByText(/transfer summary/i),
+ ).toBeInTheDocument();
+ expect(within(confirmDialog).getByText('$15.00')).toBeInTheDocument();
+
+ // Tab cycles within the dialog: close button -> Back -> Confirm.
+ await user.keyboard('{Tab}');
+ expect(document.activeElement).toHaveAttribute(
+ 'aria-label',
+ 'Close dialog',
+ );
+ await user.keyboard('{Tab}');
+ expect(document.activeElement.textContent).toBe('Back');
+ await user.keyboard('{Tab}');
+ expect(document.activeElement.textContent).toBe('Confirm transfer');
+
+ // Activate confirmation with Enter and observe the announced progress.
+ await user.keyboard('{Enter}');
+ expect(
+ await screen.findByText(
+ /submitting your transfer/i,
+ {},
+ { timeout: 5000 },
+ ),
+ ).toBeInTheDocument();
+
+ const resultDialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+
+ // Success is announced via a polite live region inside the dialog.
+ const announcement = within(resultDialog).getByText(
+ /your transfer was submitted successfully/i,
+ );
+ expect(announcement).toHaveAttribute('role', 'status');
+ await waitFor(() => {
+ expect(resultDialog).toHaveFocus();
+ });
+
+ // Result summary is readable without a pointer too.
+ expect(
+ within(resultDialog).getByText(/amina@example\.com/i),
+ ).toBeInTheDocument();
+
+ // Tab to "View transfers" and activate it to finish the journey.
+ await user.keyboard('{Tab}');
+ expect(document.activeElement).toHaveAttribute(
+ 'aria-label',
+ 'Close dialog',
+ );
+ await user.keyboard('{Tab}'); // Close
+ await user.keyboard('{Tab}'); // View transfers
+ expect(document.activeElement.textContent).toBe('View transfers');
+ await user.keyboard('{Enter}');
+
+ await screen.findByRole(
+ 'heading',
+ { name: /your transfers/i },
+ { timeout: 5000 },
+ );
+ });
+
+ it('closes the review dialog with Escape and returns focus to the form', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ await fillFormWithKeyboard(user);
+ await user.keyboard('{Enter}');
+
+ const confirmDialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await waitFor(() => {
+ expect(confirmDialog).toHaveFocus();
+ });
+
+ await user.keyboard('{Escape}');
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ // Regression: dismissing the dialog must hand focus back to the control
+ // that invoked it — here the amount field the user submitted from —
+ // never dropping it on .
+ const amountField = screen.getByLabelText(/amount/i);
+ expect(document.activeElement).toBe(amountField);
+
+ // The flow remains operable afterwards.
+ await user.keyboard('{Enter}');
+ expect(
+ await screen.findByRole('dialog', { name: /confirm your transfer/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('associates and announces validation errors on a keyboard-only submit', async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ const recipient = screen.getByLabelText(/recipient/i);
+ recipient.focus();
+ await user.keyboard('{Enter}');
+
+ // Focus jumps to the first invalid field…
+ await waitFor(() => {
+ expect(recipient).toHaveFocus();
+ });
+ expect(recipient).toHaveAttribute('aria-invalid', 'true');
+ expect(recipient).toHaveAttribute('aria-describedby', 'recipient-error');
+
+ // …and every aria-describedby target actually exists with alert semantics.
+ const describedBy = recipient.getAttribute('aria-describedby');
+ const errorNode = document.getElementById(describedBy);
+ expect(errorNode).not.toBeNull();
+ expect(errorNode).toHaveAttribute('role', 'alert');
+ expect(errorNode.textContent).toMatch(/valid email or stellar address/i);
+
+ const amountInput = screen.getByLabelText(/amount/i);
+ expect(amountInput).toHaveAttribute('aria-invalid', 'true');
+ const amountError = document.getElementById(
+ amountInput.getAttribute('aria-describedby'),
+ );
+ expect(amountError).not.toBeNull();
+ expect(amountError.textContent).toMatch(/greater than zero/i);
+
+ // An assertive summary announces the failure count.
+ const summary = screen.getByText(
+ /form submission failed with 2 validation errors/i,
+ );
+ expect(summary).toHaveAttribute('aria-live', 'assertive');
+
+ // No dialog should have opened for an invalid submission.
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+});
diff --git a/test/integration/send-flow-axe.test.jsx b/test/integration/send-flow-axe.test.jsx
new file mode 100644
index 0000000..59d9594
--- /dev/null
+++ b/test/integration/send-flow-axe.test.jsx
@@ -0,0 +1,101 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+// axe-core attaches itself to the global window object when imported.
+import 'axe-core';
+
+const axe = window.axe;
+import App from '../../src/App.jsx';
+
+/**
+ * Automated accessibility audit of the Send Money flow using axe-core.
+ * Each state of the flow is scanned for WCAG violations:
+ * 1. the empty form
+ * 2. the form with visible validation errors
+ * 3. the quote confirmation dialog
+ * 4. the transfer-submitted result dialog
+ *
+ * Rules that require a real layout/paint pipeline (colour contrast from
+ * stylesheets, landmark regions) cannot be evaluated reliably in jsdom and
+ * are disabled; everything semantic is enforced.
+ */
+const JSDOM_LIMITED_RULES = {
+ 'color-contrast': { enabled: false },
+ region: { enabled: false },
+};
+
+async function expectNoAxeViolations(container) {
+ const results = await axe.run(container, {
+ rules: JSDOM_LIMITED_RULES,
+ resultTypes: ['violations'],
+ });
+ const summary = results.violations
+ .map(
+ (v) =>
+ `${v.id} (${v.help}): ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`,
+ )
+ .join('\n');
+ expect(summary).toBe('');
+}
+
+describe('Send Money automated accessibility audit', () => {
+ beforeEach(() => {
+ window.history.pushState({}, '', '/send');
+ localStorage.clear();
+ });
+
+ it('has no axe violations on the initial form', async () => {
+ const { container } = render(
);
+ await screen.findByRole('heading', { name: /send money/i });
+
+ await expectNoAxeViolations(container);
+ });
+
+ it('has no axe violations with validation errors displayed', async () => {
+ const { container } = render(
);
+ const user = userEvent.setup();
+
+ await screen.findByRole('button', { name: /review & send/i });
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ await screen.findByText(/enter a valid email or stellar address/i);
+
+ await expectNoAxeViolations(container);
+ });
+
+ it('has no axe violations in the quote confirmation dialog', async () => {
+ const { container } = render(
);
+ const user = userEvent.setup();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.type(screen.getByLabelText(/amount/i), '15');
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await expectNoAxeViolations(dialog);
+ });
+
+ it('has no axe violations in the transfer-submitted result dialog', async () => {
+ render(
);
+ const user = userEvent.setup();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.type(screen.getByLabelText(/amount/i), '15');
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+
+ const resultDialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ await expectNoAxeViolations(resultDialog);
+ }, 20000);
+});
diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx
index 956eded..25f6a23 100644
--- a/test/integration/send-money-form.test.jsx
+++ b/test/integration/send-money-form.test.jsx
@@ -4,6 +4,7 @@ import {
render,
screen,
waitFor,
+ within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -25,6 +26,36 @@ function createdTransfer(payload) {
};
}
+// Open the quote confirmation dialog and submit the transfer from it.
+async function reviewAndConfirm(user) {
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+ // Wallet connect + transfer creation are mocked with real delays.
+ return screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ {
+ timeout: 5000,
+ },
+ );
+}
+
+async function finishOnTransfersPage(user, resultDialog) {
+ await user.click(
+ within(resultDialog).getByRole('button', { name: /view transfers/i }),
+ );
+ await screen.findByRole(
+ 'heading',
+ { name: /your transfers/i },
+ { timeout: 5000 },
+ );
+}
+
describe('Send money form flows', () => {
beforeEach(() => {
window.history.pushState({}, '', '/send');
@@ -47,6 +78,10 @@ describe('Send money form flows', () => {
expect(
screen.getByText(/enter an amount greater than zero/i),
).toBeInTheDocument();
+ // Invalid submissions must not open the confirmation dialog.
+ expect(
+ screen.queryByRole('dialog', { name: /confirm your transfer/i }),
+ ).not.toBeInTheDocument();
});
it('submits a transfer and shows it on the transfers page', async () => {
@@ -54,55 +89,65 @@ describe('Send money form flows', () => {
render(
);
await fillValidForm(user);
- await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const resultDialog = await reviewAndConfirm(user);
+ await finishOnTransfersPage(user, resultDialog);
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
- { timeout: 5000 },
- );
await waitFor(() => {
expect(screen.getByText('$15.00')).toBeInTheDocument();
});
expect(screen.getAllByText(/pending/i).length).toBeGreaterThan(0);
});
- it('disables the submit button while wallet connection is pending', async () => {
+ it('disables the confirm button while wallet connection is pending', async () => {
const createTransfer = vi.spyOn(api, 'createTransfer');
const user = userEvent.setup();
render(
);
await fillValidForm(user);
await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled();
expect(createTransfer).not.toHaveBeenCalled();
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
+ const resultDialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
{ timeout: 5000 },
);
+ await finishOnTransfersPage(user, resultDialog);
});
- it('creates one transfer after two rapid submit-button clicks', async () => {
+ it('creates one transfer after two rapid confirm-button clicks', async () => {
const createTransfer = vi.spyOn(api, 'createTransfer');
const user = userEvent.setup();
render(
);
await fillValidForm(user);
- const submitButton = screen.getByRole('button', { name: /review & send/i });
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ const confirmButton = within(dialog).getByRole('button', {
+ name: /confirm transfer/i,
+ });
act(() => {
- submitButton.click();
- submitButton.click();
+ confirmButton.click();
+ confirmButton.click();
});
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
+ const resultDialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
{ timeout: 5000 },
);
expect(createTransfer).toHaveBeenCalledTimes(1);
+ await finishOnTransfersPage(user, resultDialog);
});
it('creates one transfer after two synchronous native submit events', async () => {
@@ -119,12 +164,22 @@ describe('Send money form flows', () => {
fireEvent.submit(form);
});
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
+ // A repeated review request must not stack a second confirmation dialog.
+ const dialogs = screen.getAllByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ expect(dialogs).toHaveLength(1);
+
+ await user.click(
+ within(dialogs[0]).getByRole('button', { name: /confirm transfer/i }),
+ );
+ const resultDialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
{ timeout: 5000 },
);
expect(createTransfer).toHaveBeenCalledTimes(1);
+ await finishOnTransfersPage(user, resultDialog);
});
it('releases the submission lock after failure and permits a retry', async () => {
@@ -137,6 +192,12 @@ describe('Send money form flows', () => {
await fillValidForm(user);
await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
expect(
await screen.findByText(/could not submit the transfer/i),
@@ -146,11 +207,20 @@ describe('Send money form flows', () => {
await user.click(retryButton);
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
+ // Re-review, then confirm again through the dialog.
+ const retryDialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(retryDialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+
+ const resultDialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
{ timeout: 5000 },
);
+ await finishOnTransfersPage(user, resultDialog);
expect(createTransfer).toHaveBeenCalledTimes(2);
});
@@ -168,13 +238,8 @@ describe('Send money form flows', () => {
).toBeEnabled();
await fillValidForm(user);
- await user.click(screen.getByRole('button', { name: /review & send/i }));
-
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
- { timeout: 5000 },
- );
+ const resultDialog = await reviewAndConfirm(user);
+ await finishOnTransfersPage(user, resultDialog);
expect(createTransfer).toHaveBeenCalledTimes(1);
});
});