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