feat(core-ui): add CookieConsentBanner headless component
Implements the EU-compliant cookie consent banner with: - modal + banner variants, CNIL equal-prominence Reject/Accept buttons - granular category toggles (essential non-toggleable) - __consent_state cookie management (SameSite=Lax, Secure, 1-yr, _v:1) - render-prop overrides: renderHeader, renderCategoryRow, renderActions - useConsent() integration when ConsentProvider is present - CookieConsentBannerLoader SSR-safe wrapper - RTL behavioral tests: Reject All, Save Selected, ESC=Reject, focus-trap - Storybook stories for modal, banner, render-prop, and a11y tab demo - jsdom configured with HTTPS origin for Secure cookie testing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "@repo/core-testing/react";
|
||||
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { InMemoryConsent } from "@repo/core-consent";
|
||||
import { ConsentProvider } from "@repo/core-consent/react";
|
||||
import { CookieConsentBanner } from "./cookie-consent-banner";
|
||||
import { CookieConsentBannerLoader } from "./cookie-consent-banner-loader";
|
||||
import {
|
||||
CONSENT_COOKIE_NAME,
|
||||
readConsentCookie,
|
||||
writeConsentCookie,
|
||||
clearConsentCookie,
|
||||
} from "./cookie-helpers";
|
||||
import type { ConsentCookieState } from "./types";
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the consent cookie before each test
|
||||
document.cookie = `${CONSENT_COOKIE_NAME}=; Max-Age=0; Path=/`;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("readConsentCookie", () => {
|
||||
it("returns null when no cookie is set", () => {
|
||||
expect(readConsentCookie()).toBeNull();
|
||||
});
|
||||
|
||||
it("reads back a cookie written by writeConsentCookie", () => {
|
||||
const state: ConsentCookieState = {
|
||||
_v: 1,
|
||||
categories: { essential: true, analytics: false, marketing: false },
|
||||
};
|
||||
writeConsentCookie(state);
|
||||
expect(readConsentCookie()).toEqual(state);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
document.cookie = `${CONSENT_COOKIE_NAME}=notjson`;
|
||||
expect(readConsentCookie()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when _v does not match COOKIE_VERSION", () => {
|
||||
const bad = encodeURIComponent(JSON.stringify({ _v: 99, categories: {} }));
|
||||
document.cookie = `${CONSENT_COOKIE_NAME}=${bad}`;
|
||||
expect(readConsentCookie()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when categories key is missing", () => {
|
||||
const bad = encodeURIComponent(JSON.stringify({ _v: 1 }));
|
||||
document.cookie = `${CONSENT_COOKIE_NAME}=${bad}`;
|
||||
expect(readConsentCookie()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when categories is not an object", () => {
|
||||
const bad = encodeURIComponent(
|
||||
JSON.stringify({ _v: 1, categories: "bad" }),
|
||||
);
|
||||
document.cookie = `${CONSENT_COOKIE_NAME}=${bad}`;
|
||||
expect(readConsentCookie()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearConsentCookie", () => {
|
||||
it("removes the cookie so readConsentCookie returns null", () => {
|
||||
writeConsentCookie({ _v: 1, categories: { essential: true } });
|
||||
clearConsentCookie();
|
||||
expect(readConsentCookie()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — Reject All
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — Reject All", () => {
|
||||
it("fires onConsentChange with all non-essential categories denied", async () => {
|
||||
const onConsentChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Reject All" }));
|
||||
|
||||
await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce());
|
||||
const [states] = onConsentChange.mock.calls[0] as [
|
||||
Array<{ category: string; state: string }>,
|
||||
];
|
||||
|
||||
const find = (id: string) => states.find((s) => s.category === id);
|
||||
|
||||
expect(find("essential")?.state).toBe("granted"); // required — always granted
|
||||
expect(find("functional")?.state).toBe("denied");
|
||||
expect(find("analytics")?.state).toBe("denied");
|
||||
expect(find("marketing")?.state).toBe("denied");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — Save Selected with toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — Save Selected with toggle", () => {
|
||||
it("fires onConsentChange with analytics granted after toggling it on", async () => {
|
||||
const onConsentChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
||||
);
|
||||
|
||||
// Toggle analytics ON (starts off by default)
|
||||
await userEvent.click(
|
||||
screen.getByRole("switch", { name: "Analytics cookies" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Save Selected" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce());
|
||||
const [states] = onConsentChange.mock.calls[0] as [
|
||||
Array<{ category: string; state: string }>,
|
||||
];
|
||||
|
||||
expect(states.find((s) => s.category === "analytics")?.state).toBe(
|
||||
"granted",
|
||||
);
|
||||
// Other non-essential categories remain denied
|
||||
expect(states.find((s) => s.category === "marketing")?.state).toBe(
|
||||
"denied",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — ESC key
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — ESC key", () => {
|
||||
it("ESC in modal triggers Reject All semantics", async () => {
|
||||
const onConsentChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce());
|
||||
const [states] = onConsentChange.mock.calls[0] as [
|
||||
Array<{ category: string; state: string }>,
|
||||
];
|
||||
expect(states.find((s) => s.category === "analytics")?.state).toBe(
|
||||
"denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("ESC does not trigger Reject All for banner variant", async () => {
|
||||
const onConsentChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner
|
||||
variant="banner"
|
||||
onConsentChange={onConsentChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
// Give async handler a chance to run if it were registered
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(onConsentChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — Tab order
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — Tab order", () => {
|
||||
it("Reject All precedes Accept All in DOM order (CNIL equal-prominence)", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
|
||||
const rejectBtn = screen.getByRole("button", { name: "Reject All" });
|
||||
const acceptBtn = screen.getByRole("button", { name: "Accept All" });
|
||||
|
||||
// DOCUMENT_POSITION_FOLLOWING (4) means acceptBtn follows rejectBtn in the DOM
|
||||
expect(
|
||||
rejectBtn.compareDocumentPosition(acceptBtn) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Reject All precedes Accept All in banner variant too", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="banner" />);
|
||||
|
||||
const rejectBtn = screen.getByRole("button", { name: "Reject All" });
|
||||
const acceptBtn = screen.getByRole("button", { name: "Accept All" });
|
||||
|
||||
expect(
|
||||
rejectBtn.compareDocumentPosition(acceptBtn) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — Modal focus trap
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — Modal focus trap", () => {
|
||||
it("Tab on last focusable element wraps focus to first", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
);
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
expect(first).toBeDefined();
|
||||
expect(last).toBeDefined();
|
||||
|
||||
last!.focus();
|
||||
fireEvent.keyDown(document, { key: "Tab", shiftKey: false });
|
||||
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
|
||||
it("Shift+Tab on first focusable element wraps focus to last", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
);
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
first!.focus();
|
||||
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
||||
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it("focus is contained within the dialog after Tab on last element", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
);
|
||||
focusable[focusable.length - 1]!.focus();
|
||||
fireEvent.keyDown(document, { key: "Tab", shiftKey: false });
|
||||
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — render-prop overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — render-prop overrides", () => {
|
||||
it("renderHeader replaces the default heading", () => {
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner
|
||||
variant="modal"
|
||||
renderHeader={({ title }) => (
|
||||
<div data-testid="custom-header">{title}</div>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom-header")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renderActions replaces the default action buttons", () => {
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner
|
||||
variant="modal"
|
||||
renderActions={({ onRejectAll: reject }) => (
|
||||
<button onClick={reject} data-testid="custom-reject">
|
||||
Custom Reject
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const customReject = screen.getByTestId("custom-reject");
|
||||
expect(customReject).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Reject All" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renderCategoryRow replaces the default category row", () => {
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner
|
||||
variant="modal"
|
||||
renderCategoryRow={({ category }) => (
|
||||
<div data-testid={`custom-row-${category.id}`}>{category.label}</div>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom-row-analytics")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — Accept All
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — Accept All", () => {
|
||||
it("fires onConsentChange with all categories granted", async () => {
|
||||
const onConsentChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Accept All" }));
|
||||
|
||||
await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce());
|
||||
const [states] = onConsentChange.mock.calls[0] as [
|
||||
Array<{ category: string; state: string }>,
|
||||
];
|
||||
|
||||
expect(states.every((s) => s.state === "granted")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — ARIA roles
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — ARIA roles", () => {
|
||||
it("modal variant renders a dialog element", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("banner variant renders a region element", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="banner" />);
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Cookie Preferences" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("essential toggle is disabled (non-toggleable)", () => {
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
const essential = screen.getByRole("switch", { name: "Essential cookies" });
|
||||
expect(essential).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — cookie initialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — cookie initialisation", () => {
|
||||
it("initialises toggles from an existing __consent_state cookie", () => {
|
||||
writeConsentCookie({
|
||||
_v: 1,
|
||||
categories: {
|
||||
essential: true,
|
||||
analytics: true,
|
||||
functional: false,
|
||||
marketing: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "Analytics cookies" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "Functional cookies" }),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — ConsentProvider integration
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — ConsentProvider integration", () => {
|
||||
it("reads initial state from consent when inside ConsentProvider (no cookie)", async () => {
|
||||
const consent = new InMemoryConsent();
|
||||
await consent.grant("analytics");
|
||||
|
||||
renderWithProviders(
|
||||
<ConsentProvider value={consent}>
|
||||
<CookieConsentBanner variant="modal" />
|
||||
</ConsentProvider>,
|
||||
);
|
||||
|
||||
// analytics was pre-granted, should show as checked
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "Analytics cookies" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("calls consent.grant for each granted category on Accept All", async () => {
|
||||
const consent = new InMemoryConsent();
|
||||
const grantSpy = vi.spyOn(consent, "grant");
|
||||
const onConsentChange = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ConsentProvider value={consent}>
|
||||
<CookieConsentBanner
|
||||
variant="modal"
|
||||
onConsentChange={onConsentChange}
|
||||
/>
|
||||
</ConsentProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Accept All" }));
|
||||
|
||||
await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce());
|
||||
expect(grantSpy).toHaveBeenCalledWith(
|
||||
"analytics",
|
||||
expect.objectContaining({ method: "banner-accept" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls consent.withdraw for denied categories on Reject All", async () => {
|
||||
const consent = new InMemoryConsent();
|
||||
await consent.grant("analytics"); // pre-grant so withdraw is called
|
||||
const withdrawSpy = vi.spyOn(consent, "withdraw");
|
||||
|
||||
renderWithProviders(
|
||||
<ConsentProvider value={consent}>
|
||||
<CookieConsentBanner variant="modal" />
|
||||
</ConsentProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Reject All" }));
|
||||
|
||||
await waitFor(() => expect(withdrawSpy).toHaveBeenCalledWith("analytics"));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBannerLoader
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBannerLoader", () => {
|
||||
it("mounts the banner client-side after hydration", async () => {
|
||||
renderWithProviders(<CookieConsentBannerLoader variant="modal" />);
|
||||
|
||||
// After mounting (useEffect fires), the banner should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user