From 6a728999dcaf1d0020e8146f1e130048ea2e2245 Mon Sep 17 00:00:00 2001 From: DooseWayo <“doosewayo94@gmail.com”> Date: Sat, 17 Oct 2026 08:49:15 +0100 Subject: [PATCH] fix: announce form validation errors to screen readers Inline validation errors were conditionally mounted plain text, so screen readers never announced them (a live region must already exist before its content changes). Add a FieldError component that keeps a polite aria-live region mounted and wire it into the unlock, deposit (farm list and pool detail) and alerts API key fields, linking each input to its error via aria-describedby and isInvalid. Co-Authored-By: Claude Opus 5.5 --- src/app/alerts/page.tsx | 9 ++-- src/app/farm/[poolId]/PoolDetailClient.tsx | 16 +++--- src/app/farm/page.tsx | 23 +++++---- src/components/FieldError/FieldError.test.tsx | 51 +++++++++++++++++++ src/components/FieldError/FieldError.tsx | 34 +++++++++++++ src/components/UnlockModal/UnlockModal.tsx | 24 +++++---- 6 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 src/components/FieldError/FieldError.test.tsx create mode 100644 src/components/FieldError/FieldError.tsx diff --git a/src/app/alerts/page.tsx b/src/app/alerts/page.tsx index 10d61ee..0cb4084 100644 --- a/src/app/alerts/page.tsx +++ b/src/app/alerts/page.tsx @@ -22,6 +22,7 @@ import { type Alert, } from "@/lib/backend"; import { QueryErrorAlert } from "@/components/QueryErrorAlert/QueryErrorAlert"; +import FieldError from "@/components/FieldError/FieldError"; export default function AlertsPage() { const toast = useToast(); @@ -142,15 +143,11 @@ export default function AlertsPage() { bg="app.inputBg" isInvalid={Boolean(apiKeyError)} aria-invalid={Boolean(apiKeyError)} - aria-describedby="api-key-help" + aria-describedby="api-key-error api-key-help" _hover={{ borderColor: apiKeyError ? "app.errorFg" : "app.accent" }} _focus={{ boxShadow: "none", borderColor: apiKeyError ? "app.errorFg" : "app.accent" }} /> - {apiKeyError && ( - - {apiKeyError} - - )} + This endpoint requires backend authentication. The key is only kept in this page's memory — it is never stored or sent anywhere except the backend API. diff --git a/src/app/farm/[poolId]/PoolDetailClient.tsx b/src/app/farm/[poolId]/PoolDetailClient.tsx index 7be5552..084fda2 100644 --- a/src/app/farm/[poolId]/PoolDetailClient.tsx +++ b/src/app/farm/[poolId]/PoolDetailClient.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import NextLink from "next/link"; import TransactionConfirmModal from "@/components/TransactionConfirmModal/TransactionConfirmModal"; +import FieldError from "@/components/FieldError/FieldError"; import { Alert, AlertIcon, @@ -209,6 +210,11 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { const displayAmount = parseFloat(rawAmount); const amountValid = Number.isFinite(displayAmount) && displayAmount > 0; + const amountError = + rawAmount !== "0" && rawAmount !== "" && !amountValid + ? "Enter an amount greater than 0." + : null; + const amountErrorId = useId(); const isPending = isDepositPending(flow.step); const lockPeriodLabel = pool ? pool.minLockPeriod >= 86400 @@ -441,6 +447,8 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { value={rawAmount} onChange={(e) => setRawAmount(e.target.value)} isDisabled={isPending} + isInvalid={Boolean(amountError)} + aria-describedby={amountErrorId} borderRadius="2xl" h="50px" borderColor="app.border" @@ -449,11 +457,7 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { _hover={{ borderColor: "app.accent" }} _focus={{ boxShadow: "none", borderColor: "app.accent" }} /> - {rawAmount !== "0" && rawAmount !== "" && !amountValid && ( - - Enter an amount greater than 0. - - )} + {isPending && ( diff --git a/src/app/farm/page.tsx b/src/app/farm/page.tsx index 201b80e..be8c2b5 100644 --- a/src/app/farm/page.tsx +++ b/src/app/farm/page.tsx @@ -6,6 +6,7 @@ import UnlockModal from "@/components/UnlockModal/UnlockModal"; import BoostModal from "@/components/BoostModal/BoostModal"; import Breadcrumbs from "@/components/Breadcrumbs/Breadcrumbs"; import TransactionConfirmModal from "@/components/TransactionConfirmModal/TransactionConfirmModal"; +import FieldError from "@/components/FieldError/FieldError"; import { EarningRow, MetricColumn } from "@/app/farm/EarningRow"; import { FarmPoolRow } from "@/app/farm/FarmPoolRow"; import { @@ -49,7 +50,7 @@ import { useToast, } from "@chakra-ui/react"; import { useOwnConnectButton } from "@/context/OwnConnectButtonContext"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; const ACCENT = "#4AE292"; @@ -117,6 +118,13 @@ function DepositModal({ amountValid && typeof availableBalance === "number" && numericAmount > availableBalance; + const amountError = + !!trimmedAmount && !amountValid + ? "Enter a positive amount with no more than 7 decimals." + : exceedsBalance + ? "Amount exceeds your Horizon XLM balance." + : null; + const amountErrorId = useId(); const feePreview = useLockAssetsFeePreview({ publicKey, poolContractId: selectedContractAddress, @@ -260,6 +268,8 @@ function DepositModal({ value={amount} onChange={(event) => setAmount(event.target.value)} isDisabled={isPending} + isInvalid={Boolean(amountError)} + aria-describedby={amountErrorId} borderRadius="2xl" h="50px" borderColor="app.border" @@ -281,16 +291,7 @@ function DepositModal({ {farm.symbol} - {!!trimmedAmount && !amountValid && ( - - Enter a positive amount with no more than 7 decimals. - - )} - {exceedsBalance && ( - - Amount exceeds your Horizon XLM balance. - - )} + {isPending && ( diff --git a/src/components/FieldError/FieldError.test.tsx b/src/components/FieldError/FieldError.test.tsx new file mode 100644 index 0000000..49b0d9d --- /dev/null +++ b/src/components/FieldError/FieldError.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider, Input } from "@chakra-ui/react"; +import { describe, expect, it } from "vitest"; +import FieldError from "./FieldError"; + +function Field({ error }: { error: string | null }) { + return ( + + + + + ); +} + +describe("FieldError", () => { + it("keeps a polite live region mounted even when there is no error", () => { + const { container } = render(); + + const region = container.querySelector("#amount-error"); + expect(region).not.toBeNull(); + expect(region?.getAttribute("aria-live")).toBe("polite"); + expect(region?.getAttribute("aria-atomic")).toBe("true"); + expect(region?.textContent).toBe(""); + }); + + it("renders the message inside the same region node when an error appears", () => { + const { container, rerender } = render(); + const region = container.querySelector("#amount-error"); + + rerender(); + + // Same node, so AT that was already watching it announces the change. + expect(container.querySelector("#amount-error")).toBe(region); + expect(region?.textContent).toBe("Enter an amount greater than 0."); + }); + + it("is referenced by the input's accessible description", () => { + render(); + + const input = screen.getByLabelText("Amount"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + const describedBy = input.getAttribute("aria-describedby") ?? ""; + expect(document.getElementById(describedBy)?.textContent).toBe( + "Amount exceeds your balance.", + ); + }); +}); diff --git a/src/components/FieldError/FieldError.tsx b/src/components/FieldError/FieldError.tsx new file mode 100644 index 0000000..94cdd4f --- /dev/null +++ b/src/components/FieldError/FieldError.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Box, Text, type TextProps } from "@chakra-ui/react"; + +type FieldErrorProps = Omit & { + /** Referenced by the input's `aria-describedby`. */ + id: string; + /** Validation message; falsy renders an empty (silent) region. */ + message?: string | null; +}; + +/** + * Inline form validation error that screen readers announce as it appears + * or changes. The aria-live wrapper stays mounted even with no error: + * screen readers only announce changes inside a region that already exists, + * so mounting the region together with its text (the previous + * `{error && }` pattern) is silently skipped by most AT. + * + * Polite rather than assertive so validation-as-you-type doesn't cut off + * the user's own keystroke echo. Pair with `isInvalid` (Chakra derives + * `aria-invalid` from it and ignores a raw `aria-invalid` prop) and + * `aria-describedby={id}` on the input so the error is also read on focus. + */ +export default function FieldError({ id, message, ...textProps }: FieldErrorProps) { + return ( + + {message && ( + + {message} + + )} + + ); +} diff --git a/src/components/UnlockModal/UnlockModal.tsx b/src/components/UnlockModal/UnlockModal.tsx index 3c75bfc..a9a56fd 100644 --- a/src/components/UnlockModal/UnlockModal.tsx +++ b/src/components/UnlockModal/UnlockModal.tsx @@ -4,6 +4,7 @@ import { poolContractId, stellarNetwork, } from "@/config"; +import FieldError from "@/components/FieldError/FieldError"; import TransactionConfirmModal from "@/components/TransactionConfirmModal/TransactionConfirmModal"; import { useErrorHandler } from "@/context/ErrorContext"; import { useStellarWallet } from "@/context/StellarWalletContext"; @@ -38,7 +39,7 @@ import { Spinner, Text, } from "@chakra-ui/react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; type UnlockStep = | "idle" @@ -89,6 +90,15 @@ export default function UnlockModal() { numericAmount >= 0.01 && !!position && numericAmount <= position.lockedAmount; + const amountError = + !!amount && !amountValid && position + ? numericAmount < 0.01 + ? `Minimum unlock amount is 0.01 ${position.symbol}.` + : numericAmount > position.lockedAmount + ? `Amount exceeds locked balance of ${position.lockedAmount} ${position.symbol}.` + : "Enter a valid amount." + : null; + const amountErrorId = useId(); const feePreview = useUnlockAssetsFeePreview({ publicKey, @@ -400,6 +410,8 @@ export default function UnlockModal() { value={amount} onChange={(e) => setAmount(e.target.value)} isDisabled={!canUnlock || isProcessing} + isInvalid={Boolean(amountError)} + aria-describedby={amountErrorId} borderColor="app.border" bg="app.inputBg" color="app.text" @@ -452,15 +464,7 @@ export default function UnlockModal() { - {!!amount && !amountValid && ( - - {numericAmount < 0.01 - ? `Minimum unlock amount is 0.01 ${position.symbol}.` - : numericAmount > position.lockedAmount - ? `Amount exceeds locked balance of ${position.lockedAmount} ${position.symbol}.` - : "Enter a valid amount."} - - )} + {amountValid && (