Skip to content
Draft
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ normalization so duplicate pairs such as `usdc` and `USDC` cannot be registered.

Dynamic list updates (loading → loaded / loading → empty) on the pairs, events, api-keys, and webhooks pages are wrapped in `aria-live="polite"` regions so screen-reader users are notified when content arrives. Error messages continue to use `role="alert"` for assertive announcements. A single polite region per page prevents double announcements.

The new-pair form keeps a polite `role="status"` node mounted before submission, announces the pending registration state, and briefly announces successful registration before redirecting to the pairs list. Validation and backend errors remain on the existing `role="alert"` path and clear the polite status.

## CI/CD

On every push/PR to `main`, GitHub Actions runs:
Expand Down
32 changes: 32 additions & 0 deletions src/app/pairs/new/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ describe("NewPairPage", () => {
});
});

it("keeps a polite status region mounted before submission", () => {
render(<NewPairPage />);

const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
expect(status).toBeEmptyDOMElement();
});

it("announces pending and success states before redirecting", async () => {
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
text: async () => "{}",
} as unknown as Response);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

render(<NewPairPage />);
submitPair("xlm", "usdc");

expect(screen.getByRole("status")).toHaveTextContent("Registering pair...");
await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent(
"Pair registered. Redirecting to pairs."
);
});
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/pairs");
});
});

it.each(["USD-C", "USD C", "ABCDEFGHIJKLM"])(
"rejects invalid source asset code %s with accessible field errors",
async (code) => {
Expand All @@ -63,6 +93,7 @@ describe("NewPairPage", () => {
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/ASCII letters or numbers/i);
});
expect(screen.getByRole("status")).toBeEmptyDOMElement();
expect(sourceInput).toHaveAttribute("aria-invalid", "true");
expect(sourceInput).toHaveAttribute("aria-describedby", "source-err");
expect(mockFetch).not.toHaveBeenCalled();
Expand Down Expand Up @@ -134,6 +165,7 @@ describe("NewPairPage", () => {
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/Pair already exists/i);
});
expect(screen.getByRole("status")).toBeEmptyDOMElement();
const requestInit = mockFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(requestInit.body as string)).toEqual({
source: "XLM",
Expand Down
17 changes: 17 additions & 0 deletions src/app/pairs/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { assetsDiffer } from "@/lib/quote";

const ASSET_CODE_RE = /^[A-Za-z0-9]{1,12}$/;
const ASSET_CODE_ERROR = "Use 1-12 ASCII letters or numbers.";
const SUCCESS_REDIRECT_DELAY_MS = 300;

type FormErrors = {
source?: string;
Expand All @@ -30,6 +31,7 @@ export default function NewPairPage() {
const [destination, setDestination] = useState("");
const [errors, setErrors] = useState<FormErrors>({});
const [loading, setLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState("");

const onSubmit = async (e: FormEvent) => {
e.preventDefault();
Expand All @@ -53,10 +55,12 @@ export default function NewPairPage() {

if (Object.keys(nextErrors).length > 0 || !normalizedSource || !normalizedDestination) {
setErrors(nextErrors);
setStatusMessage("");
return;
}

setErrors({});
setStatusMessage("Registering pair...");
setSource(normalizedSource);
setDestination(normalizedDestination);
setLoading(true);
Expand All @@ -65,8 +69,11 @@ export default function NewPairPage() {
source: normalizedSource,
destination: normalizedDestination,
});
setStatusMessage("Pair registered. Redirecting to pairs.");
await new Promise((resolve) => setTimeout(resolve, SUCCESS_REDIRECT_DELAY_MS));
router.push("/pairs");
} catch (err) {
setStatusMessage("");
setErrors({ form: (err as Error).message });
} finally {
setLoading(false);
Expand Down Expand Up @@ -97,6 +104,7 @@ export default function NewPairPage() {
: current.destination,
form: undefined,
}));
setStatusMessage("");
}}
error={errors.source}
/>
Expand All @@ -112,6 +120,7 @@ export default function NewPairPage() {
destination: undefined,
form: undefined,
}));
setStatusMessage("");
}}
error={errors.destination}
/>
Expand All @@ -122,6 +131,14 @@ export default function NewPairPage() {
>
{loading ? "Saving…" : "Register pair"}
</button>
<p
role="status"
aria-live="polite"
aria-atomic="true"
className="min-h-5 text-sm text-neutral-600 dark:text-neutral-400"
>
{statusMessage}
</p>
{errors.form && (
<p role="alert" className="text-sm text-rose-600">
{errors.form}
Expand Down