Skip to content
Open
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
101 changes: 31 additions & 70 deletions package-lock.json

Large diffs are not rendered by default.

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

Expand All @@ -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) {
Expand All @@ -47,8 +54,12 @@ export default function Button({
onClick={onClick}
aria-label={ariaLabel}
title={title}
aria-haspopup={ariaHasPopup}
ref={ref}
>
{children}
</button>
);
}
});

export default Button;
76 changes: 71 additions & 5 deletions src/components/Modal.jsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -25,16 +85,22 @@ export default function Modal({ open, onClose, title, children }) {
if (!open) return null;

return (
<div className="modal-overlay">
<div className="modal-overlay" onKeyDown={handleKeyDown}>
<div
className="modal-panel"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
<div className="modal-header">
{title && <h3 className="modal-title">{title}</h3>}
{/* h2: dialogs sit under the page's h1 in the heading outline. */}
{title && (
<h2 className="modal-title" id={titleId}>
{title}
</h2>
)}
<button
type="button"
className="modal-close"
Expand Down
4 changes: 2 additions & 2 deletions src/components/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function Navbar() {
RemitFlow
</Link>

<nav className="navbar-links">
<nav className="navbar-links" aria-label="Primary">
<NavLink to="/" end className="navbar-link">
Home
</NavLink>
Expand Down Expand Up @@ -80,7 +80,7 @@ export default function Navbar() {
className={`navbar-drawer${menuOpen ? ' navbar-drawer--open' : ''}`}
aria-label="Mobile navigation"
>
<nav className="navbar-drawer-nav">
<nav className="navbar-drawer-nav" aria-label="Mobile">
<NavLink
to="/"
end
Expand Down
14 changes: 14 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ a {
border-radius: 4px;
}

/* Visually hidden but exposed to assistive technology. Used for live-region
announcements (route changes, form summaries) that must not be visible. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

/* ── Contrast preferences ───────────────────────────────────── */

/* Higher contrast: deepen backgrounds, lighten text, strengthen borders. */
Expand Down
53 changes: 53 additions & 0 deletions src/pages/SendMoney.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,56 @@
padding: 2rem;
text-align: center;
}

/* Confirmation / result dialog content */
.send-dialog-summary {
margin: 0 0 0.75rem;
}

.send-dialog-line {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0;
}

.send-dialog-line dt {
color: var(--color-muted);
}

.send-dialog-line dd {
margin: 0;
font-weight: 600;
word-break: break-all;
text-align: right;
}

.send-submit-status {
min-height: 1.25rem;
margin: 0.75rem 0 0;
font-size: 0.85rem;
color: var(--color-primary);
}

.send-result-status {
margin: 0 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid #14532d;
border-radius: 8px;
background: rgba(34, 197, 94, 0.12);
color: #4ade80;
}

[data-theme='light'] .send-result-status {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}

.send-dialog-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
Loading