diff --git a/README.md b/README.md index dd15d82..4267238 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ The app runs at http://localhost:5173 by default. completed, failed), search/status/date-range filters synced to the URL, plus loading, error and empty states. - **Mock wallet** — connect a demo Stellar wallet (no network calls). +- **Keyboard navigation** — skip link, header, page content, and footer follow a + logical tab order; navigation actions use a single focus stop each. ## Tech Stack @@ -62,8 +64,9 @@ cp .env.example .env ## Testing Integration tests cover send-money validation, successful transfer submission, -pending button behavior, duplicate-submission prevention, and Transfers page -filter sync (search, status, and date-range presets such as last 7/30/90 days). +pending button behavior, duplicate-submission prevention, Transfers page filter +sync (search, status, and date-range presets), and keyboard tab order across +the main pages. ## Accessibility diff --git a/src/App.css b/src/App.css index 0c52f97..66820da 100644 --- a/src/App.css +++ b/src/App.css @@ -1,5 +1,6 @@ .app { display: flex; + flex-direction: column; min-height: 100vh; } diff --git a/src/components/Button.css b/src/components/Button.css index d9b5fb8..0ab4bf5 100644 --- a/src/components/Button.css +++ b/src/components/Button.css @@ -13,9 +13,11 @@ min-height: 44px; } -.btn:disabled { +.btn:disabled, +.btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; + pointer-events: none; } .btn-primary { diff --git a/src/components/Button.jsx b/src/components/Button.jsx index 53c5ce4..0ea8fb4 100644 --- a/src/components/Button.jsx +++ b/src/components/Button.jsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom' import './Button.css' /** @@ -7,18 +8,38 @@ import './Button.css' * @param {boolean} [props.disabled] * @param {Function} [props.onClick] * @param {'button'|'submit'} [props.type] + * @param {string} [props.to] - when set, renders as a router link styled as a button */ export default function Button({ children, variant = 'primary', disabled = false, type = 'button', - onClick + onClick, + to }) { + const className = `btn btn-${variant}` + + if (to) { + if (disabled) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) + } + return ( - - - - + + diff --git a/src/pages/NotFound.jsx b/src/pages/NotFound.jsx index 239c07c..cbc3009 100644 --- a/src/pages/NotFound.jsx +++ b/src/pages/NotFound.jsx @@ -1,4 +1,3 @@ -import { Link } from 'react-router-dom' import Button from '../components/Button.jsx' import { useDocumentTitle } from '../hooks/useDocumentTitle.js' import './NotFound.css' @@ -16,9 +15,7 @@ export default function NotFound() {

The page you are looking for does not exist or has moved.

- - - + ) } diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 5cd18a2..c320f88 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react' -import { Link, useSearchParams } from 'react-router-dom' +import { useSearchParams } from 'react-router-dom' import Chart from '../components/Chart.jsx' import TransferRow from '../components/TransferRow.jsx' import Skeleton from '../components/Skeleton.jsx' @@ -82,9 +82,7 @@ export default function Transfers() {

Your Transfers

- - - +
@@ -143,9 +141,7 @@ export default function Transfers() { hasActiveFilters ? ( ) : ( - - - + ) } /> diff --git a/test/integration/tab-order.test.jsx b/test/integration/tab-order.test.jsx new file mode 100644 index 0000000..9230878 --- /dev/null +++ b/test/integration/tab-order.test.jsx @@ -0,0 +1,128 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import App from '../../src/App.jsx' +import Button from '../../src/components/Button.jsx' + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + +function getFocusables(container = document) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => element.getAttribute('aria-hidden') !== 'true' + ) +} + +function assertNoNestedInteractiveElements(container = document) { + container.querySelectorAll('a[href], button').forEach((element) => { + expect( + element.querySelector('a[href], button, input, select, textarea') + ).toBeNull() + }) +} + +function focusIndex(elements, matcher) { + return elements.findIndex(matcher) +} + +describe('tab order across pages', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('renders navigation links as single focus stops', () => { + render( + + + + ) + + expect(screen.getByRole('link', { name: 'Send Money' })).toHaveClass('btn') + expect(screen.queryByRole('button', { name: 'Send Money' })).not.toBeInTheDocument() + }) + + it('keeps skip link before header and main content before footer on Home', async () => { + window.history.pushState({}, '', '/') + render() + + await screen.findByRole('heading', { name: /send money home/i }) + + const focusables = getFocusables() + assertNoNestedInteractiveElements() + + expect(focusables[0]).toHaveClass('skip-link') + + const sendMoneyIndex = focusIndex(focusables, (el) => + el.textContent?.includes('Send Money') + ) + const statusIndex = focusIndex(focusables, (el) => el.textContent?.trim() === 'Status') + + expect(sendMoneyIndex).toBeGreaterThan(-1) + expect(statusIndex).toBeGreaterThan(sendMoneyIndex) + }) + + it('tabs through Send Money fields in visual order', async () => { + window.history.pushState({}, '', '/send') + render() + + await screen.findByRole('heading', { name: /send money/i }) + + const user = userEvent.setup() + const focusables = getFocusables() + assertNoNestedInteractiveElements() + + const recipientIndex = focusIndex( + focusables, + (el) => el.id === 'recipient' || el.getAttribute('for') === 'recipient' + ) + const amountIndex = focusIndex(focusables, (el) => el.id === 'amount') + const fromIndex = focusIndex(focusables, (el) => el.id === 'from') + const swapIndex = focusIndex(focusables, (el) => + el.getAttribute('aria-label')?.includes('Swap currencies') + ) + const toIndex = focusIndex(focusables, (el) => el.id === 'to') + const submitIndex = focusIndex(focusables, (el) => + el.textContent?.includes('Review & Send') + ) + + expect(recipientIndex).toBeLessThan(amountIndex) + expect(amountIndex).toBeLessThan(fromIndex) + expect(fromIndex).toBeLessThan(swapIndex) + expect(swapIndex).toBeLessThan(toIndex) + expect(toIndex).toBeLessThan(submitIndex) + + await user.tab() + expect(document.activeElement).toHaveClass('skip-link') + }) + + it('keeps Transfers actions ahead of footer links', async () => { + window.history.pushState({}, '', '/transfers') + localStorage.setItem('remitflow.transfers', JSON.stringify([])) + render() + + await screen.findByRole('heading', { name: /your transfers/i }) + + const focusables = getFocusables() + assertNoNestedInteractiveElements() + + const newTransferIndex = focusIndex(focusables, (el) => + el.textContent?.includes('New Transfer') + ) + const statusIndex = focusIndex(focusables, (el) => el.textContent?.trim() === 'Status') + + expect(newTransferIndex).toBeGreaterThan(-1) + expect(statusIndex).toBeGreaterThan(newTransferIndex) + }) + + it('uses one focus stop for the NotFound recovery action', async () => { + window.history.pushState({}, '', '/missing-page') + render() + + await screen.findByRole('heading', { name: /page not found/i }) + + assertNoNestedInteractiveElements() + expect(screen.getByRole('link', { name: 'Back to Home' })).toHaveClass('btn') + expect(screen.queryByRole('button', { name: 'Back to Home' })).not.toBeInTheDocument() + }) +})