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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}

Expand Down
4 changes: 3 additions & 1 deletion src/components/Button.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 23 additions & 2 deletions src/components/Button.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Link } from 'react-router-dom'
import './Button.css'

/**
Expand All @@ -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 (
<span className={className} aria-disabled="true">
{children}
</span>
)
}

return (
<Link to={to} className={className}>
{children}
</Link>
)
}

return (
<button
type={type}
className={`btn btn-${variant}`}
className={className}
disabled={disabled}
onClick={onClick}
>
Expand Down
11 changes: 4 additions & 7 deletions src/pages/Home.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Link } from 'react-router-dom'
import Button from '../components/Button.jsx'
import { POPULAR_CORRIDORS, getCurrency } from '../constants/currencies.js'
import { useDocumentTitle } from '../hooks/useDocumentTitle.js'
Expand Down Expand Up @@ -40,12 +39,10 @@ export default function Home() {
seconds, with low and transparent fees.
</p>
<div className="hero-actions">
<Link to="/send">
<Button>Send Money</Button>
</Link>
<Link to="/transfers">
<Button variant="secondary">View Transfers</Button>
</Link>
<Button to="/send">Send Money</Button>
<Button to="/transfers" variant="secondary">
View Transfers
</Button>
</div>
</section>

Expand Down
5 changes: 1 addition & 4 deletions src/pages/NotFound.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,9 +15,7 @@ export default function NotFound() {
<p className="not-found-text">
The page you are looking for does not exist or has moved.
</p>
<Link to="/">
<Button>Back to Home</Button>
</Link>
<Button to="/">Back to Home</Button>
</div>
)
}
10 changes: 3 additions & 7 deletions src/pages/Transfers.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -82,9 +82,7 @@ export default function Transfers() {
<div className="transfers">
<div className="transfers-header">
<h1 className="page-title">Your Transfers</h1>
<Link to="/send">
<Button>New Transfer</Button>
</Link>
<Button to="/send">New Transfer</Button>
</div>

<div className="transfers-filters">
Expand Down Expand Up @@ -143,9 +141,7 @@ export default function Transfers() {
hasActiveFilters ? (
<Button onClick={() => setSearchParams({})}>Clear filters</Button>
) : (
<Link to="/send">
<Button>Send your first transfer</Button>
</Link>
<Button to="/send">Send your first transfer</Button>
)
}
/>
Expand Down
128 changes: 128 additions & 0 deletions test/integration/tab-order.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<Button to="/send">Send Money</Button>
</MemoryRouter>
)

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(<App />)

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(<App />)

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(<App />)

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(<App />)

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()
})
})
Loading