feat(v1): bloquants release v1 — tests, stories, visual regression, gouvernance, publishing
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user