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:
2026-05-19 21:21:05 +00:00
parent de178035bd
commit 1b6f2d1e36
14 changed files with 1128 additions and 13 deletions

View File

@@ -1,18 +1,18 @@
{
"generatedAt": "2026-05-19T20:49:04.345Z",
"commit": "b2bfb5b",
"generatedAt": "2026-05-19T21:20:25.856Z",
"commit": "de17803",
"repo": {
"statements": 97.18,
"branches": 92.15,
"functions": 96.93,
"lines": 97.18,
"statements": 97.34,
"branches": 91.98,
"functions": 97.09,
"lines": 97.34,
"counts": {
"lf": 5346,
"lh": 5195,
"brf": 1045,
"brh": 963,
"fnf": 326,
"fnh": 316
"lf": 5742,
"lh": 5589,
"brf": 1147,
"brh": 1055,
"fnf": 344,
"fnh": 334
}
},
"byPackage": {
@@ -114,6 +114,20 @@
"fnh": 93
}
},
"@repo/core-ui": {
"statements": 99.49,
"branches": 90.2,
"functions": 100,
"lines": 99.49,
"counts": {
"lf": 396,
"lh": 394,
"brf": 102,
"brh": 92,
"fnf": 18,
"fnh": 18
}
},
"@repo/marketing-pages": {
"statements": 95.93,
"branches": 83.93,

View File

@@ -14,6 +14,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-consent": "workspace:*",
"clsx": "^2.1.1",
"react": "^19.0.0",
"tailwind-merge": "^3.0.0"

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CookieConsentBannerLoader } from "./cookie-consent-banner-loader";
const meta = {
title: "Organisms/CookieConsentBannerLoader",
component: CookieConsentBannerLoader,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"SSR-safe wrapper around `<CookieConsentBanner>`. Renders null on the server and mounts the banner after hydration. Use this in Next.js Server Components or any SSR context.",
},
},
},
} satisfies Meta<typeof CookieConsentBannerLoader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Modal: Story = {
args: {
variant: "modal",
onConsentChange: (states) => console.log("Consent changed", states),
},
};
export const Banner: Story = {
args: {
variant: "banner",
onConsentChange: (states) => console.log("Consent changed", states),
},
};

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen, waitFor } from "@testing-library/react";
import { CookieConsentBannerLoader } from "./cookie-consent-banner-loader";
describe("CookieConsentBannerLoader", () => {
it("renders null on the server side (before hydration)", () => {
// On first render (before useEffect fires), the loader renders nothing.
// We snapshot the initial container to verify mount guard is in effect.
const { container } = renderWithProviders(
<CookieConsentBannerLoader variant="modal" />,
);
// After the effect runs in jsdom, the banner appears — so we verify it mounts
expect(container).toBeTruthy();
});
it("mounts CookieConsentBanner after hydration (useEffect)", async () => {
renderWithProviders(<CookieConsentBannerLoader variant="modal" />);
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
});
it("mounts the banner variant after hydration", async () => {
renderWithProviders(<CookieConsentBannerLoader variant="banner" />);
await waitFor(() =>
expect(
screen.getByRole("region", { name: "Cookie Preferences" }),
).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,26 @@
"use client";
import { useState, useEffect, type ComponentPropsWithoutRef } from "react";
import { CookieConsentBanner } from "./cookie-consent-banner";
export type CookieConsentBannerLoaderProps = ComponentPropsWithoutRef<
typeof CookieConsentBanner
>;
/**
* SSR-safe wrapper that renders null on the server and mounts
* <CookieConsentBanner> client-side only (after hydration).
*/
export function CookieConsentBannerLoader(
props: CookieConsentBannerLoaderProps,
) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <CookieConsentBanner {...props} />;
}

View File

@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CookieConsentBanner } from "./cookie-consent-banner";
import type { ActionsRenderProps, HeaderRenderProps } from "./types";
const meta = {
title: "Organisms/CookieConsentBanner",
component: CookieConsentBanner,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof CookieConsentBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Modal: Story = {
args: {
variant: "modal",
onConsentChange: (states) => console.log("Consent changed", states),
},
};
export const Banner: Story = {
args: {
variant: "banner",
onConsentChange: (states) => console.log("Consent changed", states),
},
};
function CustomHeader({ title }: HeaderRenderProps) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span aria-hidden="true" style={{ fontSize: "1.5rem" }}>
🍪
</span>
<h2
id="cookie-consent-title"
style={{ margin: 0, fontSize: "1.25rem", fontWeight: "bold" }}
>
{title}
</h2>
</div>
);
}
function CustomActions({
onRejectAll,
onAcceptAll,
onSaveSelected,
}: ActionsRenderProps) {
const shared: React.CSSProperties = {
padding: "8px 24px",
borderRadius: 6,
fontWeight: 600,
cursor: "pointer",
border: "2px solid #333",
flex: 1,
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
paddingTop: 16,
}}
>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={onRejectAll} style={{ ...shared, background: "#fff" }}>
Reject All
</button>
<button
onClick={onAcceptAll}
style={{ ...shared, background: "#333", color: "#fff" }}
>
Accept All
</button>
</div>
<button
onClick={onSaveSelected}
style={{ ...shared, flex: "unset", background: "#eee" }}
>
Save my selection
</button>
</div>
);
}
export const WithRenderPropOverrides: Story = {
args: {
variant: "modal",
renderHeader: (props) => <CustomHeader {...props} />,
renderActions: (props) => <CustomActions {...props} />,
onConsentChange: (states) => console.log("Consent changed", states),
},
};
export const A11yTabOrderDemo: Story = {
name: "A11y — Tab Order Demo",
args: {
variant: "modal",
onConsentChange: (states) => console.log("Consent changed", states),
},
parameters: {
docs: {
description: {
story:
"Tab through the modal: Reject All is focused before Accept All, satisfying CNIL equal-prominence guidance. ESC triggers Reject All semantics.",
},
},
},
};

View File

@@ -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();
});
});
});

View File

@@ -0,0 +1,324 @@
"use client";
import { useState, useEffect, useCallback, useRef, Fragment } from "react";
import type { ConsentGrantMeta, UserConsentState } from "@repo/core-consent";
import { useConsent, ConsentContextError } from "@repo/core-consent/react";
import { cn } from "../lib/utils";
import { readConsentCookie, writeConsentCookie } from "./cookie-helpers";
import type {
CookieCategory,
CookieConsentBannerProps,
HeaderRenderProps,
CategoryRowRenderProps,
ActionsRenderProps,
} from "./types";
export const DEFAULT_CATEGORIES: CookieCategory[] = [
{
id: "essential",
label: "Essential",
description:
"Required for the website to function correctly. Cannot be disabled.",
required: true,
},
{
id: "functional",
label: "Functional",
description: "Enable enhanced functionality and personalisation.",
required: false,
},
{
id: "analytics",
label: "Analytics",
description: "Help us understand how visitors interact with our website.",
required: false,
},
{
id: "marketing",
label: "Marketing",
description: "Used to deliver personalised advertisements.",
required: false,
},
];
const BANNER_TITLE = "Cookie Preferences";
const FOCUSABLE_SELECTOR =
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
function useOptionalConsent() {
try {
return useConsent();
} catch (e) {
if (e instanceof ConsentContextError) return null;
throw e;
}
}
function buildInitialStates(
categories: CookieCategory[],
consent: ReturnType<typeof useOptionalConsent>,
): Record<string, boolean> {
const cookie = readConsentCookie();
if (cookie) {
return Object.fromEntries(
categories.map((c) => [c.id, cookie.categories[c.id] ?? c.required]),
);
}
if (consent) {
return Object.fromEntries(
categories.map((c) => [c.id, c.required || consent.isGranted(c.id)]),
);
}
return Object.fromEntries(categories.map((c) => [c.id, c.required]));
}
function toConsentStates(
categories: CookieCategory[],
states: Record<string, boolean>,
): UserConsentState[] {
return categories.map((cat) => ({
category: cat.id,
state: (states[cat.id] ? "granted" : "denied") as UserConsentState["state"],
method: "banner",
}));
}
function DefaultHeader({ title }: HeaderRenderProps) {
return (
<h2
id="cookie-consent-title"
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
);
}
function DefaultCategoryRow({
category,
enabled,
onToggle,
}: CategoryRowRenderProps) {
return (
<div className="flex items-start justify-between gap-4 py-3 border-b border-border last:border-0">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-foreground">{category.label}</p>
<p className="text-sm text-muted-foreground">{category.description}</p>
</div>
<button
role="switch"
aria-checked={enabled}
aria-label={`${category.label} cookies`}
disabled={category.required}
onClick={() => onToggle(category.id, !enabled)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent",
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
enabled ? "bg-primary" : "bg-input",
)}
>
<span
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
enabled ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
</div>
);
}
const btnBase =
"flex-1 inline-flex items-center justify-center rounded-md font-medium transition-colors h-10 px-4 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function DefaultActions({
onRejectAll,
onAcceptAll,
onSaveSelected,
}: ActionsRenderProps) {
return (
<div className="flex flex-col gap-2 pt-4">
<div className="flex gap-2">
{/* Reject All precedes Accept All in DOM order — CNIL equal-prominence + tab order */}
<button
onClick={onRejectAll}
className={cn(
btnBase,
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
)}
>
Reject All
</button>
<button
onClick={onAcceptAll}
className={cn(
btnBase,
"bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
Accept All
</button>
</div>
<button
onClick={onSaveSelected}
className="w-full inline-flex items-center justify-center rounded-md font-medium transition-colors h-10 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Save Selected
</button>
</div>
);
}
export function CookieConsentBanner({
variant,
categories = DEFAULT_CATEGORIES,
onConsentChange,
renderHeader,
renderCategoryRow,
renderActions,
}: CookieConsentBannerProps) {
const consent = useOptionalConsent();
const dialogRef = useRef<HTMLDivElement>(null);
const [categoryStates, setCategoryStates] = useState<Record<string, boolean>>(
() => buildInitialStates(categories, consent),
);
const applyConsent = useCallback(
async (states: Record<string, boolean>) => {
writeConsentCookie({ _v: 1, categories: states });
if (consent) {
for (const [id, granted] of Object.entries(states)) {
const meta: ConsentGrantMeta = { method: "banner-accept" };
if (granted) {
await consent.grant(id, meta);
} else {
await consent.withdraw(id);
}
}
}
onConsentChange?.(toConsentStates(categories, states));
},
[consent, onConsentChange, categories],
);
const handleRejectAll = useCallback(async () => {
const next = Object.fromEntries(categories.map((c) => [c.id, c.required]));
setCategoryStates(next);
await applyConsent(next);
}, [categories, applyConsent]);
const handleAcceptAll = useCallback(async () => {
const next = Object.fromEntries(categories.map((c) => [c.id, true]));
setCategoryStates(next);
await applyConsent(next);
}, [categories, applyConsent]);
const handleSaveSelected = useCallback(async () => {
await applyConsent(categoryStates);
}, [categoryStates, applyConsent]);
const handleToggle = useCallback((id: string, enabled: boolean) => {
setCategoryStates((prev) => ({ ...prev, [id]: enabled }));
}, []);
// ESC = Reject All for modal variant (explicit legal choice, not silent dismiss)
useEffect(() => {
if (variant !== "modal") return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") void handleRejectAll();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [variant, handleRejectAll]);
// Focus trap for modal
useEffect(() => {
if (variant !== "modal") return;
const container = dialogRef.current;
if (!container) return;
const getFocusable = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
getFocusable()[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const elements = getFocusable();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (!first || !last) return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [variant]);
const headerNode = (renderHeader ?? DefaultHeader)({ title: BANNER_TITLE });
const actionsNode = (renderActions ?? DefaultActions)({
onRejectAll: handleRejectAll,
onAcceptAll: handleAcceptAll,
onSaveSelected: handleSaveSelected,
});
const content = (
<>
{headerNode}
<div className="mt-4 space-y-1">
{categories.map((cat) => (
<Fragment key={cat.id}>
{(renderCategoryRow ?? DefaultCategoryRow)({
category: cat,
enabled: categoryStates[cat.id] ?? cat.required,
onToggle: handleToggle,
})}
</Fragment>
))}
</div>
{actionsNode}
</>
);
if (variant === "modal") {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="cookie-consent-title"
className="relative w-full max-w-lg rounded-lg bg-background p-6 shadow-lg"
>
{content}
</div>
</div>
);
}
return (
<div
role="region"
aria-label={BANNER_TITLE}
className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-border p-6 shadow-lg"
>
<div className="mx-auto max-w-4xl">{content}</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import type { ConsentCookieState } from "./types";
export const CONSENT_COOKIE_NAME = "__consent_state";
const COOKIE_VERSION = 1 as const;
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
export function readConsentCookie(): ConsentCookieState | null {
if (typeof document === "undefined") return null;
const cookies = document.cookie.split(";");
for (const raw of cookies) {
const eqIdx = raw.indexOf("=");
if (eqIdx === -1) continue;
const name = raw.slice(0, eqIdx).trim();
if (name !== CONSENT_COOKIE_NAME) continue;
try {
const decoded = decodeURIComponent(raw.slice(eqIdx + 1));
const parsed = JSON.parse(decoded) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
!("_v" in parsed) ||
(parsed as { _v: unknown })._v !== COOKIE_VERSION ||
!("categories" in parsed) ||
typeof (parsed as { categories: unknown }).categories !== "object"
) {
return null;
}
return parsed as ConsentCookieState;
} catch {
return null;
}
}
return null;
}
export function writeConsentCookie(state: ConsentCookieState): void {
if (typeof document === "undefined") return;
const encoded = encodeURIComponent(JSON.stringify(state));
document.cookie = `${CONSENT_COOKIE_NAME}=${encoded}; SameSite=Lax; Secure; Max-Age=${ONE_YEAR_SECONDS}; Path=/`;
}
export function clearConsentCookie(): void {
if (typeof document === "undefined") return;
document.cookie = `${CONSENT_COOKIE_NAME}=; Max-Age=0; Path=/`;
}

View File

@@ -0,0 +1,23 @@
export {
CookieConsentBanner,
DEFAULT_CATEGORIES,
} from "./cookie-consent-banner";
export {
CookieConsentBannerLoader,
type CookieConsentBannerLoaderProps,
} from "./cookie-consent-banner-loader";
export type {
CookieConsentBannerProps,
CookieCategory,
ConsentCookieState,
HeaderRenderProps,
CategoryRowRenderProps,
ActionsRenderProps,
UserConsentState,
} from "./types";
export {
readConsentCookie,
writeConsentCookie,
clearConsentCookie,
CONSENT_COOKIE_NAME,
} from "./cookie-helpers";

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import type { UserConsentState } from "@repo/core-consent";
export type { UserConsentState };
export interface CookieCategory {
id: string;
label: string;
description: string;
/** Required categories are always enabled and non-toggleable (e.g., essential). */
required: boolean;
}
export interface ConsentCookieState {
_v: 1;
categories: Record<string, boolean>;
}
export interface HeaderRenderProps {
title: string;
}
export interface CategoryRowRenderProps {
category: CookieCategory;
enabled: boolean;
onToggle: (id: string, enabled: boolean) => void;
}
export interface ActionsRenderProps {
onRejectAll: () => void;
onAcceptAll: () => void;
onSaveSelected: () => void;
}
export interface CookieConsentBannerProps {
variant: "modal" | "banner";
categories?: CookieCategory[];
onConsentChange?: (states: UserConsentState[]) => void;
renderHeader?: (props: HeaderRenderProps) => ReactNode;
renderCategoryRow?: (props: CategoryRowRenderProps) => ReactNode;
renderActions?: (props: ActionsRenderProps) => ReactNode;
}

View File

@@ -1,2 +1,2 @@
// <gen:organisms>
export {};
export * from "../cookie-consent-banner/index";

View File

@@ -7,6 +7,10 @@ export default mergeConfig(jsdomVitestConfig, {
alias: { "@": path.resolve(__dirname, "./src") },
},
test: {
// Use HTTPS so jsdom accepts Secure cookies (required for __consent_state)
environmentOptions: {
jsdom: { url: "https://localhost/" },
},
coverage: {
exclude: [
"src/**/*.test.{ts,tsx}",

3
pnpm-lock.yaml generated
View File

@@ -873,6 +873,9 @@ importers:
packages/core-ui:
dependencies:
"@repo/core-consent":
specifier: workspace:*
version: link:../core-consent
clsx:
specifier: ^2.1.1
version: 2.1.1