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 && (