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
186 changes: 186 additions & 0 deletions src/__tests__/components/InstallFreighterModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import InstallFreighterModal from "@/components/InstallFreighterModal";

describe("InstallFreighterModal — focus management (#807)", () => {
beforeEach(() => {
jest.clearAllMocks();
});

const defaultProps = {
isOpen: false,
onClose: jest.fn(),
onRetry: jest.fn(),
};

/**
* Helper to render a button that acts as the "trigger" (the Connect Wallet
* button in Navbar), simulating the real flow where a user clicks to open
* the modal and expects focus to return after closing it.
*/
function renderWithTrigger(modalProps = {}) {
return render(
<div>
<button type="button" data-testid="trigger-button">
Connect Wallet
</button>
<InstallFreighterModal {...defaultProps} {...modalProps} />
</div>,
);
}

it("focuses the first focusable element inside the modal when it opens", () => {
renderWithTrigger({ isOpen: true });

const installLink = screen.getByText("Install Freighter");
// requestAnimationFrame-based focus is async, so we wait
return waitFor(() => {
expect(installLink).toBe(document.activeElement);
});
});

it("restores focus to the trigger button when the modal closes via 'Not now'", async () => {
const triggerRef = { current: null as HTMLElement | null };
const { rerender } = render(
<div>
<button
type="button"
data-testid="trigger-button"
ref={(el) => {
triggerRef.current = el;
}}
>
Connect Wallet
</button>
<InstallFreighterModal {...defaultProps} isOpen={false} />
</div>,
);

// Focus the trigger button
triggerRef.current?.focus();
expect(triggerRef.current).toBe(document.activeElement);

// Open the modal
rerender(
<div>
<button
type="button"
data-testid="trigger-button"
ref={(el) => {
triggerRef.current = el;
}}
>
Connect Wallet
</button>
<InstallFreighterModal {...defaultProps} isOpen={true} />
</div>,
);

// Wait for focus to move into the modal
await waitFor(() => {
expect(screen.getByText("Install Freighter")).toBe(document.activeElement);
});

// Close the modal
fireEvent.click(screen.getByText("Not now"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);

// Re-render with isOpen=false to trigger the useEffect cleanup
rerender(
<div>
<button
type="button"
data-testid="trigger-button"
ref={(el) => {
triggerRef.current = el;
}}
>
Connect Wallet
</button>
<InstallFreighterModal {...defaultProps} isOpen={false} />
</div>,
);

// Focus should be restored to the trigger button
expect(triggerRef.current).toBe(document.activeElement);
});

it("has a keyboard-accessible retry button that can be focused", () => {
renderWithTrigger({ isOpen: true });

const retryButton = screen.getByText("I installed it — check again");
expect(retryButton).toBeInTheDocument();
expect(retryButton.tagName).toBe("BUTTON");
expect(retryButton).not.toBeDisabled();
});

it("calls onRetry when the retry button is clicked", () => {
const onRetry = jest.fn();
renderWithTrigger({ isOpen: true, onRetry });

const retryButton = screen.getByText("I installed it — check again");
fireEvent.click(retryButton);

// The handleRetry has a 500ms delay, so it won't call onRetry immediately
expect(retryButton).toBeDisabled();
});

it("renders nothing when isOpen is false", () => {
const { container } = renderWithTrigger({ isOpen: false });

expect(container.querySelector(".fixed")).not.toBeInTheDocument();
});

it("enables the retry button and calls onRetry after checking completes", async () => {
jest.useFakeTimers();
const onRetry = jest.fn();
renderWithTrigger({ isOpen: true, onRetry });

const retryButton = screen.getByText("I installed it — check again");
fireEvent.click(retryButton);

expect(retryButton).toBeDisabled();
expect(retryButton).toHaveTextContent("Checking...");

jest.advanceTimersByTime(500);

// Use waitFor to let React flush state updates from the async handler
await waitFor(() => {
expect(onRetry).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(retryButton).not.toBeDisabled();
});
expect(retryButton).toHaveTextContent("I installed it — check again");

jest.useRealTimers();
});

it("closes the modal when Escape is pressed", () => {
const onClose = jest.fn();
renderWithTrigger({ isOpen: true, onClose });

fireEvent.keyDown(document, { key: "Escape" });

expect(onClose).toHaveBeenCalledTimes(1);
});

it("traps focus within the modal when Tab cycling", () => {
renderWithTrigger({ isOpen: true });

const installLink = screen.getByText("Install Freighter");
const notNowButton = screen.getByText("Not now");

// Focus the last focusable element (Not now button)
notNowButton.focus();
expect(notNowButton).toBe(document.activeElement);

// Tab forward from last element should wrap to first
fireEvent.keyDown(document, { key: "Tab" });
expect(installLink).toBe(document.activeElement);

// Tab backward from first element should wrap to last
installLink.focus();
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
expect(notNowButton).toBe(document.activeElement);
});
});
47 changes: 44 additions & 3 deletions src/components/EditCampaignMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { validateImageUrl } from "@/lib/imageValidation";

interface Props {
campaignId: number;
Expand Down Expand Up @@ -31,6 +32,7 @@
const [description, setDescription] = useState(initialDescription);
const [coverImageUrl, setCoverImageUrl] = useState(initialCoverImageUrl);
const [override, setOverride] = useState<MetaOverride | null>(null);
const [imageError, setImageError] = useState<string | null>(null);

useEffect(() => {
try {
Expand All @@ -48,6 +50,16 @@
}, [storageKey]);

const handleSave = () => {
// Validate the cover image URL before saving
if (coverImageUrl) {
const { valid, error } = validateImageUrl(coverImageUrl);
if (!valid) {
setImageError(error || "Invalid image URL");
return;
}
}
setImageError(null);

const data: MetaOverride = {
title,
description,
Expand All @@ -69,11 +81,24 @@
// ignore
}
setOverride(null);
setImageError(null);
setTitle(initialTitle);
setDescription(initialDescription);
setCoverImageUrl(initialCoverImageUrl);
};

const titleId = `edit-title-${campaignId}`;
const descriptionId = `edit-description-${campaignId}`;
const coverImageId = `edit-cover-image-url-${campaignId}`;

const handleImageUrlChange = (url: string) => {
setCoverImageUrl(url);
// Clear error when the user starts typing
if (imageError) {
setImageError(null);
}
};

return (
<div className="mt-4 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
{/* Toggle header */}
Expand Down Expand Up @@ -168,15 +193,31 @@
id={`edit-meta-cover-${campaignId}`}
type="url"
value={coverImageUrl}
onChange={(e) => setCoverImageUrl(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-700 text-sm text-zinc-900 dark:text-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleImageUrlChange(e.target.value)}
aria-invalid={imageError ? true : undefined}
aria-describedby={imageError ? "cover-image-error" : undefined}
className={`w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
imageError
? "border-red-400 bg-red-50 dark:border-red-700 dark:bg-red-900/20 text-red-900 dark:text-red-200"
: "border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-700 text-zinc-900 dark:text-zinc-50"
}`}
/>
{imageError && (
<p
id="cover-image-error"
role="alert"
className="mt-1 text-xs text-red-600 dark:text-red-400"
>
{imageError}
</p>
)}
</div>

<button
type="button"
onClick={handleSave}
className="self-start px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
disabled={!!imageError}
className="self-start px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("saveButton")}
</button>
Expand Down
59 changes: 59 additions & 0 deletions src/components/InstallFreighterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,65 @@ export default function InstallFreighterModal({
[isOpen],
);
const [checking, setChecking] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);

// #807 — Focus management for keyboard-only users
useEffect(() => {
if (!isOpen) return;

// Save the element that was focused before the modal opened
previousActiveElementRef.current = document.activeElement as HTMLElement;

// On next frame, focus the first interactive element inside the modal
const raf = requestAnimationFrame(() => {
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
});

// Focus trap & Escape key handler
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}

if (e.key !== "Tab") return;

const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (!focusableElements || focusableElements.length === 0) return;

const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];

if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};

document.addEventListener("keydown", handleKeyDown);

return () => {
cancelAnimationFrame(raf);
document.removeEventListener("keydown", handleKeyDown);
// Restore focus to the trigger element when the modal closes
previousActiveElementRef.current?.focus();
previousActiveElementRef.current = null;
};
}, [isOpen, onClose]);

const handleRetry = async () => {
setChecking(true);
Expand Down
Loading