From 1b6f2d1e3639ad0b58c547e1da740e82c69e5aa0 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 21:21:05 +0000 Subject: [PATCH] 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 --- coverage/summary.json | 38 +- packages/core-ui/package.json | 1 + .../cookie-consent-banner-loader.stories.tsx | 34 ++ .../cookie-consent-banner-loader.test.tsx | 30 ++ .../cookie-consent-banner-loader.tsx | 26 + .../cookie-consent-banner.stories.tsx | 113 +++++ .../cookie-consent-banner.test.tsx | 454 ++++++++++++++++++ .../cookie-consent-banner.tsx | 324 +++++++++++++ .../cookie-consent-banner/cookie-helpers.ts | 47 ++ .../src/cookie-consent-banner/index.ts | 23 + .../src/cookie-consent-banner/types.ts | 42 ++ packages/core-ui/src/organisms/index.ts | 2 +- packages/core-ui/vitest.config.ts | 4 + pnpm-lock.yaml | 3 + 14 files changed, 1128 insertions(+), 13 deletions(-) create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.stories.tsx create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.test.tsx create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.tsx create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.test.tsx create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.tsx create mode 100644 packages/core-ui/src/cookie-consent-banner/cookie-helpers.ts create mode 100644 packages/core-ui/src/cookie-consent-banner/index.ts create mode 100644 packages/core-ui/src/cookie-consent-banner/types.ts diff --git a/coverage/summary.json b/coverage/summary.json index 7561218..8449633 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -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, diff --git a/packages/core-ui/package.json b/packages/core-ui/package.json index a790ed5..113cef0 100644 --- a/packages/core-ui/package.json +++ b/packages/core-ui/package.json @@ -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" diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.stories.tsx b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.stories.tsx new file mode 100644 index 0000000..b6e4ac8 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.stories.tsx @@ -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 ``. Renders null on the server and mounts the banner after hydration. Use this in Next.js Server Components or any SSR context.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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), + }, +}; diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.test.tsx b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.test.tsx new file mode 100644 index 0000000..c0d2d0a --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.test.tsx @@ -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( + , + ); + // 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(); + await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument()); + }); + + it("mounts the banner variant after hydration", async () => { + renderWithProviders(); + await waitFor(() => + expect( + screen.getByRole("region", { name: "Cookie Preferences" }), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.tsx b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.tsx new file mode 100644 index 0000000..5e065d4 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner-loader.tsx @@ -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 + * client-side only (after hydration). + */ +export function CookieConsentBannerLoader( + props: CookieConsentBannerLoaderProps, +) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return ; +} diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx new file mode 100644 index 0000000..633c633 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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 ( +
+ + +
+ ); +} + +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 ( +
+
+ + +
+ +
+ ); +} + +export const WithRenderPropOverrides: Story = { + args: { + variant: "modal", + renderHeader: (props) => , + renderActions: (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.", + }, + }, + }, +}; diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.test.tsx b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.test.tsx new file mode 100644 index 0000000..47fbd43 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.test.tsx @@ -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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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(); + + 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(); + + 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(); + + const dialog = screen.getByRole("dialog"); + const focusable = Array.from( + dialog.querySelectorAll( + '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(); + + const dialog = screen.getByRole("dialog"); + const focusable = Array.from( + dialog.querySelectorAll( + '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(); + + const dialog = screen.getByRole("dialog"); + const focusable = Array.from( + dialog.querySelectorAll( + '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( + ( +
{title}
+ )} + />, + ); + + expect(screen.getByTestId("custom-header")).toBeInTheDocument(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + }); + + it("renderActions replaces the default action buttons", () => { + renderWithProviders( + ( + + )} + />, + ); + + 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( + ( +
{category.label}
+ )} + />, + ); + + 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( + , + ); + + 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(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("banner variant renders a region element", () => { + renderWithProviders(); + expect( + screen.getByRole("region", { name: "Cookie Preferences" }), + ).toBeInTheDocument(); + }); + + it("essential toggle is disabled (non-toggleable)", () => { + renderWithProviders(); + 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(); + + 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( + + + , + ); + + // 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( + + + , + ); + + 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( + + + , + ); + + 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(); + + // After mounting (useEffect fires), the banner should appear + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.tsx b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.tsx new file mode 100644 index 0000000..48e0f07 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.tsx @@ -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, +): Record { + 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, +): UserConsentState[] { + return categories.map((cat) => ({ + category: cat.id, + state: (states[cat.id] ? "granted" : "denied") as UserConsentState["state"], + method: "banner", + })); +} + +function DefaultHeader({ title }: HeaderRenderProps) { + return ( + + ); +} + +function DefaultCategoryRow({ + category, + enabled, + onToggle, +}: CategoryRowRenderProps) { + return ( +
+
+

{category.label}

+

{category.description}

+
+ +
+ ); +} + +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 ( +
+
+ {/* Reject All precedes Accept All in DOM order — CNIL equal-prominence + tab order */} + + +
+ +
+ ); +} + +export function CookieConsentBanner({ + variant, + categories = DEFAULT_CATEGORIES, + onConsentChange, + renderHeader, + renderCategoryRow, + renderActions, +}: CookieConsentBannerProps) { + const consent = useOptionalConsent(); + const dialogRef = useRef(null); + + const [categoryStates, setCategoryStates] = useState>( + () => buildInitialStates(categories, consent), + ); + + const applyConsent = useCallback( + async (states: Record) => { + 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(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} +
+ {categories.map((cat) => ( + + {(renderCategoryRow ?? DefaultCategoryRow)({ + category: cat, + enabled: categoryStates[cat.id] ?? cat.required, + onToggle: handleToggle, + })} + + ))} +
+ {actionsNode} + + ); + + if (variant === "modal") { + return ( +
+
+ {content} +
+
+ ); + } + + return ( +
+
{content}
+
+ ); +} diff --git a/packages/core-ui/src/cookie-consent-banner/cookie-helpers.ts b/packages/core-ui/src/cookie-consent-banner/cookie-helpers.ts new file mode 100644 index 0000000..c220075 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/cookie-helpers.ts @@ -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=/`; +} diff --git a/packages/core-ui/src/cookie-consent-banner/index.ts b/packages/core-ui/src/cookie-consent-banner/index.ts new file mode 100644 index 0000000..5d977a1 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/index.ts @@ -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"; diff --git a/packages/core-ui/src/cookie-consent-banner/types.ts b/packages/core-ui/src/cookie-consent-banner/types.ts new file mode 100644 index 0000000..d98e670 --- /dev/null +++ b/packages/core-ui/src/cookie-consent-banner/types.ts @@ -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; +} + +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; +} diff --git a/packages/core-ui/src/organisms/index.ts b/packages/core-ui/src/organisms/index.ts index b868e75..7976475 100644 --- a/packages/core-ui/src/organisms/index.ts +++ b/packages/core-ui/src/organisms/index.ts @@ -1,2 +1,2 @@ // -export {}; +export * from "../cookie-consent-banner/index"; diff --git a/packages/core-ui/vitest.config.ts b/packages/core-ui/vitest.config.ts index 867d8d8..5090114 100644 --- a/packages/core-ui/vitest.config.ts +++ b/packages/core-ui/vitest.config.ts @@ -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}", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ad9ba0..a3c0afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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