+
+
+
+ {accessibleDescription}
+
{label && (
{label}
)}
+ {!renderError && !hasInvalidSize && (
+
+ )}
);
}
diff --git a/src/components/ui/PieChart.test.tsx b/src/components/ui/PieChart.test.tsx
index bb7eafea..319d1b34 100644
--- a/src/components/ui/PieChart.test.tsx
+++ b/src/components/ui/PieChart.test.tsx
@@ -170,6 +170,53 @@ describe("PieChart", () => {
});
});
+ describe("keyboard accessibility of slices (#953)", () => {
+ it("makes every slice focusable and exposes it as a graphics-symbol", () => {
+ const { container } = render(
);
+ const paths = container.querySelectorAll("path");
+
+ expect(paths).toHaveLength(3);
+ paths.forEach((path) => {
+ expect(path).toHaveAttribute("tabindex", "0");
+ expect(path).toHaveAttribute("role", "graphics-symbol");
+ });
+ });
+
+ it("labels each slice with its name and percentage for screen readers", () => {
+ const { container } = render(
);
+ const paths = container.querySelectorAll("path");
+
+ expect(paths[0]).toHaveAttribute(
+ "aria-label",
+ "Stellar Lumens: 60.0%",
+ );
+ expect(paths[1]).toHaveAttribute("aria-label", "USD Coin: 30.0%");
+ expect(paths[2]).toHaveAttribute("aria-label", "Aqua: 10.0%");
+ });
+
+ it("applies a visible keyboard focus ring to each slice", () => {
+ const { container } = render(
);
+ const path = container.querySelector("path")!;
+ const className = path.getAttribute("class") ?? "";
+
+ expect(className).toContain("focus-visible:outline-none");
+ expect(className).toContain("focus-visible:ring-2");
+ expect(className).toContain("focus-visible:ring-brand");
+ });
+
+ it("blurs the tooltip when a focused slice loses focus", () => {
+ const { container } = render(
);
+ const paths = container.querySelectorAll("path");
+
+ fireEvent.focus(paths[0]);
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
+ "Stellar Lumens: 60.0%",
+ );
+ fireEvent.blur(paths[0]);
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+ });
+
describe("slice tooltips and dynamic repositioning", () => {
it("renders title tag inside each path for native fallback", () => {
const { container } = render(
);
diff --git a/src/components/ui/PieChart.tsx b/src/components/ui/PieChart.tsx
index 9e57d74f..7d0ea98d 100644
--- a/src/components/ui/PieChart.tsx
+++ b/src/components/ui/PieChart.tsx
@@ -341,7 +341,10 @@ export function PieChart({
tabIndex={0}
role="graphics-symbol"
aria-label={`${slice.label}: ${pct.toFixed(1)}%`}
- className="transition-opacity duration-150 hover:opacity-80 focus:opacity-80 focus:outline-none cursor-pointer"
+ // A keyboard user tabs onto each slice; the focus ring (plus a
+ // brand-coloured stroke, since box-shadow rings don't render
+ // reliably on SVG) makes the focused slice obvious.
+ className="transition-opacity duration-150 hover:opacity-80 focus:opacity-80 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-base focus-visible:stroke-brand focus-visible:stroke-2"
onMouseEnter={(e) => handlePointerMove(e, slice, pct)}
onMouseMove={(e) => handlePointerMove(e, slice, pct)}
onFocus={() => handleFocus(slice, pct, midAngle)}
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
index 470bb01b..0c2b510e 100644
--- a/src/components/ui/Toast.tsx
+++ b/src/components/ui/Toast.tsx
@@ -1,7 +1,10 @@
import * as ToastPrimitive from "@radix-ui/react-toast";
import { useEffect, useRef } from "react";
-import type { Toast as ToastType } from "@/context/ToastContext";
+import {
+ type Toast as ToastType,
+ useOptionalToast,
+} from "@/context/ToastContext";
import { cn } from "@/lib/utils";
function CheckIcon({ className }: { className?: string }) {
@@ -47,6 +50,10 @@ const iconMap = {
interface ToastItemProps {
toast: ToastType;
onDismiss: (id: string) => void;
+ /** Pause the countdown while the pointer hovers the toast. */
+ onPause?: (id: string) => void;
+ /** Resume the countdown once the pointer leaves the toast. */
+ onResume?: (id: string) => void;
}
/**
@@ -61,16 +68,31 @@ function focusAdjacentToast(current: HTMLElement) {
if (next?.hasAttribute("data-toast-id")) next.focus();
}
-function ToastItem({ toast, onDismiss }: ToastItemProps) {
+function ToastItem({ toast, onDismiss, onPause, onResume }: ToastItemProps) {
const { icon: Icon, bg, fg } = iconMap[toast.type];
return (
{ if (!open) onDismiss(toast.id); }}
+ // Hovering or focusing a toast freezes its countdown so the user has
+ // time to read it or interact with its action button.
+ onMouseEnter={() => onPause?.(toast.id)}
+ onMouseLeave={() => onResume?.(toast.id)}
+ onFocus={() => onPause?.(toast.id)}
+ onBlur={(e) => {
+ // React bubbles focus/blur, so moving focus between the toast and one
+ // of its own buttons fires blur here too. Only resume once focus has
+ // actually left the toast subtree.
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
+ onResume?.(toast.id);
+ }
+ }}
onKeyDown={(e) => {
if (e.key !== "Escape") return;
// Escape from anywhere inside this toast (the toast itself, its
@@ -137,6 +159,10 @@ interface ToastContainerProps {
export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
const viewportRef = useRef(null);
+ // The timer lives in the provider, so read the pause/resume controls from
+ // context rather than threading them through every call site. Optional so
+ // the container can still be rendered standalone (outside a provider).
+ const toast = useOptionalToast();
// When the stack overflows, keep the newest toast (last, nearest the
// corner) in view rather than leaving it scrolled off the bottom.
@@ -149,8 +175,14 @@ export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
return (
- {toasts.map((toast) => (
-
+ {toasts.map((item) => (
+
))}
{/*
Toasts are laid out in normal flow in a single column with a fixed gap,
diff --git a/src/context/SorokitContext.ts b/src/context/SorokitContext.ts
index d4522cf4..b9dbc68c 100644
--- a/src/context/SorokitContext.ts
+++ b/src/context/SorokitContext.ts
@@ -12,6 +12,13 @@ export interface SorokitState {
client: SorokitClient;
address: string | null;
walletName: string | null;
+ /**
+ * Ids of supported wallet extensions currently detected on `window`. Seeded
+ * on mount and refreshed when an extension injects late (`stellar#initialized`
+ * plus a short poll), so the connect UI can pick up a wallet that wasn't
+ * present when the page first rendered.
+ */
+ detectedWallets?: string[];
isHardware?: boolean;
isConnected: boolean;
isConnecting: boolean;
diff --git a/src/context/SorokitProvider.test.tsx b/src/context/SorokitProvider.test.tsx
index 35f3c731..2fac0275 100644
--- a/src/context/SorokitProvider.test.tsx
+++ b/src/context/SorokitProvider.test.tsx
@@ -1,13 +1,30 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StrictMode, useEffect, useRef, useState } from "react";
-import { beforeEach,describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProvider } from "@/__tests__/utils";
import { getClient } from "@/lib/client";
+import { createMockClient, MOCK_ADDRESS } from "@/lib/mock-client";
import { SorokitProvider } from "./SorokitProvider";
import { useSorokit } from "./useSorokit";
+const WALLET_GLOBALS = [
+ "freighter",
+ "isFreighter",
+ "xbull",
+ "xBull",
+ "albedo",
+ "lobstr",
+] as const;
+
+const DetectedWalletsProbe = () => {
+ const { detectedWallets } = useSorokit();
+ return (
+ {(detectedWallets ?? []).join(",")}
+ );
+};
+
const TestComponent = () => {
const { address, account, balances, connectWallet, disconnectWallet, switchNetwork, refreshAccount, clearCustomNetworks, customNetworks, isLoadingAccount, isDisconnecting, error, walletError, errorHistory } = useSorokit();
@@ -87,6 +104,14 @@ describe("SorokitProvider", () => {
} as unknown as ReturnType;
});
+ afterEach(() => {
+ // Wallet globals are set directly on `window`, so a test that injects one
+ // would otherwise leak into the next test's detection snapshot.
+ for (const key of WALLET_GLOBALS) {
+ delete (window as unknown as Record)[key];
+ }
+ });
+
it("disconnectWallet clears address, account, balances, and error", async () => {
mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account error" });
mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances error" });
@@ -1260,4 +1285,171 @@ describe("SorokitProvider", () => {
);
});
});
+
+ describe("delayed wallet-extension detection (#954)", () => {
+ it("detects an extension already injected before mount", () => {
+ (window as unknown as Record).lobstr = {};
+
+ renderWithProvider(, { client: mockClient });
+
+ expect(screen.getByTestId("detected-wallets")).toHaveTextContent("lobstr");
+ });
+
+ it("detects an extension injected after mount when it fires stellar#initialized", async () => {
+ renderWithProvider(, { client: mockClient });
+ expect(screen.getByTestId("detected-wallets")).toHaveTextContent("");
+
+ await act(async () => {
+ (window as unknown as Record).freighter = {};
+ window.dispatchEvent(new Event("stellar#initialized"));
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("detected-wallets")).toHaveTextContent(
+ "freighter",
+ ),
+ );
+ });
+
+ it("polls briefly for an extension that injects without firing an event", async () => {
+ vi.useFakeTimers();
+ renderWithProvider(, { client: mockClient });
+ expect(screen.getByTestId("detected-wallets")).toHaveTextContent("");
+
+ await act(async () => {
+ (window as unknown as Record).albedo = {};
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(screen.getByTestId("detected-wallets")).toHaveTextContent("albedo");
+ });
+
+ it("stops polling once the detection window elapses", async () => {
+ vi.useFakeTimers();
+ const setSpy = vi.spyOn(window, "setInterval");
+ renderWithProvider(, { client: mockClient });
+
+ expect(setSpy).toHaveBeenCalled();
+
+ await act(async () => {
+ vi.advanceTimersByTime(200 * 12);
+ });
+
+ // Injecting after the poll window is up is not detected without an event.
+ await act(async () => {
+ (window as unknown as Record).xbull = {};
+ vi.advanceTimersByTime(200 * 12);
+ });
+
+ expect(screen.getByTestId("detected-wallets")).toHaveTextContent("");
+ });
+ });
+
+ describe("network switch cancellation (#954)", () => {
+ it("cancels in-flight account requests on a switch without surfacing an error", async () => {
+ let rejectBalances: ((reason?: unknown) => void) | undefined;
+ mockClient.account.getAccount = vi.fn(() => new Promise(() => {}));
+ mockClient.account.getBalances = vi.fn(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectBalances = reject;
+ }),
+ );
+
+ renderWithProvider(, { client: mockClient });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Connect"));
+ });
+
+ // Make sure the account query actually started before the switch lands.
+ await waitFor(() =>
+ expect(mockClient.account.getBalances).toHaveBeenCalled(),
+ );
+
+ // A switch lands while the account query is still in flight.
+ await act(async () => {
+ fireEvent.click(screen.getByText("Switch"));
+ });
+ expect(mockClient.network.switchNetwork).toHaveBeenCalled();
+
+ // Reject the superseded requests; the provider must swallow these rather
+ // than let them become unhandled promise rejections.
+ await act(async () => {
+ rejectBalances?.(new Error("aborted"));
+ });
+
+ expect(screen.getByTestId("error")).toHaveTextContent("none");
+ expect(screen.getByTestId("address")).toHaveTextContent("none");
+ });
+
+ it("surfaces a genuine rejection for the current query", async () => {
+ mockClient.account.getAccount = vi
+ .fn()
+ .mockRejectedValue(new Error("rpc unavailable"));
+
+ renderWithProvider(, { client: mockClient });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Connect"));
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("error")).toHaveTextContent("rpc unavailable"),
+ );
+ });
+ });
+
+ describe("headless mock client injection (#954)", () => {
+ it("uses an injected mock client when no wallet extension is present", async () => {
+ const client = createMockClient();
+
+ await act(async () => {
+ renderWithProvider(, { client });
+ });
+
+ expect(getClient()).toBe(client);
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Connect"));
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("address")).toHaveTextContent(MOCK_ADDRESS),
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("account")).toHaveTextContent("123456789"),
+ );
+ expect(screen.getByTestId("balances")).toHaveTextContent("1");
+ expect(screen.getByTestId("error")).toHaveTextContent("none");
+ });
+
+ it("re-points an injected mock client factory at the new network on switch", async () => {
+ const headlessClient = createMockClient();
+ const switchedClient = createMockClient();
+ const createClientForNetwork = vi.fn().mockReturnValue(switchedClient);
+
+ await act(async () => {
+ render(
+
+
+ ,
+ );
+ });
+
+ expect(getClient()).toBe(headlessClient);
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Switch"));
+ });
+
+ expect(createClientForNetwork).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "testnet" }),
+ );
+ expect(getClient()).toBe(switchedClient);
+ });
+ });
});
diff --git a/src/context/SorokitProvider.tsx b/src/context/SorokitProvider.tsx
index ecba63d4..4bbf0f01 100644
--- a/src/context/SorokitProvider.tsx
+++ b/src/context/SorokitProvider.tsx
@@ -22,6 +22,32 @@ export const BUILTIN_NETWORKS: NetworkName[] = [
"custom",
];
+/**
+ * Wallet-extension globals, keyed by the id we report to consumers. Outer
+ * arrays carry aliases because extensions don't agree on casing (xBull vs
+ * xbull, freighter vs isFreighter).
+ */
+const WALLET_GLOBAL_KEYS: ReadonlyArray = [
+ ["freighter", ["freighter", "isFreighter"]],
+ ["xbull", ["xbull", "xBull"]],
+ ["albedo", ["albedo"]],
+ ["lobstr", ["lobstr"]],
+];
+
+/**
+ * #954 — snapshot which supported wallet extensions have injected their
+ * globals. Extensions commonly inject a few milliseconds *after* DOM load,
+ * so this is called repeatedly (on mount, on the `stellar#initialized`
+ * event, and on a short poll) rather than once.
+ */
+function detectInjectedWallets(): string[] {
+ if (typeof window === "undefined") return [];
+ const win = window as unknown as Record;
+ return WALLET_GLOBAL_KEYS.filter(([, keys]) =>
+ keys.some((key) => Boolean(win[key])),
+ ).map(([id]) => id);
+}
+
export function SorokitProvider({
client,
onError,
@@ -59,6 +85,12 @@ export function SorokitProvider({
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [errorSeverity, setErrorSeverity] = useState<"info" | "error">("error");
const [errorHistory, setErrorHistory] = useState([]);
+ // #954 — ids of wallet extensions currently detected on `window`. Seeded
+ // synchronously so an extension injected before mount is visible on the
+ // first render, then updated as late injections land.
+ const [detectedWallets, setDetectedWallets] = useState(() =>
+ detectInjectedWallets(),
+ );
const error = accountError ?? networkError ?? walletError ?? null;
@@ -67,6 +99,43 @@ export function SorokitProvider({
onErrorRef.current = onError;
}, [onError]);
+ // #954 — detect wallet extensions that inject after mount. Some announce
+ // readiness with `stellar#initialized`; others just assign their global a
+ // few frames later, so also poll briefly before giving up.
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ let cancelled = false;
+
+ const refresh = () => {
+ if (cancelled) return;
+ const next = detectInjectedWallets();
+ setDetectedWallets((prev) =>
+ prev.length === next.length && prev.every((w, i) => w === next[i])
+ ? prev
+ : next,
+ );
+ };
+
+ refresh();
+ window.addEventListener("stellar#initialized", refresh);
+ window.addEventListener("stellar:initialized", refresh);
+
+ let attempts = 0;
+ const maxAttempts = 10;
+ const intervalId = window.setInterval(() => {
+ attempts += 1;
+ refresh();
+ if (attempts >= maxAttempts) window.clearInterval(intervalId);
+ }, 200);
+
+ return () => {
+ cancelled = true;
+ window.removeEventListener("stellar#initialized", refresh);
+ window.removeEventListener("stellar:initialized", refresh);
+ window.clearInterval(intervalId);
+ };
+ }, []);
+
// #353 — connectWallet/disconnectWallet/switchNetwork/refreshAccount read
// the client through this ref instead of closing over the `client` prop
// directly, so their identities stay stable even when a parent passes a
@@ -94,6 +163,20 @@ export function SorokitProvider({
// slower earlier response can't land after a newer one and show stale data.
const isRefreshingRef = useRef(false);
+ // #954 — cancels the in-flight account/balance query. The client API has no
+ // AbortSignal parameter, so the controller is used as the provider's own
+ // cancellation flag; what matters is that a superseded query (a rapid network
+ // switch, a disconnect, a new address) has its rejection checked and
+ // swallowed instead of surfacing as an unhandled promise rejection.
+ const accountRequestRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ accountRequestRef.current?.abort();
+ accountRequestRef.current = null;
+ };
+ }, []);
+
// #537 — a single source of truth for "a network switch is in flight".
// `NetworkScreen` and `NetworkSwitcher` both read this through the context,
// so controls on one surface disable while the other surface started a
@@ -184,14 +267,21 @@ export function SorokitProvider({
setAccountError(null);
let active = true;
+ // Supersede any query already in flight (address change, client change,
+ // rapid network switching) before starting a new one.
+ accountRequestRef.current?.abort();
+ const controller = new AbortController();
+ accountRequestRef.current = controller;
+
const timerId = window.setTimeout(() => {
+ if (!active || controller.signal.aborted) return;
setIsLoadingAccount(true);
Promise.all([
client.account.getAccount(address),
client.account.getBalances(address),
])
.then(([accountRes, balancesRes]) => {
- if (!active) return;
+ if (!active || controller.signal.aborted) return;
if (accountRes.data) setAccount(accountRes.data);
if (balancesRes.data) setBalances(balancesRes.data);
const combined = [accountRes.error, balancesRes.error]
@@ -210,13 +300,27 @@ export function SorokitProvider({
reportError(balancesRes.error, "account", "error");
}
})
+ .catch((e) => {
+ // A rejected request from a query that has since been superseded is
+ // expected (e.g. a network switch aborted it); only a rejection for
+ // the *current* query is a real error worth surfacing.
+ if (!active || controller.signal.aborted) return;
+ const message =
+ e instanceof Error ? e.message : "Failed to load account data";
+ setAccountError(message);
+ reportError(message, "account", "error");
+ })
.finally(() => {
- if (active) setIsLoadingAccount(false);
+ if (active && !controller.signal.aborted) setIsLoadingAccount(false);
});
}, 0);
return () => {
active = false;
+ controller.abort();
+ if (accountRequestRef.current === controller) {
+ accountRequestRef.current = null;
+ }
window.clearTimeout(timerId);
};
}, [address, client, reportError]);
@@ -323,6 +427,11 @@ export function SorokitProvider({
setIsSwitchingNetwork(true);
try {
+ // #954 — cancel any in-flight account/balance query before the switch
+ // builds a new client, so the old network's responses can't land on the
+ // new one (and their rejections don't surface as unhandled).
+ accountRequestRef.current?.abort();
+ accountRequestRef.current = null;
resetTransactionWatchers();
const { data, error } = await clientRef.current.network.switchNetwork(param);
if (error) {
@@ -410,11 +519,19 @@ export function SorokitProvider({
if (!address || isRefreshingRef.current) return;
isRefreshingRef.current = true;
setIsLoadingAccount(true);
+
+ // Supersede the previous query and register this one so a network switch
+ // (which aborts `accountRequestRef`) cancels it cleanly.
+ accountRequestRef.current?.abort();
+ const controller = new AbortController();
+ accountRequestRef.current = controller;
+
try {
const [accountRes, balancesRes] = await Promise.all([
clientRef.current.account.getAccount(address),
clientRef.current.account.getBalances(address),
]);
+ if (controller.signal.aborted) return;
if (accountRes.data) setAccount(accountRes.data);
if (balancesRes.data) setBalances(balancesRes.data);
if (accountRes.error && balancesRes.error) {
@@ -428,7 +545,19 @@ export function SorokitProvider({
} else if (balancesRes.error) {
reportError(balancesRes.error, "account", "error");
}
+ } catch (e) {
+ // Swallow a rejection for a query that has been superseded (e.g. the
+ // network switched mid-refresh); surface only genuine failures.
+ if (controller.signal.aborted) return;
+ reportError(
+ e instanceof Error ? e.message : "Failed to refresh account data",
+ "account",
+ "error",
+ );
} finally {
+ if (accountRequestRef.current === controller) {
+ accountRequestRef.current = null;
+ }
setIsLoadingAccount(false);
isRefreshingRef.current = false;
}
@@ -439,6 +568,7 @@ export function SorokitProvider({
client: currentClient,
address,
walletName,
+ detectedWallets,
isConnected: !!address,
isConnecting,
isLoading: isConnecting || isLoadingAccount,
@@ -470,6 +600,7 @@ export function SorokitProvider({
currentClient,
address,
walletName,
+ detectedWallets,
isConnecting,
isDisconnecting,
isLoadingAccount,
diff --git a/src/context/ToastContext.test.tsx b/src/context/ToastContext.test.tsx
index 5f770b24..e729868f 100644
--- a/src/context/ToastContext.test.tsx
+++ b/src/context/ToastContext.test.tsx
@@ -284,6 +284,106 @@ describe("ToastContext", () => {
});
});
+ describe("auto-dismiss timer pause/resume", () => {
+ it("pauses the countdown while hovered and resumes from where it left off", () => {
+ renderToasts();
+ act(() => {
+ api().info("Hover me", { duration: 1000 });
+ });
+
+ const toast = screen.getByRole("alert");
+ fireEvent.mouseEnter(toast);
+
+ // Far beyond the original duration — a paused timer must not fire.
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(api().toasts).toHaveLength(1);
+
+ // The remaining 1s is served after the pointer leaves.
+ fireEvent.mouseLeave(toast);
+ act(() => {
+ vi.advanceTimersByTime(999);
+ });
+ expect(api().toasts).toHaveLength(1);
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(api().toasts).toHaveLength(0);
+ });
+
+ it("pauses the countdown while focused and resumes on blur", () => {
+ renderToasts();
+ act(() => {
+ api().info("Focus me", { duration: 1000 });
+ });
+
+ const toast = screen.getByRole("alert");
+ act(() => toast.focus());
+
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(api().toasts).toHaveLength(1);
+
+ act(() => toast.blur());
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(api().toasts).toHaveLength(0);
+ });
+
+ it("keeps the countdown paused while hover and focus overlap", () => {
+ renderToasts();
+ act(() => {
+ api().info("Both", { duration: 1000 });
+ });
+
+ const toast = screen.getByRole("alert");
+ // Pointer enters, then keyboard focus arrives; releasing only one of the
+ // two sources must not let the timer resume.
+ fireEvent.mouseEnter(toast);
+ act(() => toast.focus());
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(api().toasts).toHaveLength(1);
+
+ fireEvent.mouseLeave(toast);
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(api().toasts).toHaveLength(1);
+
+ act(() => toast.blur());
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(api().toasts).toHaveLength(0);
+ });
+
+ it("pauseToast/resumeToast are exposed on the context", () => {
+ renderToasts();
+ expect(typeof api().pauseToast).toBe("function");
+ expect(typeof api().resumeToast).toBe("function");
+ });
+
+ it("sticky toasts (duration 0) have no countdown to pause", () => {
+ renderToasts();
+ act(() => {
+ api().info("Sticky", { duration: 0 });
+ });
+
+ const toast = screen.getByRole("alert");
+ fireEvent.mouseEnter(toast);
+ fireEvent.mouseLeave(toast);
+ act(() => {
+ vi.advanceTimersByTime(60_000);
+ });
+ expect(api().toasts).toHaveLength(1);
+ });
+ });
+
describe("stacking", () => {
it("renders every toast in a single viewport, oldest to newest", () => {
renderToasts();
diff --git a/src/context/ToastContext.tsx b/src/context/ToastContext.tsx
index 8e9c9142..08529388 100644
--- a/src/context/ToastContext.tsx
+++ b/src/context/ToastContext.tsx
@@ -52,6 +52,14 @@ interface ToastContextValue {
dismissAll: () => void;
/** @deprecated Alias of `dismissAll`, kept for backwards compatibility. */
clearAll: () => void;
+ /**
+ * Pause a toast's auto-dismiss countdown (e.g. while the pointer hovers it
+ * or it holds keyboard focus). Pauses are reference-counted so overlapping
+ * hover/focus sources don't resume the timer early.
+ */
+ pauseToast: (id: string) => void;
+ /** Resume a paused toast's countdown from where it left off. */
+ resumeToast: (id: string) => void;
}
/* ------------------------------------------------------------------ */
@@ -67,23 +75,63 @@ const ToastContext = createContext(null);
let toastCounter = 0;
const DEFAULT_DURATION = 5000;
+/**
+ * Per-toast countdown state. `remaining` is only meaningful while a toast is
+ * paused: it holds the milliseconds left when the countdown was frozen.
+ * `pauseCount` is reference-counted because a toast can be paused by more
+ * than one source at once (pointer hover *and* keyboard focus), and it must
+ * only resume once every source has released it.
+ */
+interface ToastTimer {
+ timeout: ReturnType | null;
+ remaining: number;
+ startedAt: number;
+ pauseCount: number;
+}
+
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState([]);
const [unreadIds, setUnreadIds] = useState([]);
- const timersRef = useRef