Initial commit
This commit is contained in:
71
packages/core-ui/AGENTS.md
Normal file
71
packages/core-ui/AGENTS.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# AGENTS.md — core-ui
|
||||
|
||||
Design-system primitives organized by Atomic Design. Only **generic components** (atoms, molecules, generic organisms) live here. Feature-specific components live in their respective feature packages.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- **Atoms** — Single HTML elements: Button, Input, Label, Card
|
||||
- **Molecules** — Combinations: FormField (Label + Input), Badge, Avatar
|
||||
- **Generic Organisms** — Reusable complex layouts: Modal, Tabs, NavigationMenu, CommandPalette
|
||||
- **Templates** — Page layouts: AuthLayout, DashboardLayout, LandingLayout
|
||||
- **Utilities** — `cn()` for className merging, design tokens, Tailwind config
|
||||
|
||||
## Feature-specific boundary
|
||||
|
||||
Feature-specific organisms (e.g., `ArticleCard`, `ArticleList`, `HeaderNavMenu`) live in their feature's `ui/` folder, NOT here. This boundary keeps `core-ui` framework-agnostic and reusable.
|
||||
|
||||
## Must NOT import
|
||||
|
||||
- Any feature package (`@repo/auth`, `@repo/blog`, etc.)
|
||||
- Any app package
|
||||
- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`
|
||||
|
||||
> Note: `@repo/core-trpc` is an optional package scaffolded via `pnpm turbo gen core-package trpc`. If not present, this constraint still applies to any future installation.
|
||||
|
||||
## Public exports
|
||||
|
||||
From `package.json`:
|
||||
|
||||
- `.` — all atoms, molecules, organisms, templates
|
||||
|
||||
Example usage:
|
||||
|
||||
```typescript
|
||||
import { Button, Input, Label } from "@repo/core-ui";
|
||||
import { FormField } from "@repo/core-ui";
|
||||
import { Modal, Tabs } from "@repo/core-ui";
|
||||
```
|
||||
|
||||
## Test conventions
|
||||
|
||||
- Tests colocated: `src/atoms/button/button.tsx` → `src/atoms/button/button.test.tsx`
|
||||
- Vitest environment: `jsdom` (React component testing)
|
||||
- Alias: `@/` resolves to `src/`
|
||||
- Run: `pnpm test --filter @repo/core-ui`
|
||||
- Storybook stories colocated: `src/atoms/button/button.stories.tsx`
|
||||
|
||||
## Structure
|
||||
|
||||
> To scaffold a new component, use `pnpm turbo gen core-ui-component` rather than creating files manually. The generator emits the 4-file pattern below and splices the export into the tier barrel.
|
||||
|
||||
```
|
||||
src/
|
||||
atoms/
|
||||
{name}/
|
||||
{name}.tsx # Component
|
||||
{name}.test.tsx # Tests
|
||||
{name}.stories.tsx # Storybook story
|
||||
index.ts # Barrel export
|
||||
molecules/
|
||||
{name}/
|
||||
...
|
||||
organisms/
|
||||
{name}/
|
||||
...
|
||||
templates/
|
||||
{name}/
|
||||
...
|
||||
lib/
|
||||
utils.ts # cn() and helpers
|
||||
index.ts # Re-exports everything
|
||||
```
|
||||
68
packages/core-ui/docs/library-decisions/2026-05-14-clsx.md
Normal file
68
packages/core-ui/docs/library-decisions/2026-05-14-clsx.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
package: clsx
|
||||
version: "^2.1.1"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view clsx license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
Ships first-party TypeScript types in its distribution.
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Maintained by Luke Edwards; stable, minimal API.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core UI package. `clsx` is a utility for constructing `className` strings; appropriate for `core-ui`. No boundary rule violation.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
No competing className utility in the workspace. No shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Pure compute; no network calls or vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-ui` uses `clsx` in the `cn()` utility (combined with `tailwind-merge`) for conditional class composition.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing — this is the initial UI scaffold.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Mechanical: replace `clsx()` calls with template literals or equivalent. Minimal API surface.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
1. **classnames** — the older predecessor; `clsx` is smaller and faster.
|
||||
2. **Template literals** — verbose; no conditional logic support.
|
||||
`clsx` is the de-facto standard lightweight className utility.
|
||||
66
packages/core-ui/docs/library-decisions/2026-05-14-react.md
Normal file
66
packages/core-ui/docs/library-decisions/2026-05-14-react.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
package: react
|
||||
version: "^19.0.0"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: "@types/react"
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view react license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
TypeScript types via `@types/react` (community-maintained but canonical; ships in sync with each React major).
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Maintained by Meta; v19 is the current stable major.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core UI package. React is required for the component library; appropriate for `core-ui`. No boundary rule violation.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
React is already workspace-present in app packages. Same major version; no shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Client-side rendering library; no vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-ui` renders all atomic-design components (Button, Input, Label, FormField) as React components.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing — React is already the UI framework in the workspace.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Impossible: React is the foundational UI library for this workspace.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
React is workspace-locked as the UI framework. No alternative evaluated.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
package: tailwind-merge
|
||||
version: "^3.0.0"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view tailwind-merge license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
Ships first-party TypeScript types in its distribution.
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Maintained by dcastil; v3 is the current stable major.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core UI package. `tailwind-merge` deduplicates conflicting Tailwind classes; appropriate for `core-ui`. No boundary rule violation.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
No competing Tailwind class-merging utility in the workspace. No shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Pure compute; no network calls or vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-ui` uses `tailwind-merge` in the `cn()` utility (combined with `clsx`) to resolve conflicting Tailwind class names at runtime.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing — this is the initial UI scaffold.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Mechanical: replace `twMerge()` calls in the `cn()` utility; update any call sites. Narrow API surface.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
1. **Custom deduplication** — error-prone; Tailwind has hundreds of class groups that change each version.
|
||||
2. **tw-join** — does not merge conflicts; only concatenates.
|
||||
`tailwind-merge` is the de-facto standard for conflict-free Tailwind class composition.
|
||||
3
packages/core-ui/eslint.config.js
Normal file
3
packages/core-ui/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
38
packages/core-ui/package.json
Normal file
38
packages/core-ui/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@repo/core-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./styles/globals.css": "./src/styles/globals.css",
|
||||
"./styles/theme.css": "./src/styles/theme.css"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-consent": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@storybook/react": "^8.6.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/jest-axe": "^3.5.9",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
38
packages/core-ui/src/atoms/button/button.stories.tsx
Normal file
38
packages/core-ui/src/atoms/button/button.stories.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "./button";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "secondary", "destructive", "outline", "ghost"],
|
||||
},
|
||||
size: { control: "select", options: ["sm", "default", "lg"] },
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Button", variant: "default" },
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: { children: "Destructive", variant: "destructive" },
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: { children: "Outline", variant: "outline" },
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { children: "Ghost", variant: "ghost" },
|
||||
};
|
||||
44
packages/core-ui/src/atoms/button/button.test.tsx
Normal file
44
packages/core-ui/src/atoms/button/button.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithProviders } from "@repo/core-testing/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders children inside a button", () => {
|
||||
renderWithProviders(<Button>Click me</Button>);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Click me" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClick when activated", async () => {
|
||||
const handleClick = vi.fn();
|
||||
renderWithProviders(<Button onClick={handleClick}>Go</Button>);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Go" }));
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies the destructive variant class", () => {
|
||||
renderWithProviders(<Button variant="destructive">X</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass(/destructive/);
|
||||
});
|
||||
|
||||
it("applies the lg size class", () => {
|
||||
renderWithProviders(<Button size="lg">X</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass(/h-11/);
|
||||
});
|
||||
|
||||
it("disabled prop sets the attribute and prevents click handler", async () => {
|
||||
const handleClick = vi.fn();
|
||||
renderWithProviders(
|
||||
<Button disabled onClick={handleClick}>
|
||||
X
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
await userEvent.click(button);
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
41
packages/core-ui/src/atoms/button/button.tsx
Normal file
41
packages/core-ui/src/atoms/button/button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
|
||||
size?: "sm" | "default" | "lg";
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
default: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8 text-lg",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
1
packages/core-ui/src/atoms/button/index.ts
Normal file
1
packages/core-ui/src/atoms/button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
4
packages/core-ui/src/atoms/index.ts
Normal file
4
packages/core-ui/src/atoms/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// <gen:atoms>
|
||||
export { Button, type ButtonProps } from "./button/index";
|
||||
export { Input, type InputProps } from "./input/index";
|
||||
export { Label, type LabelProps } from "./label/index";
|
||||
1
packages/core-ui/src/atoms/input/index.ts
Normal file
1
packages/core-ui/src/atoms/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Input, type InputProps } from "./input";
|
||||
19
packages/core-ui/src/atoms/input/input.stories.tsx
Normal file
19
packages/core-ui/src/atoms/input/input.stories.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Input } from "./input";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Input",
|
||||
component: Input,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Input>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { placeholder: "Enter text..." },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { placeholder: "Disabled", disabled: true },
|
||||
};
|
||||
34
packages/core-ui/src/atoms/input/input.test.tsx
Normal file
34
packages/core-ui/src/atoms/input/input.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "@repo/core-testing/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Input", () => {
|
||||
it("renders an input with the given placeholder", () => {
|
||||
renderWithProviders(<Input placeholder="email" />);
|
||||
expect(screen.getByPlaceholderText("email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards the type attribute", () => {
|
||||
renderWithProviders(<Input type="email" placeholder="email" />);
|
||||
expect(screen.getByPlaceholderText("email")).toHaveAttribute(
|
||||
"type",
|
||||
"email",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts user input", async () => {
|
||||
renderWithProviders(<Input placeholder="email" />);
|
||||
const input = screen.getByPlaceholderText("email");
|
||||
await userEvent.type(input, "hi@example.com");
|
||||
expect(input).toHaveValue("hi@example.com");
|
||||
});
|
||||
|
||||
it("disabled prop blocks user input", async () => {
|
||||
renderWithProviders(<Input placeholder="email" disabled />);
|
||||
const input = screen.getByPlaceholderText("email");
|
||||
await userEvent.type(input, "hi");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
21
packages/core-ui/src/atoms/input/input.tsx
Normal file
21
packages/core-ui/src/atoms/input/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
1
packages/core-ui/src/atoms/label/index.ts
Normal file
1
packages/core-ui/src/atoms/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Label, type LabelProps } from "./label";
|
||||
19
packages/core-ui/src/atoms/label/label.stories.tsx
Normal file
19
packages/core-ui/src/atoms/label/label.stories.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Label } from "./label";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Label",
|
||||
component: Label,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Label>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Form label" },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { children: "Disabled label", className: "peer-disabled:opacity-70" },
|
||||
};
|
||||
16
packages/core-ui/src/atoms/label/label.test.tsx
Normal file
16
packages/core-ui/src/atoms/label/label.test.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "@repo/core-testing/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Label", () => {
|
||||
it("renders the label text", () => {
|
||||
renderWithProviders(<Label htmlFor="email">Email</Label>);
|
||||
expect(screen.getByText("Email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards htmlFor to associate with an input", () => {
|
||||
renderWithProviders(<Label htmlFor="email">Email</Label>);
|
||||
expect(screen.getByText("Email")).toHaveAttribute("for", "email");
|
||||
});
|
||||
});
|
||||
20
packages/core-ui/src/atoms/label/label.tsx
Normal file
20
packages/core-ui/src/atoms/label/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { forwardRef, type LabelHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement>;
|
||||
|
||||
export const Label = forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Label.displayName = "Label";
|
||||
@@ -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,61 @@
|
||||
/**
|
||||
* Isolated test file for the error-re-throw path in useOptionalConsent.
|
||||
* Uses vi.mock to make useConsent throw a non-ConsentContextError so that
|
||||
* lines 52-53 (the `throw e` branch) are exercised.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("@repo/core-consent/react", () => ({
|
||||
useConsent: vi.fn(() => {
|
||||
throw new Error("unexpected-non-consent-error");
|
||||
}),
|
||||
ConsentContextError: class ConsentContextError extends Error {
|
||||
constructor() {
|
||||
super("no provider");
|
||||
this.name = "ConsentContextError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { CookieConsentBanner } from "./cookie-consent-banner";
|
||||
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ caught: string | null }
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { caught: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { caught: error.message };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.caught) {
|
||||
return <div data-testid="caught">{this.state.caught}</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
describe("CookieConsentBanner — useOptionalConsent rethrows non-ConsentContextError", () => {
|
||||
it("surfaces unexpected render errors through an error boundary", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<CookieConsentBanner variant="modal" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("caught")).toHaveTextContent(
|
||||
"unexpected-non-consent-error",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -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,478 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
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";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CookieConsentBanner — axe a11y (WCAG 2.2 AA)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("CookieConsentBanner — axe a11y", () => {
|
||||
it("modal variant has no axe violations", async () => {
|
||||
const { container } = renderWithProviders(
|
||||
<CookieConsentBanner variant="modal" />,
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("banner variant has no axe violations", async () => {
|
||||
const { container } = renderWithProviders(
|
||||
<CookieConsentBanner variant="banner" />,
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
5
packages/core-ui/src/index.ts
Normal file
5
packages/core-ui/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { cn } from "./lib/utils";
|
||||
export * from "./atoms/index";
|
||||
export * from "./molecules/index";
|
||||
export * from "./organisms/index";
|
||||
export * from "./templates/index";
|
||||
6
packages/core-ui/src/lib/utils.ts
Normal file
6
packages/core-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FormField } from "./form-field";
|
||||
|
||||
const meta = {
|
||||
title: "Molecules/FormField",
|
||||
component: FormField,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof FormField>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: "Email", placeholder: "you@example.com", type: "email" },
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
label: "Username",
|
||||
placeholder: "johndoe",
|
||||
description: "Must be 3-31 characters",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
error: "Password must be at least 6 characters",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "@repo/core-testing/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { FormField } from "./form-field";
|
||||
|
||||
describe("FormField", () => {
|
||||
it("renders label associated with input via auto-derived id", () => {
|
||||
renderWithProviders(<FormField label="Email Address" />);
|
||||
const input = screen.getByLabelText("Email Address");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("id", "email-address");
|
||||
});
|
||||
|
||||
it("uses an explicit id over the derived one", () => {
|
||||
renderWithProviders(<FormField label="Email" id="custom-id" />);
|
||||
expect(screen.getByLabelText("Email")).toHaveAttribute("id", "custom-id");
|
||||
});
|
||||
|
||||
it("renders a description when provided", () => {
|
||||
renderWithProviders(
|
||||
<FormField label="Email" description="We never share it." />,
|
||||
);
|
||||
expect(screen.getByText("We never share it.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error message when provided", () => {
|
||||
renderWithProviders(<FormField label="Email" error="Required" />);
|
||||
expect(screen.getByText("Required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards input props (e.g., type) to the underlying input", () => {
|
||||
renderWithProviders(<FormField label="Email" type="email" />);
|
||||
expect(screen.getByLabelText("Email")).toHaveAttribute("type", "email");
|
||||
});
|
||||
});
|
||||
31
packages/core-ui/src/molecules/form-field/form-field.tsx
Normal file
31
packages/core-ui/src/molecules/form-field/form-field.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Label } from "../../atoms/label/index";
|
||||
import { Input, type InputProps } from "../../atoms/input/index";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface FormFieldProps extends InputProps {
|
||||
label: string;
|
||||
error?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
error,
|
||||
description,
|
||||
className,
|
||||
id,
|
||||
...inputProps
|
||||
}: FormFieldProps) {
|
||||
const fieldId = id ?? label.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<Label htmlFor={fieldId}>{label}</Label>
|
||||
<Input id={fieldId} {...inputProps} />
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
packages/core-ui/src/molecules/form-field/index.ts
Normal file
1
packages/core-ui/src/molecules/form-field/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FormField, type FormFieldProps } from "./form-field";
|
||||
2
packages/core-ui/src/molecules/index.ts
Normal file
2
packages/core-ui/src/molecules/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// <gen:molecules>
|
||||
export { FormField, type FormFieldProps } from "./form-field/index";
|
||||
2
packages/core-ui/src/organisms/index.ts
Normal file
2
packages/core-ui/src/organisms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// <gen:organisms>
|
||||
export * from "../cookie-consent-banner/index";
|
||||
2
packages/core-ui/src/styles/globals.css
Normal file
2
packages/core-ui/src/styles/globals.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "./theme.css";
|
||||
24
packages/core-ui/src/styles/theme.css
Normal file
24
packages/core-ui/src/styles/theme.css
Normal file
@@ -0,0 +1,24 @@
|
||||
@theme {
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--color-accent: hsl(240 4.8% 95.9%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(240 5.9% 10%);
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
}
|
||||
1
packages/core-ui/src/templates/index.ts
Normal file
1
packages/core-ui/src/templates/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
13
packages/core-ui/tsconfig.json
Normal file
13
packages/core-ui/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.stories.tsx", "**/*.stories.ts"]
|
||||
}
|
||||
4
packages/core-ui/turbo.json
Normal file
4
packages/core-ui/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
24
packages/core-ui/vitest.config.ts
Normal file
24
packages/core-ui/vitest.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
|
||||
|
||||
export default mergeConfig(jsdomVitestConfig, {
|
||||
resolve: {
|
||||
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}",
|
||||
"src/**/*.stories.{ts,tsx}",
|
||||
"src/**/index.ts",
|
||||
"src/__factories__/**",
|
||||
"src/__contracts__/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user