feat(pwa): add InstallBanner component with tests

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-02 19:51:38 +02:00
parent e23013b0af
commit 8e41b031aa
No known key found for this signature in database
2 changed files with 177 additions and 0 deletions

View file

@ -0,0 +1,128 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock the hook so we can control its return values
const mockPromptInstall = vi.fn();
const mockDismiss = vi.fn();
vi.mock("../hooks/useInstallPrompt", () => ({
useInstallPrompt: vi.fn(() => ({
canInstall: false,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
})),
}));
import { useInstallPrompt } from "../hooks/useInstallPrompt";
import { InstallBanner } from "./InstallBanner";
const mockUseInstallPrompt = vi.mocked(useInstallPrompt);
describe("InstallBanner", () => {
beforeEach(() => {
mockPromptInstall.mockClear();
mockDismiss.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders nothing when canInstall is false and not iOS", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: false,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when already in standalone mode", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: true,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when dismissed", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: true,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders install banner when canInstall is true", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
expect(screen.getByText(/install/i)).toBeInTheDocument();
});
it("calls promptInstall when install button is clicked", async () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
await userEvent.click(screen.getByRole("button", { name: /install/i }));
expect(mockPromptInstall).toHaveBeenCalledOnce();
});
it("calls dismiss when close button is clicked", async () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
await userEvent.click(screen.getByRole("button", { name: /dismiss|close/i }));
expect(mockDismiss).toHaveBeenCalledOnce();
});
it("renders iOS instructions when isIOS is true and not dismissed", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: false,
isStandalone: false,
isIOS: true,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
expect(screen.getByText(/share/i)).toBeInTheDocument();
expect(screen.getByText(/add to home screen/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,49 @@
import { Download, Share, X } from "lucide-react";
import { useInstallPrompt } from "../hooks/useInstallPrompt";
export function InstallBanner() {
const { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss } =
useInstallPrompt();
// Don't show if already installed, dismissed, or no install option available
if (isStandalone || isDismissed) return null;
if (!canInstall && !isIOS) return null;
return (
<div className="flex items-center gap-3 border-b border-border bg-card px-4 py-2.5 text-sm">
{isIOS ? (
<>
<Share className="size-4 shrink-0 text-muted-foreground" />
<p className="flex-1 text-foreground">
Install XTablo: tap{" "}
<span className="font-medium">Share</span> then{" "}
<span className="font-medium">Add to Home Screen</span>
</p>
</>
) : (
<>
<Download className="size-4 shrink-0 text-muted-foreground" />
<p className="flex-1 text-foreground">
Get the app for a faster, native experience
</p>
<button
type="button"
onClick={promptInstall}
className="shrink-0 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
aria-label="Install app"
>
Install
</button>
</>
)}
<button
type="button"
onClick={dismiss}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-4" />
</button>
</div>
);
}