Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/app/connect/ConnectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function ConnectPanel() {
className="mt-4 w-full"
variant="outline"
onClick={connect}
disabled={connecting}
loading={connecting}
>
{connecting ? "Connecting..." : "Connect Freighter"}
</Button>
Expand Down
6 changes: 3 additions & 3 deletions src/app/issues/[id]/IssueActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,17 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
<div className="mt-10">
<div className="flex flex-wrap gap-3">
{bounty.status === "open" && (
<Button size="lg" onClick={handleFund} disabled={pending || connecting}>
<Button size="lg" onClick={handleFund} loading={pending || connecting}>
{pending || connecting ? "Confirming in wallet..." : "Fund this bounty"}
</Button>
)}
{bounty.status === "funded" && (
<Button size="lg" onClick={handleClaim} disabled={pending}>
<Button size="lg" onClick={handleClaim} loading={pending}>
{pending ? "Claiming..." : "Claim this issue"}
</Button>
)}
{(bounty.status === "funded" || bounty.status === "claimed") && (
<Button size="lg" variant="outline" onClick={handleRefund} disabled={pending}>
<Button size="lg" variant="outline" onClick={handleRefund} loading={pending}>
Refund sponsor
</Button>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/app/milestones/MilestoneActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) {

return (
<div className="mt-4">
<Button size="sm" variant="outline" onClick={handleFund} disabled={pending || connecting}>
<Button size="sm" variant="outline" onClick={handleFund} loading={pending || connecting}>
{pending || connecting ? "Confirming in wallet..." : "Fund milestone"}
</Button>
{error && (
Expand Down Expand Up @@ -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"
/>
<Button size="sm" variant="outline" onClick={handleDeposit} disabled={pending || connecting}>
<Button size="sm" variant="outline" onClick={handleDeposit} loading={pending || connecting}>
{pending || connecting ? "Confirming..." : "Deposit"}
</Button>
{error && (
Expand Down
7 changes: 6 additions & 1 deletion src/components/ui/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex items-center">
{shown.map((seed, i) => (
Expand All @@ -45,7 +46,11 @@ export function AvatarStack({ seeds, max = 5 }: { seeds: string[]; max?: number
/>
))}
{rest > 0 && (
<span className="-ml-2 flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 text-[11px] font-medium text-slate-600 ring-2 ring-white dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-950">
<span
aria-label={`+${rest} more contributors`}
title={hidden.join(", ")}
className="-ml-2 flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 text-[11px] font-medium text-slate-600 ring-2 ring-white dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-950"
>
+{rest}
</span>
)}
Expand Down
44 changes: 44 additions & 0 deletions src/components/ui/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Button>Fund this bounty</Button>);
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(<Button loading>Confirming in wallet...</Button>);
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(<Button loading>Claiming...</Button>);
expect(container.querySelector(".animate-spin")).not.toBeNull();
});

it("renders no spinner when not loading", () => {
const { container } = render(<Button>Claim this issue</Button>);
expect(container.querySelector(".animate-spin")).toBeNull();
});

it("stays disabled when explicitly disabled, independent of loading", () => {
render(<Button disabled>No action available</Button>);
const button = screen.getByRole("button", { name: "No action available" });
expect(button).toBeDisabled();
expect(button).not.toHaveAttribute("aria-busy");
});
});
24 changes: 23 additions & 1 deletion src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,45 @@ const sizeClasses: Record<Size, string> = {
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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 (
<button
aria-busy={loading || undefined}
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...props}
/>
>
{loading && (
<span
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
)}
{children}
</button>
);
}
2 changes: 1 addition & 1 deletion src/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function EmptyState({
return (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50/50 px-6 py-12 text-center dark:border-slate-800 dark:bg-slate-900/40">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<Icon className="h-5 w-5 text-slate-400 dark:text-slate-500" />
<Icon className="h-5 w-5 text-slate-400 dark:text-slate-500" aria-hidden="true" />
</span>
<p className="mt-3 font-medium text-slate-700 dark:text-slate-200">{title}</p>
<p className="mt-1 max-w-sm text-sm text-slate-500 dark:text-slate-400">{description}</p>
Expand Down
18 changes: 13 additions & 5 deletions src/context/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
// Lazy initializer reads the class themeInitScript already applied to
// <html> 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<Theme>(() =>
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "dark"
: "light",
);

useEffect(() => {
// Reflects the class the no-flash init script already applied to <html>.
// 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) => {
Expand Down
Loading