diff --git a/src/app/connect/ConnectPanel.tsx b/src/app/connect/ConnectPanel.tsx index e5a5c77..a109411 100644 --- a/src/app/connect/ConnectPanel.tsx +++ b/src/app/connect/ConnectPanel.tsx @@ -71,7 +71,7 @@ export function ConnectPanel() { className="mt-4 w-full" variant="outline" onClick={connect} - disabled={connecting} + loading={connecting} > {connecting ? "Connecting..." : "Connect Freighter"} diff --git a/src/app/issues/[id]/IssueActions.tsx b/src/app/issues/[id]/IssueActions.tsx index 89f4665..0b7854d 100644 --- a/src/app/issues/[id]/IssueActions.tsx +++ b/src/app/issues/[id]/IssueActions.tsx @@ -85,17 +85,17 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
{bounty.status === "open" && ( - )} {bounty.status === "funded" && ( - )} {(bounty.status === "funded" || bounty.status === "claimed") && ( - )} diff --git a/src/app/milestones/MilestoneActions.tsx b/src/app/milestones/MilestoneActions.tsx index 2991bb5..978f9a6 100644 --- a/src/app/milestones/MilestoneActions.tsx +++ b/src/app/milestones/MilestoneActions.tsx @@ -38,7 +38,7 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) { return (
- {error && ( @@ -97,7 +97,7 @@ export function PoolDepositButton({ poolId }: { poolId: string }) { onChange={(e) => setAmount(e.target.value)} className="w-24 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-900 focus:border-indigo-400 focus:outline-none dark:border-slate-800 dark:bg-slate-900 dark:text-white" /> - {error && ( diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index d7811d6..a25c91b 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -34,6 +34,7 @@ export function Avatar({ export function AvatarStack({ seeds, max = 5 }: { seeds: string[]; max?: number }) { const shown = seeds.slice(0, max); const rest = seeds.length - shown.length; + const hidden = seeds.slice(max); return (
{shown.map((seed, i) => ( @@ -45,7 +46,11 @@ export function AvatarStack({ seeds, max = 5 }: { seeds: string[]; max?: number /> ))} {rest > 0 && ( - + +{rest} )} diff --git a/src/components/ui/Button.test.tsx b/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..1c1acff --- /dev/null +++ b/src/components/ui/Button.test.tsx @@ -0,0 +1,44 @@ +/** + * Button.test.tsx (#211) + * + * Covers the `loading` prop: it should set aria-busy, force disabled + * (even when `disabled` isn't separately passed), and render a spinner — + * standardizing the pattern every async-action call site previously + * reimplemented independently with no aria-busy at all. + */ + +import { render, screen } from "@testing-library/react"; +import { Button } from "./Button"; + +describe("Button — loading prop", () => { + it("is not busy or disabled by default", () => { + render(); + const button = screen.getByRole("button", { name: "Fund this bounty" }); + expect(button).not.toHaveAttribute("aria-busy"); + expect(button).not.toBeDisabled(); + }); + + it("sets aria-busy and disables the button when loading", () => { + render(); + const button = screen.getByRole("button", { name: /Confirming in wallet/ }); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button).toBeDisabled(); + }); + + it("renders a spinner when loading", () => { + const { container } = render(); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + }); + + it("renders no spinner when not loading", () => { + const { container } = render(); + expect(container.querySelector(".animate-spin")).toBeNull(); + }); + + it("stays disabled when explicitly disabled, independent of loading", () => { + render(); + const button = screen.getByRole("button", { name: "No action available" }); + expect(button).toBeDisabled(); + expect(button).not.toHaveAttribute("aria-busy"); + }); +}); diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index ef9b907..e1baf6f 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -24,16 +24,30 @@ const sizeClasses: Record = { interface ButtonProps extends ButtonHTMLAttributes { variant?: Variant; size?: Size; + /** + * Marks the button as performing an async action: sets aria-busy, forces + * disabled, and renders a small spinner. Every async-action call site in + * the app (fund/claim/refund/deposit) previously reimplemented this + * pattern independently via `disabled={pending}` + a manually swapped + * text label, with no aria-busy anywhere — a screen reader user got no + * indication anything happened until the DOM text changed (#211). + */ + loading?: boolean; } export function Button({ variant = "primary", size = "md", + loading = false, + disabled, className, + children, ...props }: ButtonProps) { return ( ); } diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index 5c34031..d7c4321 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -14,7 +14,7 @@ export function EmptyState({ return (
- +

{title}

{description}

diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 5d91de1..0bcc389 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -16,13 +16,21 @@ interface ThemeContextValue { const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState("light"); + // Lazy initializer reads the class themeInitScript already applied to + // synchronously, at mount/hydration time — not hardcoded to + // "light" and corrected a render later, which made ThemeToggle briefly + // show the wrong icon (backwards relative to the real theme) on every + // page load in dark mode (#208). Guarded for SSR, where this Client + // Component still executes once with no `document` available; the + // client's own hydration render is what actually matters here and always + // has `document` by then, since the inline script runs before React. + const [theme, setTheme] = useState(() => + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light", + ); useEffect(() => { - // Reflects the class the no-flash init script already applied to . - // eslint-disable-next-line react-hooks/set-state-in-effect - setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light"); - // Listen for system color scheme changes when no explicit preference is set. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemThemeChange = (e: MediaQueryListEvent) => {