feat(v1): bloquants release v1 — tests, stories, visual regression, gouvernance, publishing
Release / Release / open changeset PR (push) Has been cancelled
CI / Build, typecheck, test, a11y (push) Has been cancelled

6 chantiers v1 sur 7 livrés (DataTable refonte reportée car nécessite
2-3j en propre — TanStack Table + virtualisation + filter builder).

v1-A — Tests (4 → 22 fichiers) :
- Avatar, AvatarGroup, UserCard, MetricCard, ProfileHeader, Tooltip,
  Sheet, Drawer, Slider, ToggleGroup, Tabs, Pagination, Accordion,
  Switch, Badge, ConfirmDialog, Popover, Menu, Text, PricingCard,
  FeatureCard, Toast — chacun avec render + clavier + axe-core.

v1-B — Storybook (7 → 23 fichiers) :
- Avatar, UserCard, ProfileHeader, MetricCard, PricingCard,
  FeatureCard, Sheet (4 sides), HoverCard, Slider, ToggleGroup,
  Menu+ContextMenu, Toast (avec démo "Empiler 5"), Tabs,
  Pagination, Accordion, Badge.

v1-D — Visual regression Playwright :
- playwright.config.ts (light + dark, threshold strict 0.2)
- e2e/visual.spec.ts (20 stories critiques)
- Step CI + upload report en cas de fail

v1-E — Site doc Starlight rempli :
- 11 pages composants détaillées (Button, Input, Tooltip, Dialog,
  Toast, Avatar, ThemePicker, MetricCard, PricingCard, ToggleGroup,
  Slider) avec API, anatomie, do/don't, A11y.

v1-F — Publishing Verdaccio :
- verdaccio/config.yaml, docker-compose.verdaccio.yml, .npmrc
- README setup local + déploiement prod + backups + sécurité

v1-G — Gouvernance :
- LICENSE, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md
- CODEOWNERS, PR template, 3 issue templates (bug/feature/rfc)

Bug fix bonus : tooltip dark mode (text-primary comme bg + text-inverse
comme texte → blanc-sur-blanc invisible). Remplacé par neutral-900/0
en light + bg-raised/text-primary en dark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dinawo
2026-05-04 22:29:35 +02:00
parent 62317f2ad7
commit 133feff75d
69 changed files with 3433 additions and 7 deletions
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Accordion } from "./Accordion";
describe("Accordion", () => {
it("est fermé par défaut", () => {
render(<Accordion label="Section">Contenu</Accordion>);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
});
it("toggle au clic", async () => {
const user = userEvent.setup();
render(<Accordion label="Section">Contenu</Accordion>);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
});
it("toggle au clavier (Enter / Space)", async () => {
const user = userEvent.setup();
render(<Accordion label="Section">Contenu</Accordion>);
const btn = screen.getByRole("button");
btn.focus();
await user.keyboard("{Enter}");
expect(btn).toHaveAttribute("aria-expanded", "true");
await user.keyboard(" ");
expect(btn).toHaveAttribute("aria-expanded", "false");
});
it("aria-controls relié au panel", () => {
render(<Accordion label="x">y</Accordion>);
const id = screen.getByRole("button").getAttribute("aria-controls");
expect(document.getElementById(id!)).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(<Accordion label="x">y</Accordion>);
expect(await axe(container)).toHaveNoViolations();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { Avatar } from "./Avatar";
describe("Avatar", () => {
it("rend les initiales", () => {
render(<Avatar initials="md" alt="Marie Dupont" />);
expect(screen.getByText("MD")).toBeInTheDocument();
});
it("limite à 2 caractères", () => {
render(<Avatar initials="abcdef" alt="x" />);
expect(screen.getByText("AB")).toBeInTheDocument();
});
it("rend une image quand src est fourni", () => {
render(<Avatar src="/avatar.png" alt="Marie" />);
const img = screen.getByRole("img", { name: "Marie" });
expect(img).toHaveAttribute("src", "/avatar.png");
});
it("expose un statut accessible", () => {
render(<Avatar initials="MD" alt="Marie" status="online" />);
expect(screen.getByLabelText("En ligne")).toBeInTheDocument();
});
it("génère une couleur stable depuis les initiales", () => {
const { container, rerender } = render(<Avatar initials="MD" alt="x" />);
const className1 = container.firstChild?.className ?? "";
rerender(<Avatar initials="MD" alt="x" />);
expect(container.firstChild?.className).toBe(className1);
});
it("a11y axe-core", async () => {
const { container } = render(
<>
<Avatar initials="MD" alt="Marie Dupont" status="online" />
<Avatar src="/x.png" alt="Jean Martin" status="busy" size="lg" />
<Avatar initials="SB" alt="Sophie Bernard" shape="square" />
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { AvatarGroup } from "./AvatarGroup";
describe("AvatarGroup", () => {
const data = [
{ initials: "MD", alt: "Marie" },
{ initials: "JM", alt: "Jean" },
{ initials: "SB", alt: "Sophie" },
{ initials: "TL", alt: "Thomas" },
{ initials: "ER", alt: "Emma" },
{ initials: "LB", alt: "Lohann" },
];
it("limite l'affichage à max", () => {
render(<AvatarGroup avatars={data} max={3} />);
expect(screen.getByText("+3")).toBeInTheDocument();
expect(screen.getByLabelText(/3 autres/)).toBeInTheDocument();
});
it("respecte le total custom", () => {
render(<AvatarGroup avatars={data.slice(0, 3)} max={3} total={12} />);
expect(screen.getByText("+9")).toBeInTheDocument();
});
it("expose role group", () => {
render(<AvatarGroup avatars={data.slice(0, 2)} />);
expect(screen.getByRole("group")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(<AvatarGroup avatars={data} max={4} />);
expect(await axe(container)).toHaveNoViolations();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { axe } from "vitest-axe";
import { Badge } from "./Feedback";
describe("Badge", () => {
it("rend les variants sans crash", () => {
const { container } = render(
<>
<Badge>Default</Badge>
<Badge variant="brand">Brand</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
<Badge variant="info">Info</Badge>
</>,
);
expect(container.querySelectorAll(".mmg-badge")).toHaveLength(6);
});
it("a11y axe-core", async () => {
const { container } = render(
<>
<Badge variant="brand">Nouveau</Badge>
<Badge variant="success">Actif</Badge>
<Badge variant="warning">En attente</Badge>
<Badge variant="danger">Erreur</Badge>
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { ConfirmDialog } from "./ConfirmDialog";
function Wrapped({ destructive }: { destructive?: boolean }) {
const [open, setOpen] = useState(false);
const [confirmed, setConfirmed] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<span data-testid="state">{confirmed ? "yes" : "no"}</span>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
destructive={destructive}
title="Supprimer ?"
description="Action irréversible."
confirmLabel="Supprimer"
onConfirm={() => setConfirmed(true)}
/>
</>
);
}
describe("ConfirmDialog", () => {
it("appelle onConfirm puis ferme", async () => {
const user = userEvent.setup();
render(<Wrapped destructive />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
await user.click(screen.getByRole("button", { name: "Supprimer" }));
expect(screen.getByTestId("state")).toHaveTextContent("yes");
});
it("Annuler ferme sans appeler onConfirm", async () => {
const user = userEvent.setup();
render(<Wrapped destructive />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
await user.click(screen.getByRole("button", { name: "Annuler" }));
expect(screen.getByTestId("state")).toHaveTextContent("no");
});
it("a11y axe-core (ouvert)", async () => {
const user = userEvent.setup();
const { container } = render(<Wrapped destructive />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
expect(await axe(container)).toHaveNoViolations();
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Drawer } from "./Drawer";
function Wrapped() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<Drawer open={open} onClose={() => setOpen(false)} title="Filtres">
<p>Contenu drawer</p>
</Drawer>
</>
);
}
describe("Drawer", () => {
it("ouvre + Esc ferme", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Open"));
expect(await screen.findByRole("dialog")).toHaveAccessibleName("Filtres");
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("a11y axe-core", async () => {
const user = userEvent.setup();
const { container } = render(<Wrapped />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
expect(await axe(container)).toHaveNoViolations();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { FeatureCard } from "./FeatureCard";
describe("FeatureCard", () => {
it("rend titre + description + lien", () => {
render(
<FeatureCard
icon="rocket-2-fill"
title="Onboarding éclair"
description="Créez un user en 90s."
link={{ label: "Voir", href: "/x" }}
/>,
);
expect(screen.getByText("Onboarding éclair")).toBeInTheDocument();
expect(screen.getByText("Créez un user en 90s.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Voir/ })).toHaveAttribute("href", "/x");
});
it("variante glow ajoute la classe", () => {
const { container } = render(
<FeatureCard icon="rocket-2-fill" title="x" glowOnHover />,
);
expect(container.querySelector(".mmg-feature-card--glow")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<FeatureCard
icon="shield-check-fill"
iconColor="green"
title="RGPD by design"
description="Audit ANSSI."
link={{ label: "Notre politique", href: "#" }}
glowOnHover
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Menu } from "./Menu";
const items = [
{ label: "Modifier", icon: "edit-line" as const, onSelect: () => {} },
{ label: "Dupliquer", icon: "file-copy-line" as const, onSelect: () => {} },
{ type: "divider" } as const,
{ label: "Supprimer", icon: "delete-bin-line" as const, danger: true, onSelect: () => {} },
];
describe("Menu", () => {
it("ouvre + flèches naviguent + Enter sélectionne", async () => {
const user = userEvent.setup();
let selected = "";
const items2 = [
{ label: "A", onSelect: () => (selected = "A") },
{ label: "B", onSelect: () => (selected = "B") },
];
render(<Menu trigger={<button type="button">Open</button>} items={items2} />);
await user.click(screen.getByText("Open"));
await screen.findByRole("menu");
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(selected).toBe("B");
});
it("Escape ferme", async () => {
const user = userEvent.setup();
render(<Menu trigger={<button type="button">Open</button>} items={items} />);
await user.click(screen.getByText("Open"));
await screen.findByRole("menu");
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("a11y axe-core (ouvert)", async () => {
const user = userEvent.setup();
const { container } = render(<Menu trigger={<button type="button">Open</button>} items={items} />);
await user.click(screen.getByText("Open"));
await screen.findByRole("menu");
expect(await axe(container)).toHaveNoViolations();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { MetricCard } from "./MetricCard";
describe("MetricCard", () => {
it("rend label + valeur", () => {
render(<MetricCard label="MRR" value="84 320 €" />);
expect(screen.getByText("MRR")).toBeInTheDocument();
expect(screen.getByText("84 320 €")).toBeInTheDocument();
});
it("affiche delta + period", () => {
render(
<MetricCard label="x" value="100" delta="+12.4%" period="vs M-1" trend="up" />,
);
expect(screen.getByText("+12.4%")).toBeInTheDocument();
expect(screen.getByText("vs M-1")).toBeInTheDocument();
});
it("invertTrend inverse la sémantique colorée", () => {
const { container, rerender } = render(
<MetricCard label="x" value="1" delta="+5%" trend="up" />,
);
const success1 = container.querySelector(".mmg-metric-card__delta--success");
expect(success1).not.toBeNull();
rerender(
<MetricCard label="x" value="1" delta="+5%" trend="up" invertTrend />,
);
const danger = container.querySelector(".mmg-metric-card__delta--danger");
expect(danger).not.toBeNull();
});
it("interactive si href", () => {
render(<MetricCard label="x" value="1" href="/m" />);
expect(screen.getByRole("link")).toHaveAttribute("href", "/m");
});
it("a11y axe-core", async () => {
const { container } = render(
<MetricCard
label="MRR"
value="84 320 €"
delta="+12.4%"
trend="up"
period="vs mois dernier"
icon="money-euro-circle-line"
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Pagination } from "./Pagination";
function Wrapped() {
const [page, setPage] = useState(1);
return <Pagination page={page} pageCount={5} onChange={setPage} />;
}
describe("Pagination", () => {
it("page courante a aria-current=page", () => {
render(<Pagination page={3} pageCount={5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "3" })).toHaveAttribute("aria-current", "page");
});
it("clic sur une page change la valeur", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("button", { name: "3" }));
expect(screen.getByRole("button", { name: "3" })).toHaveAttribute("aria-current", "page");
});
it("précédent désactivé sur page 1", () => {
render(<Pagination page={1} pageCount={5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "Page précédente" })).toBeDisabled();
});
it("suivant désactivé sur dernière page", () => {
render(<Pagination page={5} pageCount={5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "Page suivante" })).toBeDisabled();
});
it("a11y axe-core", async () => {
const { container } = render(<Wrapped />);
expect(await axe(container)).toHaveNoViolations();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Popover } from "./Popover";
describe("Popover", () => {
it("trigger ouvre + escape ferme", async () => {
const user = userEvent.setup();
render(
<Popover trigger={<button type="button">Open</button>}>
<p>Contenu</p>
</Popover>,
);
await user.click(screen.getByText("Open"));
expect(await screen.findByText("Contenu")).toBeInTheDocument();
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Contenu")).not.toBeInTheDocument();
});
it("a11y axe-core (fermé)", async () => {
const { container } = render(
<Popover trigger={<button type="button">Open</button>}>
<p>Contenu</p>
</Popover>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { PricingCard } from "./PricingCard";
import { Button } from "./Button";
const features = [
{ label: "Jusqu'à 10 collaborateurs" },
{ label: "Support 24/7" },
{ label: "API & webhooks", included: false },
];
describe("PricingCard", () => {
it("rend nom + prix + features", () => {
render(
<PricingCard
name="Starter"
description="TPE"
price="9 €"
pricePeriod="/mois"
features={features}
cta={<Button>Choisir</Button>}
/>,
);
expect(screen.getByText("Starter")).toBeInTheDocument();
expect(screen.getByText("9 €")).toBeInTheDocument();
expect(screen.getByText("/mois")).toBeInTheDocument();
expect(screen.getByText("API & webhooks")).toBeInTheDocument();
});
it("highlighted ajoute la classe", () => {
const { container } = render(
<PricingCard name="Pro" price="19 €" features={features} highlighted cta={<Button>x</Button>} />,
);
expect(container.querySelector(".mmg-pricing-card--highlighted")).toBeInTheDocument();
});
it("badge optionnel", () => {
render(
<PricingCard name="Pro" price="x" features={features} badge="Populaire" cta={<Button>x</Button>} />,
);
expect(screen.getByText("Populaire")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<PricingCard
name="Pro"
description="PME en croissance."
price="19 €"
pricePeriod="/utilisateur/mois"
highlighted
badge="Populaire"
features={features}
cta={<Button variant="primary" block>Choisir</Button>}
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { ProfileHeader } from "./ProfileHeader";
describe("ProfileHeader", () => {
it("rend nom + sous-titre + bio", () => {
render(
<ProfileHeader
name="Marie Dupont"
subtitle="Lead Developer"
bio="Frontend depuis 2020."
initials="MD"
/>,
);
expect(screen.getByText("Marie Dupont")).toBeInTheDocument();
expect(screen.getByText("Lead Developer")).toBeInTheDocument();
expect(screen.getByText("Frontend depuis 2020.")).toBeInTheDocument();
});
it("rend les stats", () => {
render(
<ProfileHeader
name="x"
initials="x"
stats={[
{ label: "Commits", value: "184" },
{ label: "Reviews", value: "23" },
]}
/>,
);
expect(screen.getByText("Commits")).toBeInTheDocument();
expect(screen.getByText("184")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<ProfileHeader
name="Marie Dupont"
subtitle="Lead Developer · Synapse"
bio="Frontend lead."
initials="MD"
status="online"
stats={[{ label: "Commits", value: "184" }]}
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Sheet } from "./Sheet";
function Wrapped({ side }: { side?: "left" | "right" | "top" | "bottom" }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Ouvrir</button>
<Sheet open={open} onOpenChange={setOpen} side={side} title="Édition" description="Modifier l'utilisateur">
<p>Contenu</p>
</Sheet>
</>
);
}
describe("Sheet", () => {
it("est fermé par défaut", () => {
render(<Wrapped />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("s'ouvre via le trigger", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
});
it("a un titre lié via aria-labelledby (Radix)", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveAccessibleName("Édition");
});
it("se ferme avec Escape", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
await screen.findByRole("dialog");
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("a11y axe-core (ouvert)", async () => {
const user = userEvent.setup();
const { container } = render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
await screen.findByRole("dialog");
expect(await axe(container)).toHaveNoViolations();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Slider } from "./Slider";
function Single() {
const [v, setV] = useState([50]);
return <Slider label="Volume" value={v} onValueChange={setV} />;
}
function Range() {
const [v, setV] = useState([20, 80]);
return <Slider label="Range" value={v} onValueChange={setV} />;
}
describe("Slider", () => {
it("expose un thumb avec aria-valuenow", () => {
render(<Single />);
const slider = screen.getByRole("slider");
expect(slider).toHaveAttribute("aria-valuenow", "50");
});
it("range expose 2 thumbs", () => {
render(<Range />);
expect(screen.getAllByRole("slider")).toHaveLength(2);
});
it("flèche droite incrémente", async () => {
const user = userEvent.setup();
render(<Single />);
const slider = screen.getByRole("slider");
slider.focus();
await user.keyboard("{ArrowRight}");
expect(slider).toHaveAttribute("aria-valuenow", "51");
});
it("Home / End naviguent aux extrêmes", async () => {
const user = userEvent.setup();
render(<Single />);
screen.getByRole("slider").focus();
await user.keyboard("{End}");
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "100");
await user.keyboard("{Home}");
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "0");
});
it("a11y axe-core", async () => {
const { container } = render(<Single />);
expect(await axe(container)).toHaveNoViolations();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Switch } from "./Form";
describe("Switch", () => {
it("rend en checkbox-like", () => {
render(<Switch label="Notifications" />);
expect(screen.getByRole("checkbox", { name: "Notifications" })).toBeInTheDocument();
});
it("toggle au clavier (Space)", async () => {
const user = userEvent.setup();
render(<Switch label="x" />);
const sw = screen.getByRole("checkbox");
sw.focus();
await user.keyboard(" ");
expect(sw).toBeChecked();
});
it("respecte defaultChecked", () => {
render(<Switch label="x" defaultChecked />);
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("disabled empêche les clicks", async () => {
const user = userEvent.setup();
render(<Switch label="x" disabled />);
await user.click(screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox")).not.toBeChecked();
});
it("a11y axe-core", async () => {
const { container } = render(
<>
<Switch label="Notifications" />
<Switch label="Marketing" defaultChecked />
<Switch label="Désactivé" disabled />
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Tabs } from "./Tabs";
function Wrapped() {
const [v, setV] = useState("a");
return (
<Tabs
value={v}
onChange={setV}
items={[
{ id: "a", label: "Aperçu" },
{ id: "b", label: "Détails" },
{ id: "c", label: "Données" },
]}
/>
);
}
describe("Tabs", () => {
it("expose role tablist + tabs", () => {
render(<Wrapped />);
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getAllByRole("tab")).toHaveLength(3);
});
it("clic sur un tab le sélectionne", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("tab", { name: "Détails" }));
expect(screen.getByRole("tab", { name: "Détails" })).toHaveAttribute("aria-selected", "true");
});
it("a11y axe-core", async () => {
const { container } = render(<Wrapped />);
expect(await axe(container)).toHaveNoViolations();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { Text, Display, Eyebrow, Lead } from "./Text";
describe("Text", () => {
it("rend en h1 par défaut pour display", () => {
const { container } = render(<Text variant="display-xl">Hero</Text>);
expect(container.querySelector("h1")).toBeInTheDocument();
});
it("rend en p pour lead", () => {
const { container } = render(<Text variant="lead">Lead</Text>);
expect(container.querySelector("p")).toBeInTheDocument();
});
it("respecte la prop as", () => {
const { container } = render(<Text variant="h1" as="h2">Section</Text>);
expect(container.querySelector("h2")).toBeInTheDocument();
expect(container.querySelector("h1")).not.toBeInTheDocument();
});
it("applique les modificateurs en classes", () => {
const { container } = render(
<Text variant="body" italic emphasis highlight>x</Text>,
);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain("mmg-text--italic");
expect(el.className).toContain("mmg-text--emphasis");
expect(el.className).toContain("mmg-text--highlight");
});
it("helpers Display / Eyebrow / Lead", () => {
render(
<>
<Eyebrow>SIRH</Eyebrow>
<Display size="xl">Hero</Display>
<Lead>Sub</Lead>
</>,
);
expect(screen.getByText("SIRH")).toBeInTheDocument();
expect(screen.getByText("Hero")).toBeInTheDocument();
expect(screen.getByText("Sub")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<article>
<Eyebrow>SIRH</Eyebrow>
<Display size="xl">Le temps de vos équipes</Display>
<Lead>Plannings, congés, paie. Une plateforme.</Lead>
<Text>
Body normal avec <Text as="em" italic>terme étranger</Text> et{" "}
<Text as="strong" emphasis>mot-clef</Text>.
</Text>
</article>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { ToastProvider, useToast } from "./Toast";
function Trigger() {
const { toast } = useToast();
return (
<button
onClick={() =>
toast({ title: "Sauvegardé", description: "Modifs OK.", severity: "success" })
}
>
Trigger
</button>
);
}
describe("Toast", () => {
it("affiche un toast au trigger", async () => {
const user = userEvent.setup();
render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await user.click(screen.getByText("Trigger"));
expect(await screen.findByText("Sauvegardé")).toBeInTheDocument();
});
it("danger severity → role=alert", async () => {
const user = userEvent.setup();
function T() {
const { toast } = useToast();
return (
<button onClick={() => toast({ title: "Erreur", severity: "danger" })}>x</button>
);
}
render(
<ToastProvider>
<T />
</ToastProvider>,
);
await user.click(screen.getByText("x"));
expect(await screen.findByRole("alert")).toBeInTheDocument();
});
it("close button dismisse", async () => {
const user = userEvent.setup();
render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await user.click(screen.getByText("Trigger"));
await screen.findByText("Sauvegardé");
await user.click(screen.getByLabelText("Fermer la notification"));
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Sauvegardé")).not.toBeInTheDocument();
});
it("a11y axe-core", async () => {
const user = userEvent.setup();
const { container } = render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await user.click(screen.getByText("Trigger"));
await screen.findByText("Sauvegardé");
expect(await axe(container)).toHaveNoViolations();
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { ToggleGroup } from "./ToggleGroup";
const ITEMS = [
{ value: "list", label: "Liste" },
{ value: "grid", label: "Grille" },
{ value: "kanban", label: "Kanban" },
];
function Single() {
const [v, setV] = useState<string | undefined>("list");
return (
<ToggleGroup type="single" value={v} onValueChange={setV} ariaLabel="Vue" items={ITEMS} />
);
}
function Multiple() {
const [v, setV] = useState<string[]>(["list", "grid"]);
return (
<ToggleGroup type="multiple" value={v} onValueChange={setV} ariaLabel="Filtres" items={ITEMS} />
);
}
describe("ToggleGroup", () => {
it("rend tous les items en single", () => {
render(<Single />);
expect(screen.getAllByRole("radio")).toHaveLength(3);
});
it("clic toggle l'item en single", async () => {
const user = userEvent.setup();
render(<Single />);
await user.click(screen.getByRole("radio", { name: "Grille" }));
expect(screen.getByRole("radio", { name: "Grille" })).toHaveAttribute("data-state", "on");
});
it("rend en mode multiple plusieurs actifs", () => {
render(<Multiple />);
const buttons = screen.getAllByRole("button");
expect(buttons.filter((b) => b.getAttribute("data-state") === "on")).toHaveLength(2);
});
it("a11y axe-core", async () => {
const { container } = render(<Single />);
expect(await axe(container)).toHaveNoViolations();
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Tooltip, TooltipProvider } from "./Tooltip";
const wrap = (ui: React.ReactNode) => render(<TooltipProvider>{ui}</TooltipProvider>);
describe("Tooltip", () => {
it("affiche au hover", async () => {
const user = userEvent.setup();
wrap(
<Tooltip content="Action sécurisée">
<button type="button">Hover me</button>
</Tooltip>,
);
await user.hover(screen.getByRole("button"));
expect(await screen.findByRole("tooltip")).toHaveTextContent("Action sécurisée");
});
it("affiche au focus clavier", async () => {
const user = userEvent.setup();
wrap(
<Tooltip content="Tip">
<button type="button">Focus me</button>
</Tooltip>,
);
await user.tab();
expect(await screen.findByRole("tooltip")).toBeInTheDocument();
});
it("se ferme avec Escape", async () => {
const user = userEvent.setup();
wrap(
<Tooltip content="Tip" delay={0}>
<button type="button">x</button>
</Tooltip>,
);
await user.hover(screen.getByRole("button"));
await screen.findByRole("tooltip");
await user.keyboard("{Escape}");
// Radix removes role=tooltip after escape
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = wrap(
<Tooltip content="Tip">
<button type="button">x</button>
</Tooltip>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
it("rend nom + role", () => {
render(<UserCard name="Marie Dupont" role="Lead Dev" />);
expect(screen.getByText("Marie Dupont")).toBeInTheDocument();
expect(screen.getByText("Lead Dev")).toBeInTheDocument();
});
it("génère les initiales auto depuis le nom", () => {
render(<UserCard name="Marie Dupont" role="Lead" />);
expect(screen.getByText("MD")).toBeInTheDocument();
});
it("rend en lien si href fourni", () => {
render(<UserCard name="x" role="x" href="/profile/1" />);
expect(screen.getByRole("link")).toHaveAttribute("href", "/profile/1");
});
it("rend en bouton si onClick fourni", async () => {
const user = userEvent.setup();
let clicks = 0;
render(<UserCard name="x" role="x" onClick={() => clicks++} />);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(1);
});
it("focusable au clavier en mode interactif", async () => {
const user = userEvent.setup();
render(<UserCard name="x" role="x" href="#" />);
await user.tab();
expect(screen.getByRole("link")).toHaveFocus();
});
it("a11y axe-core", async () => {
const { container } = render(
<UserCard
name="Marie Dupont"
role="Lead Developer"
initials="MD"
status="online"
meta="marie@test.fr"
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});