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
9 changes: 3 additions & 6 deletions src/app/alerts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 && (
<Text fontSize="xs" color="app.errorFg" mt={2}>
{apiKeyError}
</Text>
)}
<FieldError id="api-key-error" message={apiKeyError} mt={2} />
<Text id="api-key-help" fontSize="xs" color="app.muted" mt={2}>
This endpoint requires backend authentication. The key is only kept in this page&apos;s memory — it is never stored or sent anywhere except the backend API.
</Text>
Expand Down
16 changes: 10 additions & 6 deletions src/app/farm/[poolId]/PoolDetailClient.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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 && (
<Text fontSize="xs" color="app.errorFg">
Enter an amount greater than 0.
</Text>
)}
<FieldError id={amountErrorId} message={amountError} />
</Flex>

{isPending && (
Expand Down
23 changes: 12 additions & 11 deletions src/app/farm/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -281,16 +291,7 @@ function DepositModal({
{farm.symbol}
</Text>
</Box>
{!!trimmedAmount && !amountValid && (
<Text fontSize="xs" color="app.errorFg">
Enter a positive amount with no more than 7 decimals.
</Text>
)}
{exceedsBalance && (
<Text fontSize="xs" color="app.errorFg">
Amount exceeds your Horizon XLM balance.
</Text>
)}
<FieldError id={amountErrorId} message={amountError} />
</Flex>

{isPending && (
Expand Down
51 changes: 51 additions & 0 deletions src/components/FieldError/FieldError.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ChakraProvider>
<Input
aria-label="Amount"
isInvalid={Boolean(error)}
aria-describedby="amount-error"
/>
<FieldError id="amount-error" message={error} />
</ChakraProvider>
);
}

describe("FieldError", () => {
it("keeps a polite live region mounted even when there is no error", () => {
const { container } = render(<Field error={null} />);

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(<Field error={null} />);
const region = container.querySelector("#amount-error");

rerender(<Field error="Enter an amount greater than 0." />);

// 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(<Field error="Amount exceeds your balance." />);

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.",
);
});
});
34 changes: 34 additions & 0 deletions src/components/FieldError/FieldError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { Box, Text, type TextProps } from "@chakra-ui/react";

type FieldErrorProps = Omit<TextProps, "id" | "children"> & {
/** 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 && <Text>}` 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 (
<Box id={id} aria-live="polite" aria-atomic="true">
{message && (
<Text fontSize="xs" color="app.errorFg" {...textProps}>
{message}
</Text>
)}
</Box>
);
}
24 changes: 14 additions & 10 deletions src/components/UnlockModal/UnlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -452,15 +464,7 @@ export default function UnlockModal() {
</Box>
</Flex>

{!!amount && !amountValid && (
<Text fontSize="xs" color="app.errorFg" role="alert">
{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."}
</Text>
)}
<FieldError id={amountErrorId} message={amountError} />

{amountValid && (
<Box border="1px solid #303030" borderRadius="2xl" p={3}>
Expand Down