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:
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-19T20:49:04.345Z",
|
"generatedAt": "2026-05-19T21:20:25.856Z",
|
||||||
"commit": "b2bfb5b",
|
"commit": "de17803",
|
||||||
"repo": {
|
"repo": {
|
||||||
"statements": 97.18,
|
"statements": 97.34,
|
||||||
"branches": 92.15,
|
"branches": 91.98,
|
||||||
"functions": 96.93,
|
"functions": 97.09,
|
||||||
"lines": 97.18,
|
"lines": 97.34,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 5346,
|
"lf": 5742,
|
||||||
"lh": 5195,
|
"lh": 5589,
|
||||||
"brf": 1045,
|
"brf": 1147,
|
||||||
"brh": 963,
|
"brh": 1055,
|
||||||
"fnf": 326,
|
"fnf": 344,
|
||||||
"fnh": 316
|
"fnh": 334
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"byPackage": {
|
"byPackage": {
|
||||||
@@ -114,6 +114,20 @@
|
|||||||
"fnh": 93
|
"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": {
|
"@repo/marketing-pages": {
|
||||||
"statements": 95.93,
|
"statements": 95.93,
|
||||||
"branches": 83.93,
|
"branches": 83.93,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@repo/core-consent": "workspace:*",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tailwind-merge": "^3.0.0"
|
"tailwind-merge": "^3.0.0"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
packages/core-ui/src/cookie-consent-banner/cookie-helpers.ts
Normal file
47
packages/core-ui/src/cookie-consent-banner/cookie-helpers.ts
Normal 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=/`;
|
||||||
|
}
|
||||||
23
packages/core-ui/src/cookie-consent-banner/index.ts
Normal file
23
packages/core-ui/src/cookie-consent-banner/index.ts
Normal 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";
|
||||||
42
packages/core-ui/src/cookie-consent-banner/types.ts
Normal file
42
packages/core-ui/src/cookie-consent-banner/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
// <gen:organisms>
|
// <gen:organisms>
|
||||||
export {};
|
export * from "../cookie-consent-banner/index";
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export default mergeConfig(jsdomVitestConfig, {
|
|||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
alias: { "@": path.resolve(__dirname, "./src") },
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
// Use HTTPS so jsdom accepts Secure cookies (required for __consent_state)
|
||||||
|
environmentOptions: {
|
||||||
|
jsdom: { url: "https://localhost/" },
|
||||||
|
},
|
||||||
coverage: {
|
coverage: {
|
||||||
exclude: [
|
exclude: [
|
||||||
"src/**/*.test.{ts,tsx}",
|
"src/**/*.test.{ts,tsx}",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -873,6 +873,9 @@ importers:
|
|||||||
|
|
||||||
packages/core-ui:
|
packages/core-ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@repo/core-consent":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core-consent
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user