chore: initial DSMMG v0.2 — refonte architecturale complète
Release / Release / open changeset PR (push) Has been cancelled
CI / Build, typecheck, test, a11y (push) Has been cancelled

Mise en place du Design System ManageMate Group v0.2 — refonte du
système de tokens (préfixe --mmg-color-*), 9 presets accent
user-themable validés WCAG AA, overlays Radix UI + Floating UI,
Storybook 8 + Vitest + axe-core en CI, doc Astro Starlight,
DESIGN.md (format google-labs-code) et exports tokens DTCG/CSS/
TS/Figma/Tailwind v3 et v4.

- 4 packages monorepo pnpm : @managemate/{tokens,css,react,icons}
- 62 composants React headless-first (Sheet, HoverCard, ContextMenu,
  Slider, ToggleGroup, AvatarGroup, UserCard, ProfileHeader,
  MetricCard, PricingCard, FeatureCard, Text/Display/Eyebrow/Lead…)
- Lint contraste WCAG : 37/37 paires AA, branché CI
- Toast pile Sonner-style avec ResizeObserver
- Theming user (9 presets) sans casser sémantique fixe
- Identité Synapse (rose #D12B6A) préservée

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dinawo
2026-05-04 22:07:57 +02:00
parent 5e019857fc
commit 62317f2ad7
172 changed files with 31397 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
import { useId, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type AccordionProps = {
label: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
className?: string;
};
export function Accordion({ label, defaultOpen = false, children, className }: AccordionProps) {
const [open, setOpen] = useState(defaultOpen);
const id = useId();
return (
<div className={cx("mmg-accordion", className)} data-open={open}>
<button
type="button"
className="mmg-accordion__btn"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
>
<span>{label}</span>
<Icon name="arrow-down-line" className="mmg-accordion__chevron" />
</button>
<div id={id} role="region" className="mmg-accordion__panel">
{children}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { Icon, type IconName } from "./Icon";
export type SidebarItem = {
id?: string;
label: ReactNode;
href: string;
icon?: IconName;
current?: boolean;
};
export type SidebarSection = {
category?: ReactNode;
items: SidebarItem[];
};
export function AppShell({ children }: { children: ReactNode }) {
return <div className="mmg-app-shell">{children}</div>;
}
export type SidebarProps = {
brand?: ReactNode;
sections: SidebarSection[];
footer?: ReactNode;
};
export function Sidebar({ brand, sections, footer }: SidebarProps) {
return (
<aside className="mmg-sidebar">
{brand && <div className="mmg-sidebar__header">{brand}</div>}
<nav className="mmg-sidebar__body" aria-label="Navigation latérale">
{sections.map((sec, i) => (
<div key={i}>
{sec.category && <div className="mmg-sidebar__category">{sec.category}</div>}
{sec.items.map((item, j) => (
<a
key={item.id ?? j}
href={item.href}
aria-current={item.current ? "page" : undefined}
className="mmg-sidebar__item"
>
{item.icon && <Icon name={item.icon} />}
{item.label}
</a>
))}
</div>
))}
</nav>
{footer && <div className="mmg-sidebar__footer">{footer}</div>}
</aside>
);
}
export function AppMain({ children }: { children: ReactNode }) {
return <div className="mmg-app-main">{children}</div>;
}
export type TopbarProps = {
title?: ReactNode;
actions?: ReactNode;
};
export function Topbar({ title, actions }: TopbarProps) {
return (
<header className="mmg-topbar">
<h1 className="mmg-topbar__title">{title}</h1>
{actions && <div className="mmg-topbar__actions">{actions}</div>}
</header>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type ArticleAsideProps = {
children: ReactNode;
sticky?: boolean;
className?: string;
};
export function ArticleAside({ children, sticky = true, className }: ArticleAsideProps) {
return (
<div className={cx("mmg-aside", sticky && "mmg-aside--sticky", className)}>
{children}
</div>
);
}
ArticleAside.Section = function ArticleAsideSection({
title,
children,
}: {
title: ReactNode;
children: ReactNode;
}) {
return (
<section className="mmg-aside__section">
<h3 className="mmg-aside__title">{title}</h3>
{children}
</section>
);
};
ArticleAside.Links = function ArticleAsideLinks({
items,
}: {
items: { label: ReactNode; href: string; external?: boolean }[];
}) {
return (
<ul className="mmg-aside__links">
{items.map((item, i) => (
<li key={i}>
<a
href={item.href}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
>
<Icon name={item.external ? "external-link-line" : "arrow-right-line"} size="sm" />
{item.label}
</a>
</li>
))}
</ul>
);
};
ArticleAside.Documents = function ArticleAsideDocuments({
items,
}: {
items: { label: ReactNode; href: string; size?: string; format?: string }[];
}) {
return (
<ul className="mmg-aside__docs">
{items.map((item, i) => (
<li key={i}>
<a href={item.href} download>
<Icon name="file-pdf-line" size="md" />
<span>
<span className="mmg-aside__doc-label">{item.label}</span>
{(item.size || item.format) && (
<span className="mmg-aside__doc-meta">
{[item.format, item.size].filter(Boolean).join(" · ")}
</span>
)}
</span>
</a>
</li>
))}
</ul>
);
};
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type ArticleCalloutProps = {
type?: "info" | "warning" | "important";
title?: ReactNode;
children: ReactNode;
};
export function ArticleCallout({ type = "info", title, children }: ArticleCalloutProps) {
const icon: IconName =
type === "warning"
? "alert-fill"
: type === "important"
? "error-warning-fill"
: "information-fill";
return (
<aside className={cx("mmg-prose__callout", `mmg-prose__callout--${type}`)}>
<Icon name={icon} size="md" className="mmg-prose__callout-icon" />
<div>
{title && <strong className="mmg-prose__callout-title">{title}</strong>}
<div>{children}</div>
</div>
</aside>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type ArticleFooterProps = {
/** Date de dernière mise à jour (ISO ou Date) */
updatedAt?: string | Date;
/** Locale pour le formatage de date — défaut fr-FR */
locale?: string;
/** URL canonique pour le partage */
shareUrl?: string;
/** Callback feedback */
onFeedback?: (helpful: boolean) => void;
className?: string;
};
export function ArticleFooter({
updatedAt,
locale = "fr-FR",
shareUrl,
onFeedback,
className,
}: ArticleFooterProps) {
const [helpful, setHelpful] = useState<"yes" | "no" | null>(null);
const date = updatedAt
? typeof updatedAt === "string"
? new Date(updatedAt)
: updatedAt
: null;
const formatted = date
? date.toLocaleDateString(locale, { day: "numeric", month: "long", year: "numeric" })
: null;
const submit = (h: boolean) => {
setHelpful(h ? "yes" : "no");
onFeedback?.(h);
};
return (
<footer className={cx("mmg-article__footer", className)}>
{formatted && (
<div className="mmg-article__updated">
<Icon name="time-line" size="sm" />
Page mise à jour le <strong>{formatted}</strong>
</div>
)}
{shareUrl && (
<div className="mmg-article__share">
<span className="mmg-article__share-label">Partager cette page&nbsp;:</span>
<a
href={`mailto:?subject=${encodeURIComponent("À voir")}&body=${encodeURIComponent(shareUrl)}`}
aria-label="Partager par e-mail"
className="mmg-article__share-btn"
>
<Icon name="mail-line" size="md" />
</a>
<button
type="button"
aria-label="Copier le lien"
className="mmg-article__share-btn"
onClick={() => navigator.clipboard?.writeText(shareUrl)}
>
<Icon name="file-copy-line" size="md" />
</button>
<button
type="button"
aria-label="Imprimer"
className="mmg-article__share-btn"
onClick={() => window.print()}
>
<Icon name="external-link-line" size="md" />
</button>
</div>
)}
<div className="mmg-article__feedback">
{helpful === null ? (
<>
<span className="mmg-article__feedback-label">
Cette page vous a-t-elle é utile&nbsp;?
</span>
<div style={{ display: "flex", gap: "var(--mmg-space-2)" }}>
<button
type="button"
className="mmg-article__feedback-btn"
onClick={() => submit(true)}
>
<Icon name="thumb-up-line" size="sm" />
Oui
</button>
<button
type="button"
className="mmg-article__feedback-btn"
onClick={() => submit(false)}
>
<Icon name="thumb-up-line" size="sm" style={{ transform: "rotate(180deg)" }} />
Non
</button>
</div>
</>
) : (
<span className="mmg-article__feedback-label">
Merci pour votre retour. Vos remarques nous aident à améliorer cette page.
</span>
)}
</div>
</footer>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type ArticleHeaderProps = {
breadcrumb?: { items: { label: ReactNode; href: string }[]; current: ReactNode };
/** Catégorie/type ("Démarche", "Information") affiché en eyebrow */
eyebrow?: ReactNode;
title: ReactNode;
lead?: ReactNode;
/** Métadonnées : auteur, dates, durée de lecture */
meta?: ReactNode;
className?: string;
};
export function ArticleHeader({ breadcrumb, eyebrow, title, lead, meta, className }: ArticleHeaderProps) {
return (
<header className={cx("mmg-article__header", className)}>
<div className="mmg-container mmg-container--narrow">
{breadcrumb && (
<nav className="mmg-breadcrumb" aria-label="Fil d'Ariane">
{breadcrumb.items.map((item, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<a href={item.href}>{item.label}</a>
<span className="mmg-breadcrumb__sep" aria-hidden>/</span>
</span>
))}
<span className="mmg-breadcrumb__current">{breadcrumb.current}</span>
</nav>
)}
{eyebrow && <div className="mmg-article__eyebrow">{eyebrow}</div>}
<h1 className="mmg-article__title">{title}</h1>
{lead && <p className="mmg-article__lead">{lead}</p>}
{meta && <div className="mmg-article__meta">{meta}</div>}
</div>
</header>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type ArticlePageProps = {
/** En-tête de page (ArticleHeader) */
header: ReactNode;
/** Aside (ArticleAside) — colonne droite, sticky en desktop */
aside?: ReactNode;
/** Footer (ArticleFooter) */
footer?: ReactNode;
/** Le contenu principal */
children: ReactNode;
className?: string;
};
export function ArticlePage({ header, aside, footer, children, className }: ArticlePageProps) {
return (
<article className={cx("mmg-article", className)}>
{header}
<div className="mmg-article__layout">
<div className="mmg-article__main">
<div className="mmg-prose">{children}</div>
{footer}
</div>
{aside && <aside className="mmg-article__aside">{aside}</aside>}
</div>
</article>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState, type ReactNode } from "react";
import { cx } from "./utils";
export type ArticleTOCProps = {
items: { id: string; label: ReactNode }[];
title?: ReactNode;
};
export function ArticleTOC({ items, title = "Sommaire" }: ArticleTOCProps) {
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible) setActive(visible.target.id);
},
{ rootMargin: "-20% 0px -70% 0px" },
);
items.forEach((item) => {
const el = document.getElementById(item.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [items]);
return (
<nav className="mmg-toc" aria-label="Sommaire">
<h3 className="mmg-aside__title">{title}</h3>
<ol className="mmg-toc__list">
{items.map((item, i) => (
<li key={item.id}>
<a
href={`#${item.id}`}
className={cx("mmg-toc__link", active === item.id && "mmg-toc__link--active")}
>
<span className="mmg-toc__num">{i + 1}.</span>
{item.label}
</a>
</li>
))}
</ol>
</nav>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
export type AvatarStatus = "online" | "away" | "busy" | "offline";
export type AvatarShape = "circle" | "square";
export type AvatarProps = {
/** Initiales si pas de src (auto-shortened à 2 chars). */
initials?: string;
/** URL de l'image. */
src?: string;
/** Alt text de l'image, ou aria-label de l'avatar si initiales. */
alt?: string;
size?: AvatarSize;
shape?: AvatarShape;
/** Indicateur de présence en bas-droite. */
status?: AvatarStatus;
/** Couleur de fond pour les initiales (auto-générée à partir du nom si non fournie). */
color?: "auto" | "neutral" | "brand" | "blue" | "green" | "amber" | "violet";
/** Bordure visible (utile pour AvatarGroup). */
bordered?: boolean;
className?: string;
children?: ReactNode;
};
/** Hash stable d'une string vers un index 0..N-1 pour générer une couleur. */
function hashIndex(str: string, n: number) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0;
return Math.abs(h) % n;
}
const AUTO_COLORS = ["brand", "blue", "green", "amber", "violet"] as const;
export function Avatar({
initials,
src,
alt,
size = "md",
shape = "circle",
status,
color = "auto",
bordered,
className,
children,
}: AvatarProps) {
const finalInitials = initials?.slice(0, 2).toUpperCase();
const autoColor =
color === "auto" && finalInitials
? AUTO_COLORS[hashIndex(finalInitials, AUTO_COLORS.length)]
: color === "auto"
? "neutral"
: color;
return (
<span
className={cx(
"mmg-avatar",
size !== "md" && `mmg-avatar--${size}`,
shape === "square" && "mmg-avatar--square",
!src && `mmg-avatar--${autoColor}`,
bordered && "mmg-avatar--bordered",
className,
)}
role={src ? undefined : "img"}
aria-label={alt}
>
{src ? (
<img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
) : (
children ?? finalInitials ?? null
)}
{status && (
<span
className={cx("mmg-avatar__status", `mmg-avatar__status--${status}`)}
aria-label={
status === "online"
? "En ligne"
: status === "away"
? "Absent"
: status === "busy"
? "Occupé"
: "Hors ligne"
}
/>
)}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Children, isValidElement, type ReactNode } from "react";
import { cx } from "./utils";
import { Avatar, type AvatarSize } from "./Avatar";
export type AvatarGroupProps = {
/** Avatars à empiler (Avatar enfants ou liste de descripteurs). */
children?: ReactNode;
/** Liste alternative d'avatars (équivalent à <Avatar /> en children). */
avatars?: { initials?: string; src?: string; alt?: string; href?: string }[];
/** Limite d'avatars visibles avant le compteur "+N". Défaut 4. */
max?: number;
size?: AvatarSize;
/** Total réel pour le "+N", utile si on n'affiche qu'un échantillon. */
total?: number;
className?: string;
};
/**
* AvatarGroup — empile des avatars avec un overflow "+N".
*
* Pattern Linear / GitHub / Slack pour montrer plusieurs participants
* sans saturer l'espace. Au survol d'un avatar, le z-index remonte pour
* permettre d'identifier qui c'est. Les avatars deviennent `bordered`
* pour se découper visuellement les uns des autres.
*/
export function AvatarGroup({
children,
avatars,
max = 4,
size = "md",
total,
className,
}: AvatarGroupProps) {
const items = avatars
? avatars.map((a, i) => (
<Avatar key={i} {...a} size={size} bordered />
))
: Children.toArray(children).filter(isValidElement);
const visible = items.slice(0, max);
const remaining = (total ?? items.length) - visible.length;
return (
<div className={cx("mmg-avatar-group", `mmg-avatar-group--${size}`, className)} role="group">
{visible}
{remaining > 0 && (
<span
className={cx("mmg-avatar", "mmg-avatar--bordered", "mmg-avatar--neutral", `mmg-avatar--${size}`, "mmg-avatar-group__more")}
aria-label={`et ${remaining} autre${remaining > 1 ? "s" : ""}`}
>
+{remaining}
</span>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type BannerProps = {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
};
export function Banner({ title, description, action, className }: BannerProps) {
return (
<div className={cx("mmg-banner", className)}>
<div>
<h3 className="mmg-banner__title">{title}</h3>
{description && <p className="mmg-banner__desc">{description}</p>}
</div>
{action && <div className="mmg-banner__action">{action}</div>}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type BreadcrumbProps = {
items: { label: ReactNode; href: string }[];
current: ReactNode;
className?: string;
};
export function Breadcrumb({ items, current, className }: BreadcrumbProps) {
return (
<nav aria-label="Fil d'Ariane" className={cx("mmg-breadcrumb", className)}>
{items.map((item, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<a href={item.href}>{item.label}</a>
<span className="mmg-breadcrumb__sep" aria-hidden>/</span>
</span>
))}
<span className="mmg-breadcrumb__current">{current}</span>
</nav>
);
}
+75
View File
@@ -0,0 +1,75 @@
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 { Button } from "./Button";
describe("Button", () => {
it("rend le label", () => {
render(<Button>Continuer</Button>);
expect(screen.getByRole("button", { name: "Continuer" })).toBeInTheDocument();
});
it("appelle onClick quand cliqué", async () => {
const user = userEvent.setup();
let clicks = 0;
render(<Button onClick={() => clicks++}>Cliquer</Button>);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(1);
});
it("est focusable au clavier (Tab)", async () => {
const user = userEvent.setup();
render(<Button>OK</Button>);
await user.tab();
expect(screen.getByRole("button")).toHaveFocus();
});
it("active onClick avec Espace ou Entrée", async () => {
const user = userEvent.setup();
let clicks = 0;
render(<Button onClick={() => clicks++}>Cliquer</Button>);
const btn = screen.getByRole("button");
btn.focus();
await user.keyboard("{Enter}");
await user.keyboard(" ");
expect(clicks).toBe(2);
});
it("respecte disabled", async () => {
const user = userEvent.setup();
let clicks = 0;
render(
<Button disabled onClick={() => clicks++}>
OK
</Button>,
);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(0);
});
it("loading rend le bouton inactif aux clics", async () => {
const user = userEvent.setup();
let clicks = 0;
render(
<Button loading onClick={() => clicks++}>
OK
</Button>,
);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(0);
});
it("n'a pas de violations axe-core (a11y)", async () => {
const { container } = render(
<>
<Button>Primary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
<Button disabled>Disabled</Button>
<Button icon="settings-3-line" aria-label="Paramètres" />
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+139
View File
@@ -0,0 +1,139 @@
import {
forwardRef,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import { cva, type VariantProps } from "./cva";
import { Slot } from "./Slot";
import { Icon, type IconName } from "./Icon";
/**
* mmg-button — primitive bouton DSMMG.
*
* Architecture :
* - Headless via Slot/asChild — peut wrapper un <Link> ou tout autre
* composant tout en conservant le style et le comportement.
* - Variants typés via cva — autocomplete + type-safety.
* - États tous définis : default/hover/active/focus/focus-visible/disabled
* + loading (avec aria-busy).
*
* Accessibilité :
* - Sémantique <button> par défaut (RGAA 7.1)
* - Si `asChild` → l'enfant DOIT être focusable (a, button, [tabindex])
* - aria-busy pendant loading
* - aria-disabled si disabled (en plus de l'attribut natif)
* - Touch target ≥ 44×44 garanti via pseudo-element sur icon-only sm/xs
*/
const buttonStyles = cva("mmg-btn", {
variants: {
variant: {
primary: "mmg-btn--primary",
tonal: "mmg-btn--tonal",
secondary: "mmg-btn--secondary",
tertiary: "mmg-btn--tertiary",
ghost: "mmg-btn--ghost",
elevated: "mmg-btn--elevated",
danger: "mmg-btn--danger",
success: "mmg-btn--success",
},
size: {
xs: "mmg-btn--xs",
sm: "mmg-btn--sm",
md: "mmg-btn--md",
lg: "mmg-btn--lg",
xl: "mmg-btn--xl",
},
shape: {
pill: "mmg-btn--pill",
square: "mmg-btn--square",
},
iconOnly: {
true: "mmg-btn--icon-only",
},
block: {
true: "mmg-btn--block",
},
loading: {
true: "mmg-btn--loading",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
});
type ButtonStyleProps = VariantProps<typeof buttonStyles>;
export type ButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"size"
> &
ButtonStyleProps & {
/** Si vrai, fusionne props avec l'enfant unique (Radix-style polymorphism). */
asChild?: boolean;
/** Icône Remix avant le label */
icon?: IconName;
/** Position de l'icône (par défaut : gauche) */
iconPosition?: "left" | "right";
children?: ReactNode;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
asChild,
variant,
size,
shape,
iconOnly,
block,
loading,
icon,
iconPosition = "left",
className,
disabled,
children,
type,
...rest
},
ref,
) {
const Comp: React.ElementType = asChild ? Slot : "button";
const cls = buttonStyles({
variant,
size,
shape,
iconOnly,
block,
loading,
className,
});
const content = (
<>
{icon && iconPosition === "left" && <Icon name={icon} />}
{children}
{icon && iconPosition === "right" && <Icon name={icon} />}
</>
);
// Si asChild, on délègue le tag mais le wrapping interne reste
const isDisabled = Boolean(disabled || loading);
return (
<Comp
ref={ref}
className={cls}
type={asChild ? undefined : (type ?? "button")}
disabled={asChild ? undefined : isDisabled}
aria-busy={loading ? true : undefined}
aria-disabled={isDisabled ? true : undefined}
{...rest}
>
{content}
</Comp>
);
});
/** Type re-export pour les consommateurs qui veulent partager les variants. */
export type { ButtonStyleProps as ButtonVariants };
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type CalloutProps = {
title?: ReactNode;
children: ReactNode;
action?: ReactNode;
className?: string;
};
export function Callout({ title, children, action, className }: CalloutProps) {
return (
<div className={cx("mmg-callout", className)}>
{title && <h4 className="mmg-callout__title">{title}</h4>}
<div className="mmg-callout__body">{children}</div>
{action && <div>{action}</div>}
</div>
);
}
+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 { useState } from "react";
import { Combobox } from "./Combobox";
const OPTIONS = [
{ value: "a", label: "Alpha" },
{ value: "b", label: "Bravo" },
{ value: "c", label: "Charlie" },
];
function Wrapped() {
const [v, setV] = useState<string | undefined>();
return <Combobox label="Choix" options={OPTIONS} value={v} onChange={setV} />;
}
describe("Combobox", () => {
it("rend l'input role=combobox avec aria-expanded", () => {
render(<Wrapped />);
const input = screen.getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "false");
});
it("ouvre la listbox au focus", async () => {
const user = userEvent.setup();
render(<Wrapped />);
const input = screen.getByRole("combobox");
await user.click(input);
expect(input).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
it("filtre les options selon la requête", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("br");
const opts = screen.getAllByRole("option");
expect(opts).toHaveLength(1);
expect(opts[0]).toHaveTextContent("Bravo");
});
it("navigue avec ArrowDown / ArrowUp et sélectionne avec Enter", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
// 2 down → index 1 = Bravo
expect(screen.getByRole("combobox")).toHaveValue("Bravo");
});
it("ferme avec Escape", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("{Escape}");
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
it("affiche un message vide si aucun match", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("xyz");
expect(screen.getByText(/aucun résultat/i)).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(<Wrapped />);
expect(await axe(container)).toHaveNoViolations();
});
});
+189
View File
@@ -0,0 +1,189 @@
import {
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from "@floating-ui/react";
import { cx } from "./utils";
export type ComboboxOption = { value: string; label: string; disabled?: boolean };
export type ComboboxProps = {
options: ComboboxOption[];
value?: string;
onChange: (v: string) => void;
label?: ReactNode;
placeholder?: string;
/** Texte affiché quand aucun match. */
emptyMessage?: ReactNode;
className?: string;
};
/**
* Combobox accessible — pattern WAI-ARIA 1.2 "combobox listbox".
*
* - role="combobox" sur l'input, role="listbox" sur la liste, role="option" par item
* - aria-activedescendant pour l'item courant (pas de focus déplacé hors input)
* - aria-expanded synchro avec l'état ouvert
* - flèches ↑↓, Home/End, Enter pour sélectionner, Esc pour fermer
* - Floating UI : flip + shift + size (clamp à la viewport, autoUpdate au scroll)
* - filter case-insensitive (extensible : passer un filterFn custom plus tard)
*
* Pour des cas avancés (multi-select, async, virtualisation), voir
* @TODO: ComboboxAsync / ComboboxMulti dans un futur major.
*/
export function Combobox({
options,
value,
onChange,
label,
placeholder = "Tapez pour rechercher…",
emptyMessage = "Aucun résultat",
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState(
value ? options.find((o) => o.value === value)?.label ?? "" : "",
);
const [activeIndex, setActiveIndex] = useState(-1);
const id = useId();
const listRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q === ""
? options
: options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query]);
const { refs, floatingStyles } = useFloating({
open,
onOpenChange: setOpen,
placement: "bottom-start",
middleware: [
offset(4),
flip({ padding: 8 }),
shift({ padding: 8 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
maxHeight: `${Math.min(320, availableHeight - 8)}px`,
});
},
}),
],
whileElementsMounted: autoUpdate,
});
const select = (opt: ComboboxOption) => {
if (opt.disabled) return;
onChange(opt.value);
setQuery(opt.label);
setOpen(false);
setActiveIndex(-1);
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActiveIndex((i) => Math.min(filtered.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
} else if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setActiveIndex(filtered.length - 1);
} else if (e.key === "Enter" && activeIndex >= 0 && filtered[activeIndex]) {
e.preventDefault();
select(filtered[activeIndex]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div className={cx("mmg-field", className)}>
{label && (
<label className="mmg-field__label" htmlFor={id}>
{label}
</label>
)}
<input
ref={refs.setReference}
id={id}
className="mmg-input"
role="combobox"
aria-expanded={open}
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 && filtered[activeIndex]
? `${id}-opt-${filtered[activeIndex].value}`
: undefined
}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 100)}
onKeyDown={onKey}
/>
{open && (
<div
ref={(el) => {
refs.setFloating(el);
listRef.current = el;
}}
id={`${id}-list`}
role="listbox"
className="mmg-menu mmg-combobox__list"
style={floatingStyles}
>
{filtered.length === 0 ? (
<div className="mmg-menu__empty">{emptyMessage}</div>
) : (
filtered.map((o, i) => (
<button
key={o.value}
id={`${id}-opt-${o.value}`}
type="button"
role="option"
aria-selected={o.value === value}
aria-disabled={o.disabled}
tabIndex={-1}
className={cx(
"mmg-menu__item",
i === activeIndex && "mmg-menu__item--active",
)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => select(o)}
>
{o.label}
</button>
))
)}
</div>
)}
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type Command = {
id: string;
label: ReactNode;
/** Texte indexable pour la recherche (par défaut = label si string). */
keywords?: string;
icon?: IconName;
group?: string;
shortcut?: string[]; // ex. ["⌘", "K"]
onSelect: () => void;
};
export function CommandMenu({
open,
onClose,
commands,
placeholder = "Tapez une commande ou un mot-clé…",
emptyMessage = "Aucun résultat. Essayez un autre mot.",
}: {
open: boolean;
onClose: () => void;
commands: Command[];
placeholder?: string;
emptyMessage?: string;
}) {
const [query, setQuery] = useState("");
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Reset à l'ouverture
useEffect(() => {
if (open) {
setQuery("");
setActiveIdx(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// ESC pour fermer
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Block scroll
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return commands;
return commands.filter((c) => {
const txt = (
(typeof c.label === "string" ? c.label : "") +
" " +
(c.keywords ?? "") +
" " +
(c.group ?? "")
).toLowerCase();
// Matching simple : tous les mots de q doivent être présents
return q.split(/\s+/).every((w) => txt.includes(w));
});
}, [commands, query]);
// Groupes
const groups = useMemo(() => {
const map = new Map<string, Command[]>();
for (const c of filtered) {
const g = c.group ?? "Commandes";
if (!map.has(g)) map.set(g, []);
map.get(g)!.push(c);
}
return [...map.entries()];
}, [filtered]);
// Navigation clavier
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(filtered.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(0, i - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = filtered[activeIdx];
if (cmd) {
cmd.onSelect();
onClose();
}
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, filtered, activeIdx, onClose]);
// Scroll into view
useEffect(() => {
const el = listRef.current?.querySelector('[aria-selected="true"]');
el?.scrollIntoView({ block: "nearest" });
}, [activeIdx]);
if (!open) return null;
let runningIdx = 0;
return (
<div
className="mmg-cmdk-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Palette de commandes"
className="mmg-cmdk"
>
<div className="mmg-cmdk__input-wrap">
<Icon name="search-2-line" size="md" />
<input
ref={inputRef}
className="mmg-cmdk__input"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIdx(0);
}}
placeholder={placeholder}
aria-label="Recherche"
/>
<kbd className="mmg-cmdk__kbd">esc</kbd>
</div>
<div ref={listRef} className="mmg-cmdk__list" role="listbox">
{filtered.length === 0 ? (
<div className="mmg-cmdk__empty">{emptyMessage}</div>
) : (
groups.map(([group, items]) => (
<div key={group}>
<div className="mmg-cmdk__group-label">{group}</div>
{items.map((c) => {
const isActive = filtered[activeIdx]?.id === c.id;
const idx = runningIdx++;
return (
<div
key={c.id}
role="option"
aria-selected={isActive}
data-idx={idx}
className="mmg-cmdk__item"
onClick={() => {
c.onSelect();
onClose();
}}
onMouseEnter={() => setActiveIdx(filtered.indexOf(c))}
>
{c.icon && <Icon name={c.icon} size="md" />}
<span style={{ flex: 1 }}>{c.label}</span>
{c.shortcut && (
<span className="mmg-cmdk__item-shortcut">
{c.shortcut.map((k, i) => (
<kbd key={i} className="mmg-cmdk__kbd">{k}</kbd>
))}
</span>
)}
</div>
);
})}
</div>
))
)}
</div>
<div className="mmg-cmdk__footer">
<span className="mmg-cmdk__footer-key">
<kbd className="mmg-cmdk__kbd"></kbd>
<kbd className="mmg-cmdk__kbd"></kbd> naviguer
</span>
<span className="mmg-cmdk__footer-key">
<kbd className="mmg-cmdk__kbd"></kbd> sélectionner
</span>
<span className="mmg-cmdk__footer-key">
<kbd className="mmg-cmdk__kbd">esc</kbd> fermer
</span>
</div>
</div>
</div>
);
}
/** Hook pour ouvrir la palette via Cmd/Ctrl+K. */
export function useCommandMenu() {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((v) => !v);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
return { open, setOpen, close: () => setOpen(false), toggle: () => setOpen((v) => !v) };
}
+65
View File
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { Dialog } from "./Dialog";
import { Button } from "./Button";
export type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
confirmLabel?: ReactNode;
cancelLabel?: ReactNode;
/** Variante destructive : bouton rouge. */
destructive?: boolean;
onConfirm: () => void | Promise<void>;
/** Affiche un loader pendant onConfirm. */
loading?: boolean;
};
/**
* Boîte de dialogue de confirmation. Pattern destructive : confirm rouge,
* focus initial sur le bouton d'annulation (sécurité). Cf. heuristique
* Nielsen #5 : prevention of errors.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirmer",
cancelLabel = "Annuler",
destructive,
onConfirm,
loading,
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
size="sm"
hideClose
footer={
<div style={{ display: "flex", gap: "var(--mmg-space-2)", justifyContent: "flex-end" }}>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={async () => {
await onConfirm();
onOpenChange(false);
}}
loading={loading}
// Initial focus reste sur Cancel via Radix par défaut.
>
{confirmLabel}
</Button>
</div>
}
>
{null}
</Dialog>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { type ReactNode } from "react";
import * as RadixContextMenu from "@radix-ui/react-context-menu";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type ContextMenuItem =
| {
type?: "item";
label: ReactNode;
icon?: IconName;
onSelect?: () => void;
danger?: boolean;
disabled?: boolean;
shortcut?: string;
}
| { type: "divider" }
| { type: "label"; label: ReactNode };
export type ContextMenuProps = {
/** Zone qui déclenche le menu au clic-droit / long-press. */
children: ReactNode;
items: ContextMenuItem[];
};
/**
* ContextMenu — menu contextuel au clic-droit (ou long-press tactile).
*
* Wrapper Radix : ouvre sur clic-droit, navigation flèches, type-ahead,
* Escape, focus management. Aucun appel a11y custom.
*/
export function ContextMenu({ children, items }: ContextMenuProps) {
return (
<RadixContextMenu.Root>
<RadixContextMenu.Trigger asChild>{children}</RadixContextMenu.Trigger>
<RadixContextMenu.Portal>
<RadixContextMenu.Content className="mmg-menu" collisionPadding={8}>
{items.map((item, i) => {
if (item.type === "divider") {
return <RadixContextMenu.Separator key={i} className="mmg-menu__divider" />;
}
if (item.type === "label") {
return (
<RadixContextMenu.Label key={i} className="mmg-menu__label">
{item.label}
</RadixContextMenu.Label>
);
}
const { label, icon, onSelect, danger, disabled, shortcut } = item;
return (
<RadixContextMenu.Item
key={i}
className={cx("mmg-menu__item", danger && "mmg-menu__item--danger")}
disabled={disabled}
onSelect={onSelect}
>
{icon && <Icon name={icon} />}
<span style={{ flex: 1 }}>{label}</span>
{shortcut && <span className="mmg-menu__shortcut">{shortcut}</span>}
</RadixContextMenu.Item>
);
})}
</RadixContextMenu.Content>
</RadixContextMenu.Portal>
</RadixContextMenu.Root>
);
}
export { RadixContextMenu as ContextMenuPrimitive };
+315
View File
@@ -0,0 +1,315 @@
import { useMemo, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
import { Pictogram, type PictogramName } from "./Pictogram";
import { Button } from "./Button";
export type DataTableColumn<T> = {
key: string;
header: ReactNode;
/** Accesseur ; par défaut row[key] */
accessor?: (row: T) => unknown;
/** Render personnalisé ; par défaut texte */
cell?: (row: T) => ReactNode;
/** Trie cliquable sur la colonne */
sortable?: boolean;
/** Largeur CSS (ex. "120px", "1fr") */
width?: string | number;
/** Alignement du contenu */
align?: "left" | "right" | "center";
};
type SortState = { key: string; dir: "asc" | "desc" } | null;
export type DataTableProps<T> = {
data: T[];
columns: DataTableColumn<T>[];
/** Colonne identifiante (par défaut "id") */
rowKey?: keyof T | ((row: T) => string | number);
/** Active la sélection multiple */
selectable?: boolean;
selectedKeys?: (string | number)[];
onSelectionChange?: (keys: (string | number)[]) => void;
/** Recherche dans toutes les colonnes */
searchable?: boolean;
searchPlaceholder?: string;
/** Pagination cliente */
pageSize?: number;
/** Densité des lignes */
dense?: boolean;
/** État vide personnalisable */
emptyTitle?: ReactNode;
emptyDescription?: ReactNode;
emptyPictogram?: PictogramName;
emptyAction?: ReactNode;
/** Loading */
loading?: boolean;
/** Actions de la barre d'outils */
toolbarActions?: ReactNode;
className?: string;
onRowClick?: (row: T) => void;
};
export function DataTable<T extends Record<string, any>>({
data,
columns,
rowKey = "id",
selectable,
selectedKeys,
onSelectionChange,
searchable,
searchPlaceholder = "Rechercher…",
pageSize,
dense,
emptyTitle = "Aucun résultat",
emptyDescription = "Aucun élément ne correspond à votre recherche.",
emptyPictogram = "search",
emptyAction,
loading,
toolbarActions,
className,
onRowClick,
}: DataTableProps<T>) {
const [sort, setSort] = useState<SortState>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const getKey = (row: T): string | number =>
typeof rowKey === "function" ? rowKey(row) : (row[rowKey as string] ?? "");
const accessor = (col: DataTableColumn<T>, row: T) =>
col.accessor ? col.accessor(row) : row[col.key];
const filtered = useMemo(() => {
if (!searchable || !search.trim()) return data;
const q = search.toLowerCase();
return data.filter((row) =>
columns.some((c) => {
const v = accessor(c, row);
return v != null && String(v).toLowerCase().includes(q);
}),
);
}, [data, search, searchable, columns]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const col = columns.find((c) => c.key === sort.key);
if (!col) return filtered;
const sign = sort.dir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
const va = accessor(col, a);
const vb = accessor(col, b);
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === "number" && typeof vb === "number") return sign * (va - vb);
return sign * String(va).localeCompare(String(vb));
});
}, [filtered, sort, columns]);
const pageCount = pageSize ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
const paged = pageSize
? sorted.slice((page - 1) * pageSize, page * pageSize)
: sorted;
const allKeys = paged.map(getKey);
const allSelected = allKeys.length > 0 && allKeys.every((k) => selectedKeys?.includes(k));
const someSelected = !allSelected && allKeys.some((k) => selectedKeys?.includes(k));
const toggleAll = () => {
if (!onSelectionChange) return;
if (allSelected) {
onSelectionChange(selectedKeys?.filter((k) => !allKeys.includes(k)) ?? []);
} else {
onSelectionChange([...new Set([...(selectedKeys ?? []), ...allKeys])]);
}
};
const toggleOne = (k: string | number) => {
if (!onSelectionChange) return;
const set = new Set(selectedKeys ?? []);
set.has(k) ? set.delete(k) : set.add(k);
onSelectionChange([...set]);
};
const onSort = (col: DataTableColumn<T>) => {
if (!col.sortable) return;
setSort((cur) =>
cur?.key === col.key
? cur.dir === "asc"
? { key: col.key, dir: "desc" }
: null
: { key: col.key, dir: "asc" },
);
};
const showToolbar = searchable || toolbarActions || (selectable && (selectedKeys?.length ?? 0) > 0);
return (
<div className={cx("mmg-datatable", dense && "mmg-datatable--dense", className)}>
{showToolbar && (
<div className="mmg-datatable__toolbar">
{selectable && (selectedKeys?.length ?? 0) > 0 ? (
<strong style={{ color: "var(--mmg-color-accent)" }}>
{selectedKeys?.length} sélectionné{(selectedKeys?.length ?? 0) > 1 ? "s" : ""}
</strong>
) : searchable ? (
<div className="mmg-input-wrap mmg-datatable__toolbar-search">
<span className="mmg-input-wrap__icon"><Icon name="search-2-line" /></span>
<input
className="mmg-input"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
) : (
<span />
)}
{toolbarActions && <div className="mmg-datatable__toolbar-actions">{toolbarActions}</div>}
</div>
)}
<div className="mmg-datatable__scroll">
<table>
<thead>
<tr>
{selectable && (
<th className="mmg-datatable__cell--checkbox">
<input
type="checkbox"
className="mmg-check__input"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
aria-label="Tout sélectionner"
/>
</th>
)}
{columns.map((col) => {
const isSorted = sort?.key === col.key;
return (
<th
key={col.key}
data-sortable={col.sortable || undefined}
aria-sort={
isSorted
? sort.dir === "asc"
? "ascending"
: "descending"
: undefined
}
onClick={() => onSort(col)}
style={{
width: col.width,
textAlign: col.align,
}}
>
{col.header}
{col.sortable && (
<span className="mmg-datatable__sort-indicator">
<Icon
name={
isSorted
? sort.dir === "asc"
? "arrow-up-line"
: "arrow-down-line"
: "expand-up-down-line" as IconName
}
size="sm"
/>
</span>
)}
</th>
);
})}
</tr>
</thead>
{!loading && paged.length > 0 && (
<tbody>
{paged.map((row) => {
const k = getKey(row);
const selected = selectedKeys?.includes(k);
return (
<tr
key={String(k)}
data-selected={selected || undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={onRowClick ? { cursor: "pointer" } : undefined}
>
{selectable && (
<td className="mmg-datatable__cell--checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className="mmg-check__input"
checked={selected || false}
onChange={() => toggleOne(k)}
aria-label={`Sélectionner ${k}`}
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} style={{ textAlign: col.align }}>
{col.cell ? col.cell(row) : String(accessor(col, row) ?? "—")}
</td>
))}
</tr>
);
})}
</tbody>
)}
</table>
{loading && (
<div className="mmg-datatable__loading">
<span className="mmg-spinner mmg-spinner--lg" />
<span>Chargement</span>
</div>
)}
{!loading && paged.length === 0 && (
<div className="mmg-datatable__empty">
<Pictogram name={emptyPictogram} size={64} />
<h4>{emptyTitle}</h4>
<p>{emptyDescription}</p>
{emptyAction}
</div>
)}
</div>
{pageSize && pageCount > 1 && (
<div className="mmg-datatable__footer">
<span>
Page {page} / {pageCount} {sorted.length} résultat
{sorted.length > 1 ? "s" : ""}
</span>
<div style={{ display: "flex", gap: 4 }}>
<Button
size="sm"
variant="ghost"
icon="arrow-left-line"
iconOnly
aria-label="Page précédente"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
/>
<Button
size="sm"
variant="ghost"
icon="arrow-right-line"
iconOnly
aria-label="Page suivante"
disabled={page === pageCount}
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
/>
</div>
</div>
)}
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
import { Button } from "./Button";
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
const WEEKDAYS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"];
const fmt = (d: Date | null) =>
d
? d.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: "";
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
function buildMonthGrid(year: number, month: number) {
const first = new Date(year, month, 1);
// En France lundi = jour 0
const startDay = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrev = new Date(year, month, 0).getDate();
const cells: { date: Date; outside: boolean }[] = [];
// Préfixe (mois précédent)
for (let i = startDay - 1; i >= 0; i--) {
cells.push({ date: new Date(year, month - 1, daysInPrev - i), outside: true });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(year, month, d), outside: false });
}
// Suffixe (mois suivant)
while (cells.length < 42) {
const idx = cells.length - (startDay + daysInMonth);
cells.push({ date: new Date(year, month + 1, idx + 1), outside: true });
}
return cells;
}
export type DatePickerProps = {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
value?: Date | null;
onChange?: (date: Date | null) => void;
required?: boolean;
disabled?: boolean;
/** Désactive certaines dates */
isDisabled?: (date: Date) => boolean;
placeholder?: string;
className?: string;
};
export function DatePicker({
label,
hint,
error,
value,
onChange,
required,
disabled,
isDisabled,
placeholder = "JJ/MM/AAAA",
className,
}: DatePickerProps) {
const id = useId();
const [open, setOpen] = useState(false);
const [view, setView] = useState(() => value ?? new Date());
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (value) setView(value);
}, [value]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const cells = useMemo(
() => buildMonthGrid(view.getFullYear(), view.getMonth()),
[view],
);
const today = new Date();
return (
<div
ref={ref}
className={cx("mmg-field", error && "mmg-field--error", className)}
style={{ position: "relative" }}
>
{label && (
<label
className={cx("mmg-field__label", required && "mmg-field__label--required")}
htmlFor={id}
>
{label}
</label>
)}
<div className="mmg-input-wrap mmg-input-wrap--with-suffix">
<input
id={id}
className="mmg-input"
readOnly
required={required}
disabled={disabled}
value={fmt(value ?? null)}
placeholder={placeholder}
onClick={() => !disabled && setOpen((v) => !v)}
onFocus={() => !disabled && setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
/>
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
<Icon name="calendar-line" />
</span>
</div>
{error ? (
<span className="mmg-field__error">{error}</span>
) : hint ? (
<span className="mmg-field__hint">{hint}</span>
) : null}
{open && (
<div
className="mmg-datepicker"
style={{ top: "calc(100% + 4px)", left: 0 }}
role="dialog"
aria-label="Sélectionner une date"
>
<div className="mmg-datepicker__header">
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois précédent"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1))}
>
<Icon name="arrow-left-line" />
</button>
<span className="mmg-datepicker__title">
{MONTHS[view.getMonth()]} {view.getFullYear()}
</span>
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois suivant"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1))}
>
<Icon name="arrow-right-line" />
</button>
</div>
<div className="mmg-datepicker__weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="mmg-datepicker__weekday">{w}</div>
))}
</div>
<div className="mmg-datepicker__grid">
{cells.map(({ date, outside }, i) => {
const selected = value && sameDay(date, value);
const todayMatch = sameDay(date, today);
const dis = isDisabled?.(date) ?? false;
return (
<button
key={i}
type="button"
className={cx(
"mmg-datepicker__day",
outside && "mmg-datepicker__day--outside",
todayMatch && "mmg-datepicker__day--today",
)}
aria-selected={selected || undefined}
aria-current={todayMatch ? "date" : undefined}
disabled={dis}
onClick={() => {
onChange?.(date);
setOpen(false);
}}
>
{date.getDate()}
</button>
);
})}
</div>
<div className="mmg-datepicker__footer">
<Button
size="xs"
variant="ghost"
onClick={() => {
onChange?.(null);
setOpen(false);
}}
>
Effacer
</Button>
<Button
size="xs"
variant="tonal"
onClick={() => {
onChange?.(today);
setView(today);
setOpen(false);
}}
>
Aujourd'hui
</Button>
</div>
</div>
)}
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
import { Button } from "./Button";
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
const WEEKDAYS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"];
const fmt = (d: Date | null) =>
d ? d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }) : "";
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const isBefore = (a: Date, b: Date) => a.getTime() < b.getTime();
const isBetween = (d: Date, start: Date, end: Date) =>
d.getTime() > start.getTime() && d.getTime() < end.getTime();
function buildMonthGrid(year: number, month: number) {
const first = new Date(year, month, 1);
const startDay = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrev = new Date(year, month, 0).getDate();
const cells: { date: Date; outside: boolean }[] = [];
for (let i = startDay - 1; i >= 0; i--) {
cells.push({ date: new Date(year, month - 1, daysInPrev - i), outside: true });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(year, month, d), outside: false });
}
while (cells.length < 42) {
const idx = cells.length - (startDay + daysInMonth);
cells.push({ date: new Date(year, month + 1, idx + 1), outside: true });
}
return cells;
}
export type DateRange = { start: Date | null; end: Date | null };
export type DateRangePickerProps = {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
value?: DateRange;
onChange?: (range: DateRange) => void;
required?: boolean;
disabled?: boolean;
/** Désactive certaines dates (ex. dates passées). */
isDisabled?: (date: Date) => boolean;
className?: string;
};
export function DateRangePicker({
label,
hint,
error,
value = { start: null, end: null },
onChange,
required,
disabled,
isDisabled,
className,
}: DateRangePickerProps) {
const id = useId();
const [open, setOpen] = useState(false);
const [view, setView] = useState(() => value.start ?? new Date());
const [hover, setHover] = useState<Date | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const cells = useMemo(
() => buildMonthGrid(view.getFullYear(), view.getMonth()),
[view],
);
const today = new Date();
const handleClick = (date: Date) => {
if (!value.start || (value.start && value.end)) {
onChange?.({ start: date, end: null });
} else if (isBefore(date, value.start)) {
onChange?.({ start: date, end: value.start });
setOpen(false);
} else {
onChange?.({ start: value.start, end: date });
setOpen(false);
}
};
const inRange = (d: Date) => {
if (!value.start) return false;
const end = value.end ?? hover;
if (!end) return false;
if (isBefore(end, value.start)) return isBetween(d, end, value.start);
return isBetween(d, value.start, end);
};
const display = value.start
? value.end
? `${fmt(value.start)}${fmt(value.end)}`
: `${fmt(value.start)} → …`
: "";
return (
<div
ref={ref}
className={cx("mmg-field", error && "mmg-field--error", className)}
style={{ position: "relative" }}
>
{label && (
<label
className={cx("mmg-field__label", required && "mmg-field__label--required")}
htmlFor={id}
>
{label}
</label>
)}
<div className="mmg-input-wrap mmg-input-wrap--with-suffix">
<input
id={id}
className="mmg-input"
readOnly
required={required}
disabled={disabled}
value={display}
placeholder="Sélectionnez une plage…"
onClick={() => !disabled && setOpen((v) => !v)}
onFocus={() => !disabled && setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
/>
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
<Icon name="calendar-line" />
</span>
</div>
{error ? (
<span className="mmg-field__error">{error}</span>
) : hint ? (
<span className="mmg-field__hint">{hint}</span>
) : null}
{open && (
<div
className="mmg-datepicker mmg-datepicker--range"
style={{ top: "calc(100% + 4px)", left: 0, width: 320 }}
role="dialog"
aria-label="Sélectionner une plage de dates"
>
<div className="mmg-datepicker__header">
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois précédent"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1))}
>
<Icon name="arrow-left-line" />
</button>
<span className="mmg-datepicker__title">
{MONTHS[view.getMonth()]} {view.getFullYear()}
</span>
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois suivant"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1))}
>
<Icon name="arrow-right-line" />
</button>
</div>
<div className="mmg-datepicker__weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="mmg-datepicker__weekday">{w}</div>
))}
</div>
<div className="mmg-datepicker__grid">
{cells.map(({ date, outside }, i) => {
const isStart = value.start && sameDay(date, value.start);
const isEnd = value.end && sameDay(date, value.end);
const between = inRange(date);
const todayMatch = sameDay(date, today);
const dis = isDisabled?.(date) ?? false;
return (
<button
key={i}
type="button"
className={cx(
"mmg-datepicker__day",
outside && "mmg-datepicker__day--outside",
todayMatch && "mmg-datepicker__day--today",
between && "mmg-datepicker__day--between",
(isStart || isEnd) && "mmg-datepicker__day--endpoint",
)}
aria-selected={isStart || isEnd || undefined}
disabled={dis}
onClick={() => handleClick(date)}
onMouseEnter={() => setHover(date)}
onMouseLeave={() => setHover(null)}
>
{date.getDate()}
</button>
);
})}
</div>
<div className="mmg-datepicker__footer">
<Button
size="xs"
variant="ghost"
onClick={() => onChange?.({ start: null, end: null })}
>
Effacer
</Button>
<Button
size="xs"
variant="tonal"
onClick={() => {
const last7 = new Date();
last7.setDate(today.getDate() - 6);
onChange?.({ start: last7, end: today });
setView(today);
setOpen(false);
}}
>
7 derniers jours
</Button>
</div>
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
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 { Dialog } from "./Dialog";
function Wrapped() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Ouvrir</button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Modifier l'utilisateur"
description="Met à jour les infos."
>
<p>Contenu</p>
</Dialog>
</>
);
}
describe("Dialog", () => {
it("est fermé par défaut", () => {
render(<Wrapped />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("s'ouvre via setState", 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("Modifier l'utilisateur");
});
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}");
// Radix retire le dialog après animation — on attend la disparition
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();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { type ReactNode } from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type DialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: ReactNode;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
/** Largeur max du contenu. */
size?: "sm" | "md" | "lg" | "xl" | "full";
/** Cache le bouton de fermeture en haut à droite. */
hideClose?: boolean;
className?: string;
};
/**
* Dialog (modal) — wrapper Radix Dialog.
*
* Focus trap, Escape, scroll lock, restitution du focus à la fermeture,
* tout est géré par Radix. La sémantique est correcte (role="dialog",
* aria-modal, aria-labelledby/-describedby auto via Title/Description).
*
* Pour confirm/destructive, voir ConfirmDialog.
*/
export function Dialog({
open,
onOpenChange,
title,
description,
children,
footer,
size = "md",
hideClose,
className,
}: DialogProps) {
return (
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
<RadixDialog.Portal>
<RadixDialog.Overlay className="mmg-dialog__overlay" />
<RadixDialog.Content
className={cx("mmg-dialog", `mmg-dialog--${size}`, className)}
>
{(title || !hideClose) && (
<div className="mmg-dialog__header">
{title && <RadixDialog.Title className="mmg-dialog__title">{title}</RadixDialog.Title>}
{!hideClose && (
<RadixDialog.Close asChild>
<button type="button" className="mmg-dialog__close" aria-label="Fermer">
<Icon name="close-line" size="md" />
</button>
</RadixDialog.Close>
)}
</div>
)}
{description && (
<RadixDialog.Description className="mmg-dialog__description">
{description}
</RadixDialog.Description>
)}
<div className="mmg-dialog__body">{children}</div>
{footer && <div className="mmg-dialog__footer">{footer}</div>}
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
export { RadixDialog as DialogPrimitive };
+71
View File
@@ -0,0 +1,71 @@
import { type ReactNode } from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type DrawerProps = {
open: boolean;
/** Callback de fermeture — déclenché par Esc, click backdrop, click close. */
onClose: () => void;
/** Côté d'apparition. Défaut "right". */
side?: "left" | "right";
/** Largeur. Défaut "md" (480px). */
size?: "sm" | "md" | "lg" | "xl";
title?: ReactNode;
children: ReactNode;
footer?: ReactNode;
className?: string;
};
/**
* Drawer — panneau latéral (Radix Dialog).
*
* API simplifiée par rapport à Sheet : uniquement left/right, callback
* `onClose` (au lieu de `onOpenChange`). Pour bottom/top sheet ou plus
* de contrôle, utiliser directement Sheet.
*
* Backing Radix : focus trap, scroll lock, restitution focus, Esc — tout
* natif. Anciennement custom (useFocusTrap), maintenant aligné avec Sheet.
*/
export function Drawer({
open,
onClose,
side = "right",
size = "md",
title,
children,
footer,
className,
}: DrawerProps) {
return (
<RadixDialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
<RadixDialog.Portal>
<RadixDialog.Overlay className="mmg-sheet__overlay" />
<RadixDialog.Content
className={cx(
"mmg-sheet",
`mmg-sheet--${side}`,
`mmg-sheet--${size}`,
"mmg-drawer",
className,
)}
>
{title && (
<header className="mmg-sheet__header">
<div className="mmg-sheet__header-text">
<RadixDialog.Title className="mmg-sheet__title">{title}</RadixDialog.Title>
</div>
<RadixDialog.Close asChild>
<button type="button" className="mmg-sheet__close" aria-label="Fermer">
<Icon name="close-line" size="md" />
</button>
</RadixDialog.Close>
</header>
)}
<div className="mmg-sheet__body">{children}</div>
{footer && <footer className="mmg-sheet__footer">{footer}</footer>}
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cx } from "./utils";
import { IconBlock, type IconBlockProps } from "./IconBlock";
import type { IconName } from "./Icon";
export type EmptyStateProps = HTMLAttributes<HTMLDivElement> & {
/** Icône Remix à afficher dans un IconBlock. */
icon?: IconName;
/** Couleur de l'IconBlock. */
iconColor?: IconBlockProps["color"];
title: ReactNode;
description?: ReactNode;
/** Action principale (bouton, lien, etc.) */
action?: ReactNode;
/** Actions secondaires (lien doc, contact support…) */
secondaryAction?: ReactNode;
/** Variant compacte pour intégration dans un Card */
compact?: boolean;
};
/**
* EmptyState — état vide standardisé.
*
* Cas d'usage : table vide, recherche sans résultat, espace
* non encore configuré, fonctionnalité en cours d'arrivée.
*
* UX : toujours un *next step* clair (action) — jamais juste "rien".
*/
export function EmptyState({
icon = "search-2-line",
iconColor = "neutral",
title,
description,
action,
secondaryAction,
compact,
className,
...rest
}: EmptyStateProps) {
return (
<div
className={cx("mmg-empty", compact && "mmg-empty--compact", className)}
role="status"
{...rest}
>
<IconBlock
icon={icon}
color={iconColor}
size={compact ? "md" : "xl"}
/>
<h3 className="mmg-empty__title">{title}</h3>
{description && <p className="mmg-empty__desc">{description}</p>}
{(action || secondaryAction) && (
<div className="mmg-empty__actions">
{action}
{secondaryAction}
</div>
)}
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useId, type HTMLAttributes, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
/* ────────────────────────────────────────────────────────
SegmentedControl (Apple HIG)
- Choix exclusif court (2-5 options)
- Mieux qu'un Radio quand l'espace horizontal est dispo
- Évite l'overhead cognitif d'un Select pour peu d'options
──────────────────────────────────────────────────────── */
export type SegmentedItem<V extends string = string> = {
value: V;
label: ReactNode;
icon?: IconName;
disabled?: boolean;
};
export function SegmentedControl<V extends string = string>({
items,
value,
onChange,
size = "md",
fullWidth,
ariaLabel,
className,
}: {
items: SegmentedItem<V>[];
value: V;
onChange: (v: V) => void;
size?: "sm" | "md" | "lg";
fullWidth?: boolean;
ariaLabel?: string;
className?: string;
}) {
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cx(
"mmg-segmented",
size !== "md" && `mmg-segmented--${size}`,
fullWidth && "mmg-segmented--full",
className,
)}
>
{items.map((item) => {
const checked = item.value === value;
return (
<button
key={item.value}
type="button"
role="radio"
aria-checked={checked}
disabled={item.disabled}
className={cx(
"mmg-segmented__item",
checked && "mmg-segmented__item--active",
)}
onClick={() => onChange(item.value)}
>
{item.icon && <Icon name={item.icon} size="sm" />}
<span>{item.label}</span>
</button>
);
})}
</div>
);
}
/* ────────────────────────────────────────────────────────
DescriptionList — pattern clé/valeur structuré (DSFR-like)
Sémantique HTML correcte (<dl><dt><dd>) — meilleur que table
pour des paires non-tabulaires. Lecteurs d'écran nativement
supportés.
──────────────────────────────────────────────────────── */
export type DListItem = { label: ReactNode; value: ReactNode };
export function DescriptionList({
items,
layout = "horizontal",
className,
}: {
items: DListItem[];
/** "horizontal" : label à gauche · "vertical" : label au-dessus */
layout?: "horizontal" | "vertical";
className?: string;
}) {
return (
<dl className={cx("mmg-dlist", `mmg-dlist--${layout}`, className)}>
{items.map((item, i) => (
<div key={i} className="mmg-dlist__row">
<dt className="mmg-dlist__label">{item.label}</dt>
<dd className="mmg-dlist__value">{item.value}</dd>
</div>
))}
</dl>
);
}
/* ────────────────────────────────────────────────────────
Sparkline — micro graphe pour stat cards / dashboards
Inline SVG, sans dépendance, accessible (table de données
en <title> + role img).
──────────────────────────────────────────────────────── */
export function Sparkline({
data,
width = 100,
height = 32,
color = "var(--mmg-color-accent)",
fill = "var(--mmg-color-accent-soft)",
ariaLabel,
className,
}: {
data: number[];
width?: number;
height?: number;
color?: string;
fill?: string;
ariaLabel?: string;
className?: string;
}) {
if (data.length < 2) return null;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const stepX = width / (data.length - 1);
const points = data.map((v, i) => {
const x = i * stepX;
const y = height - ((v - min) / range) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const linePath = `M ${points.join(" L ")}`;
const areaPath = `${linePath} L ${width},${height} L 0,${height} Z`;
return (
<svg
role="img"
aria-label={ariaLabel ?? `Évolution sur ${data.length} points : min ${min}, max ${max}`}
className={cx("mmg-sparkline", className)}
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
preserveAspectRatio="none"
>
<path d={areaPath} fill={fill} />
<path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ────────────────────────────────────────────────────────
Kbd — wrapping <kbd> stylisé
Pour shortcuts inline ("Appuyez sur ⌘K").
──────────────────────────────────────────────────────── */
export function Kbd({
children,
className,
...rest
}: HTMLAttributes<HTMLElement>) {
return (
<kbd className={cx("mmg-kbd", className)} {...rest}>
{children}
</kbd>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { ButtonHTMLAttributes } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type FabProps = ButtonHTMLAttributes<HTMLButtonElement> & {
icon: IconName;
label: string;
};
export function Fab({ icon, label, className, ...rest }: FabProps) {
return (
<button
type="button"
className={cx("mmg-fab", className)}
aria-label={label}
title={label}
{...rest}
>
<Icon name={icon} size="lg" />
</button>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type FeatureCardProps = {
/** Icône principale (Remix). */
icon?: IconName;
/** Couleur de l'icône (et du glow associé en hover). */
iconColor?: "brand" | "blue" | "green" | "amber" | "violet" | "neutral";
title: ReactNode;
description?: ReactNode;
/** Slot pour un lien "En savoir plus". Rendu en bas de la carte. */
link?: { label: ReactNode; href: string };
/** Si true, ajoute un effet gradient borde au hover (Vercel-style). */
glowOnHover?: boolean;
className?: string;
};
/**
* FeatureCard — carte de mise en avant (icône + titre + description + lien).
*
* Pattern landing pages Vercel / Linear / Stripe : grille 3 colonnes avec
* un IconBlock coloré, un titre fort, une description et un lien optionnel.
*
* Variante `glowOnHover` : ajoute un effet "border qui s'illumine" au
* hover via un dégradé conique (Vercel signature). Performant (un seul
* background-image animé via mask).
*/
export function FeatureCard({
icon,
iconColor = "brand",
title,
description,
link,
glowOnHover,
className,
}: FeatureCardProps) {
return (
<div
className={cx(
"mmg-feature-card",
glowOnHover && "mmg-feature-card--glow",
className,
)}
>
{icon && (
<span className={cx("mmg-feature-card__icon", `mmg-feature-card__icon--${iconColor}`)} aria-hidden>
<Icon name={icon} size="lg" />
</span>
)}
<h3 className="mmg-feature-card__title">{title}</h3>
{description && <p className="mmg-feature-card__desc">{description}</p>}
{link && (
<a className="mmg-feature-card__link" href={link.href}>
{link.label}
<Icon name="arrow-right-line" size="sm" />
</a>
)}
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import {
useEffect,
useRef,
type HTMLAttributes,
type ReactNode,
} from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
import { useFocusTrap } from "./useFocusTrap";
type Severity = "info" | "success" | "warning" | "danger";
const SEV_ICON: Record<Severity, IconName> = {
info: "information-fill",
success: "checkbox-circle-fill",
warning: "alert-fill",
danger: "error-warning-fill",
};
/* — Alert ————————————————————————————— */
export function Alert({
severity = "info",
title,
children,
closable,
onClose,
className,
...rest
}: HTMLAttributes<HTMLDivElement> & {
severity?: Severity;
title?: ReactNode;
closable?: boolean;
onClose?: () => void;
}) {
return (
<div
role={severity === "danger" ? "alert" : "status"}
className={cx(
"mmg-alert",
severity !== "info" && `mmg-alert--${severity}`,
className,
)}
{...rest}
>
<Icon name={SEV_ICON[severity]} className="mmg-alert__icon" size="md" />
<div className="mmg-alert__body">
{title && <div className="mmg-alert__title">{title}</div>}
{children && <div className="mmg-alert__desc">{children}</div>}
</div>
{closable && (
<button
type="button"
className="mmg-alert__close"
aria-label="Fermer"
onClick={onClose}
>
<Icon name="close-line" />
</button>
)}
</div>
);
}
/* — Notice ————————————————————————————— */
export function Notice({
variant = "brand",
className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & {
variant?: "brand" | "info" | "warning" | "danger";
}) {
return (
<div
role="status"
className={cx(
"mmg-notice",
variant !== "brand" && `mmg-notice--${variant}`,
className,
)}
{...rest}
>
{children}
</div>
);
}
/* — Badge ————————————————————————————— */
export function Badge({
variant,
solid,
className,
children,
...rest
}: HTMLAttributes<HTMLSpanElement> & {
variant?: "brand" | "success" | "warning" | "danger" | "info";
solid?: boolean;
}) {
return (
<span
className={cx(
"mmg-badge",
variant && `mmg-badge--${variant}`,
solid && "mmg-badge--solid",
className,
)}
{...rest}
>
{children}
</span>
);
}
/* — Modal ————————————————————————————— */
let modalIdCounter = 0;
export function Modal({
open,
onClose,
title,
description,
size,
children,
footer,
closable = true,
}: {
open: boolean;
onClose: () => void;
title?: ReactNode;
/** Optionnel — relié via aria-describedby aux lecteurs d'écran. */
description?: ReactNode;
size?: "sm" | "lg";
children: ReactNode;
footer?: ReactNode;
closable?: boolean;
}) {
const ref = useFocusTrap<HTMLDivElement>(open);
const titleIdRef = useRef<string>("mmg-modal-title-" + ++modalIdCounter);
const descIdRef = useRef<string>("mmg-modal-desc-" + modalIdCounter);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && closable) onClose();
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, closable, onClose]);
if (!open) return null;
return (
<div
className="mmg-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget && closable) onClose();
}}
>
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleIdRef.current : undefined}
aria-describedby={description ? descIdRef.current : undefined}
className={cx("mmg-modal", size && `mmg-modal--${size}`)}
>
{title && (
<div className="mmg-modal__header">
<h3 id={titleIdRef.current} className="mmg-modal__title">
{title}
</h3>
{closable && (
<button
type="button"
className="mmg-modal__close"
aria-label="Fermer"
onClick={onClose}
>
<Icon name="close-line" size="md" />
</button>
)}
</div>
)}
<div className="mmg-modal__body">
{description && (
<p
id={descIdRef.current}
style={{ marginBottom: "var(--mmg-space-3)", color: "var(--mmg-color-text-tertiary)", fontSize: "var(--mmg-font-size-sm)" }}
>
{description}
</p>
)}
{children}
</div>
{footer && <div className="mmg-modal__footer">{footer}</div>}
</div>
</div>
);
}
/* — Toast (basique, contrôlé par parent) ————————————————————————————— */
export type ToastItem = {
id: string;
severity?: Severity;
title: ReactNode;
desc?: ReactNode;
};
export function ToastRegion({
toasts,
onDismiss,
}: {
toasts: ToastItem[];
onDismiss?: (id: string) => void;
}) {
return (
<div className="mmg-toast-region" aria-live="polite">
{toasts.map((t) => (
<div
key={t.id}
className={cx(
"mmg-toast",
t.severity && t.severity !== "info" && `mmg-toast--${t.severity}`,
)}
onClick={() => onDismiss?.(t.id)}
>
<div>
<div className="mmg-toast__title">{t.title}</div>
{t.desc && <div className="mmg-toast__desc">{t.desc}</div>}
</div>
</div>
))}
</div>
);
}
/* — Spinner ————————————————————————————— */
export function Spinner({
size = "md",
className,
...rest
}: HTMLAttributes<HTMLSpanElement> & { size?: "sm" | "md" | "lg" }) {
return (
<span
role="status"
aria-label="Chargement"
className={cx("mmg-spinner", size !== "md" && `mmg-spinner--${size}`, className)}
{...rest}
/>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type FileUploadProps = {
label?: ReactNode;
hint?: ReactNode;
multiple?: boolean;
accept?: string;
onFiles?: (files: FileList) => void;
className?: string;
};
export function FileUpload({
label = "Glissez-déposez vos fichiers ici",
hint = "ou cliquez pour parcourir",
multiple,
accept,
onFiles,
className,
}: FileUploadProps) {
const [dragging, setDragging] = useState(false);
return (
<label
className={cx("mmg-upload", className)}
data-dragging={dragging || undefined}
onDragEnter={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length > 0) onFiles?.(e.dataTransfer.files);
}}
>
<Icon name="upload-line" className="mmg-upload__icon" size="xl" />
<div className="mmg-upload__text">{label}</div>
<div className="mmg-upload__hint">{hint}</div>
<input
type="file"
multiple={multiple}
accept={accept}
onChange={(e) => e.target.files && onFiles?.(e.target.files)}
/>
</label>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type FooterColumn = {
title: ReactNode;
links: { label: ReactNode; href: string }[];
};
export type FooterProps = {
brand?: ReactNode;
description?: ReactNode;
columns?: FooterColumn[];
bottomLinks?: { label: ReactNode; href: string }[];
copyright?: ReactNode;
className?: string;
};
export function Footer({
brand,
description,
columns,
bottomLinks,
copyright,
className,
}: FooterProps) {
return (
<footer className={cx("mmg-footer", className)}>
<div className="mmg-container">
<div className="mmg-footer__top">
<div>
{brand && <div className="mmg-footer__col-title">{brand}</div>}
{description && (
<p
style={{
fontSize: "var(--mmg-font-size-sm)",
color: "var(--mmg-color-text-tertiary)",
marginTop: 8,
}}
>
{description}
</p>
)}
</div>
{columns?.map((col, i) => (
<div key={i}>
<div className="mmg-footer__col-title">{col.title}</div>
<ul className="mmg-footer__col-list">
{col.links.map((l, j) => (
<li key={j}>
<a href={l.href}>{l.label}</a>
</li>
))}
</ul>
</div>
))}
</div>
<div className="mmg-footer__bottom">
<div>{copyright ?? `© ${new Date().getFullYear()} ManageMate Group`}</div>
{bottomLinks && (
<div className="mmg-inline">
{bottomLinks.map((l, i) => (
<a
key={i}
href={l.href}
style={{ fontSize: "var(--mmg-font-size-xs)", color: "var(--mmg-color-text-quaternary)" }}
>
{l.label}
</a>
))}
</div>
)}
</div>
</div>
</footer>
);
}
+252
View File
@@ -0,0 +1,252 @@
import {
forwardRef,
useId,
type InputHTMLAttributes,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
type ReactNode,
} from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
type FieldRenderArg = {
/** id du contrôle (à passer à <input>/<textarea>/<select>) */
id: string;
/** id du message à passer en aria-describedby (peut être undefined) */
describedBy: string | undefined;
/** L'état d'invalidité, à passer en aria-invalid */
invalid: boolean | undefined;
};
type FieldProps = {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
success?: ReactNode;
required?: boolean;
className?: string;
id?: string;
children: (arg: FieldRenderArg) => ReactNode;
};
export function Field({
label,
hint,
error,
success,
required,
className,
id: idProp,
children,
}: FieldProps) {
const auto = useId();
const id = idProp ?? auto;
const messageId = `${id}-message`;
const hasMessage = Boolean(error || success || hint);
const describedBy = hasMessage ? messageId : undefined;
const state = error ? "error" : success ? "success" : undefined;
return (
<div className={cx("mmg-field", state && `mmg-field--${state}`, className)}>
{label && (
<label
className={cx(
"mmg-field__label",
required && "mmg-field__label--required",
)}
htmlFor={id}
>
{label}
</label>
)}
{children({ id, describedBy, invalid: error ? true : undefined })}
{error ? (
<span id={messageId} className="mmg-field__error" role="alert">
{error}
</span>
) : success ? (
<span id={messageId} className="mmg-field__success">
{success}
</span>
) : hint ? (
<span id={messageId} className="mmg-field__hint">
{hint}
</span>
) : null}
</div>
);
}
/* — Input ————————————————————————————— */
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
success?: ReactNode;
size?: "sm" | "md" | "lg";
prefixIcon?: IconName;
suffixIcon?: IconName;
};
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, hint, error, success, size = "md", prefixIcon, suffixIcon, required, className, ...rest },
ref,
) {
const cls = cx(
"mmg-input",
size !== "md" && `mmg-input--${size}`,
className,
);
return (
<Field label={label} hint={hint} error={error} success={success} required={required}>
{({ id, describedBy, invalid }) =>
prefixIcon || suffixIcon ? (
<span className={cx("mmg-input-wrap", suffixIcon && "mmg-input-wrap--with-suffix")}>
{prefixIcon && (
<span className="mmg-input-wrap__icon"><Icon name={prefixIcon} /></span>
)}
<input
ref={ref}
id={id}
className={cls}
required={required}
aria-describedby={describedBy}
aria-invalid={invalid}
{...rest}
/>
{suffixIcon && (
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
<Icon name={suffixIcon} />
</span>
)}
</span>
) : (
<input
ref={ref}
id={id}
className={cls}
required={required}
aria-describedby={describedBy}
aria-invalid={invalid}
{...rest}
/>
)
}
</Field>
);
});
/* — Textarea ————————————————————————————— */
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
success?: ReactNode;
fieldSize?: "sm" | "md" | "lg";
};
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
function Textarea(
{ label, hint, error, success, fieldSize = "md", required, className, ...rest },
ref,
) {
const cls = cx(
"mmg-textarea",
fieldSize !== "md" && `mmg-textarea--${fieldSize}`,
className,
);
return (
<Field label={label} hint={hint} error={error} success={success} required={required}>
{({ id, describedBy, invalid }) => (
<textarea
ref={ref}
id={id}
className={cls}
required={required}
aria-describedby={describedBy}
aria-invalid={invalid}
{...rest}
/>
)}
</Field>
);
},
);
/* — Select ————————————————————————————— */
export type SelectOption = { value: string; label: string; disabled?: boolean };
export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> & {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
success?: ReactNode;
size?: "sm" | "md" | "lg";
options: SelectOption[];
placeholder?: string;
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ label, hint, error, success, size = "md", options, placeholder, required, className, ...rest },
ref,
) {
const cls = cx("mmg-select", size !== "md" && `mmg-select--${size}`, className);
return (
<Field label={label} hint={hint} error={error} success={success} required={required}>
{({ id, describedBy, invalid }) => (
<select
ref={ref}
id={id}
className={cls}
required={required}
aria-describedby={describedBy}
aria-invalid={invalid}
{...rest}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((o) => (
<option key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</option>
))}
</select>
)}
</Field>
);
});
/* — Checkbox / Radio ————————————————————————————— */
type CheckProps = InputHTMLAttributes<HTMLInputElement> & {
label: ReactNode;
};
export const Checkbox = forwardRef<HTMLInputElement, CheckProps>(function Checkbox(
{ label, className, ...rest },
ref,
) {
return (
<label className={cx("mmg-check", className)}>
<input ref={ref} type="checkbox" className="mmg-check__input" {...rest} />
<span className="mmg-check__label">{label}</span>
</label>
);
});
export const Radio = forwardRef<HTMLInputElement, CheckProps>(function Radio(
{ label, className, ...rest },
ref,
) {
return (
<label className={cx("mmg-check", className)}>
<input ref={ref} type="radio" className="mmg-check__input" {...rest} />
<span className="mmg-check__label">{label}</span>
</label>
);
});
/* — Switch ————————————————————————————— */
export const Switch = forwardRef<HTMLInputElement, CheckProps>(function Switch(
{ label, className, ...rest },
ref,
) {
return (
<label className={cx("mmg-switch", className)}>
<input ref={ref} type="checkbox" role="switch" className="mmg-switch__input" {...rest} />
<span className="mmg-check__label">{label}</span>
</label>
);
});
+247
View File
@@ -0,0 +1,247 @@
import {
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type NavItemSimple = {
label: ReactNode;
href: string;
current?: boolean;
};
export type MegaMenuLink = {
label: ReactNode;
href: string;
description?: ReactNode;
icon?: IconName;
};
export type MegaMenuColumn = {
title?: ReactNode;
links: MegaMenuLink[];
};
export type MegaMenuFeatured = {
title: ReactNode;
description: ReactNode;
href: string;
cta?: ReactNode;
};
export type NavItemMega = {
label: ReactNode;
current?: boolean;
columns: MegaMenuColumn[];
featured?: MegaMenuFeatured;
};
export type HeaderNavItem = NavItemSimple | NavItemMega;
const isMega = (item: HeaderNavItem): item is NavItemMega => "columns" in item;
function MegaMenuTrigger({ item, index }: { item: NavItemMega; index: number }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLLIElement>(null);
const closeTimer = useRef<number | null>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const cancelClose = () => {
if (closeTimer.current) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = window.setTimeout(() => setOpen(false), 180);
};
return (
<li
ref={ref}
className="mmg-header__nav-item"
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
>
<button
type="button"
className="mmg-header__nav-trigger"
aria-expanded={open}
aria-haspopup="true"
aria-controls={`mmg-megamenu-${index}`}
onClick={() => setOpen((v) => !v)}
>
{item.label}
<Icon name="arrow-down-line" className="mmg-header__nav-chevron" size="sm" />
</button>
{open && (
<div
id={`mmg-megamenu-${index}`}
className="mmg-megamenu"
role="region"
aria-label={typeof item.label === "string" ? item.label : undefined}
>
<div
className="mmg-megamenu__grid"
style={{
gridTemplateColumns: item.featured
? `repeat(${item.columns.length}, minmax(180px, 1fr)) 240px`
: `repeat(${item.columns.length}, minmax(180px, 1fr))`,
}}
>
{item.columns.map((col, ci) => (
<div key={ci}>
{col.title && <div className="mmg-megamenu__col-title">{col.title}</div>}
<ul className="mmg-megamenu__list">
{col.links.map((l, li) => (
<li key={li}>
<a href={l.href} className="mmg-megamenu__link">
{l.icon && (
<span className="mmg-megamenu__link-icon">
<Icon name={l.icon} size="md" />
</span>
)}
<span className="mmg-megamenu__link-content">
<span className="mmg-megamenu__link-label">{l.label}</span>
{l.description && (
<span className="mmg-megamenu__link-desc">{l.description}</span>
)}
</span>
</a>
</li>
))}
</ul>
</div>
))}
{item.featured && (
<a
href={item.featured.href}
className="mmg-megamenu__featured"
style={{ textDecoration: "none" }}
>
<div className="mmg-megamenu__featured-title">{item.featured.title}</div>
<div className="mmg-megamenu__featured-desc">{item.featured.description}</div>
{item.featured.cta && (
<span style={{ color: "var(--mmg-color-accent)", fontWeight: 600, fontSize: "var(--mmg-font-size-sm)" }}>
{item.featured.cta}
</span>
)}
</a>
)}
</div>
</div>
)}
</li>
);
}
export type HeaderProps = {
brand: ReactNode;
brandHref?: string;
/** Si fourni avec serviceTitle, active le mode "institutional". */
brandTop?: ReactNode;
serviceTitle?: ReactNode;
serviceTagline?: ReactNode;
logoMark?: ReactNode;
nav?: HeaderNavItem[];
actions?: ReactNode;
className?: string;
};
export function Header({
brand,
brandHref = "/",
brandTop,
serviceTitle,
serviceTagline,
logoMark,
nav,
actions,
className,
}: HeaderProps) {
const initials = typeof brand === "string" ? brand.slice(0, 2).toUpperCase() : null;
const isInstitutional = Boolean(brandTop || serviceTitle);
return (
<header
className={cx(
"mmg-header",
isInstitutional && "mmg-header--institutional",
className,
)}
>
<div className="mmg-container">
<div className="mmg-header__inner">
<a href={brandHref} className="mmg-header__brand">
<span className="mmg-header__logo">{logoMark ?? initials}</span>
{isInstitutional ? (
<span className="mmg-header__brand-text">
{brandTop && <span className="mmg-header__brand-top">{brandTop}</span>}
{serviceTitle && <span className="mmg-header__service">{serviceTitle}</span>}
{serviceTagline && (
<span className="mmg-header__service-tagline">{serviceTagline}</span>
)}
</span>
) : (
<span className="mmg-header__name">{brand}</span>
)}
</a>
{nav && (
<nav className="mmg-header__nav" aria-label="Navigation principale">
<ul
style={{
display: "flex",
alignItems: "center",
listStyle: "none",
padding: 0,
margin: 0,
gap: "var(--mmg-space-1)",
height: "100%",
}}
>
{nav.map((item, i) =>
isMega(item) ? (
<MegaMenuTrigger key={i} item={item} index={i} />
) : (
<li
key={i}
className="mmg-header__nav-item"
style={{ display: "flex", alignItems: "center" }}
>
<a href={item.href} aria-current={item.current ? "page" : undefined}>
{item.label}
</a>
</li>
),
)}
</ul>
</nav>
)}
{actions && <div className="mmg-header__actions">{actions}</div>}
</div>
</div>
</header>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { HTMLAttributes } from "react";
import { cx } from "./utils";
export type HighlightProps = HTMLAttributes<HTMLDivElement>;
export function Highlight({ children, className, ...rest }: HighlightProps) {
return (
<div className={cx("mmg-highlight", className)} {...rest}>
{children}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { type ReactNode } from "react";
import * as RadixHoverCard from "@radix-ui/react-hover-card";
import { cx } from "./utils";
export type HoverCardProps = {
trigger: ReactNode;
children: ReactNode;
/** Délai d'apparition (ms). Défaut 700. */
openDelay?: number;
/** Délai avant fermeture (ms). Défaut 300. */
closeDelay?: number;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
sideOffset?: number;
className?: string;
};
/**
* HoverCard — preview riche au survol (Radix UI HoverCard).
*
* Pour : aperçus de profil utilisateur, métadonnées de lien, fiche produit
* en survol, etc. Pas pour les tooltips courts (utiliser Tooltip).
*
* A11y : Radix gère le hover-bridge (la fenêtre reste ouverte pendant la
* traversée de l'utilisateur entre trigger et content), le focus management
* et l'invocation par focus clavier (le HoverCard ouvre aussi sur :focus).
*/
export function HoverCard({
trigger,
children,
openDelay = 700,
closeDelay = 300,
side = "bottom",
align = "start",
sideOffset = 8,
className,
}: HoverCardProps) {
return (
<RadixHoverCard.Root openDelay={openDelay} closeDelay={closeDelay}>
<RadixHoverCard.Trigger asChild>{trigger}</RadixHoverCard.Trigger>
<RadixHoverCard.Portal>
<RadixHoverCard.Content
side={side}
align={align}
sideOffset={sideOffset}
collisionPadding={8}
className={cx("mmg-hover-card", className)}
>
{children}
<RadixHoverCard.Arrow className="mmg-hover-card__arrow" />
</RadixHoverCard.Content>
</RadixHoverCard.Portal>
</RadixHoverCard.Root>
);
}
export { RadixHoverCard as HoverCardPrimitive };
+40
View File
@@ -0,0 +1,40 @@
import type { CSSProperties, HTMLAttributes } from "react";
import { cx } from "./utils";
/** Nom d'une icône DSMMG (préfixe `mmg-icon-`). */
export type IconName = string;
export type IconProps = HTMLAttributes<HTMLSpanElement> & {
/** Nom de l'icône (ex. "arrow-right"). Voir packages/icons/dist/icons.json */
name: IconName;
/** Taille — xs: 12px, sm: 14px, md: 18px, lg: 24px, xl: 32px, 2xl: 48px.
Par défaut hérite de font-size. */
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
/** Texte alternatif. Si absent, l'icône est purement décorative (aria-hidden). */
label?: string;
};
export function Icon({
name,
size,
label,
className,
style,
...rest
}: IconProps) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cx(
"mmg-icon",
`mmg-icon-${name}`,
size && `mmg-icon--${size}`,
className,
)}
style={style as CSSProperties}
{...rest}
/>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { HTMLAttributes } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type IconBlockProps = HTMLAttributes<HTMLSpanElement> & {
/** Icône Remix (préfère les variants -fill pour visibilité). */
icon: IconName;
/** Taille — xs:32 sm:40 md:56 lg:72 xl:96 px. */
size?: "xs" | "sm" | "md" | "lg" | "xl";
/** Variante de couleur sémantique. */
color?: "brand" | "success" | "warning" | "danger" | "info" | "neutral";
/** Style visuel — soft (par défaut), filled, outline, gradient. */
variant?: "soft" | "filled" | "outline" | "gradient";
/** Texte alternatif. Si absent, IconBlock est décoratif. */
label?: string;
};
/**
* IconBlock — carré coloré avec icône centrée.
*
* Pattern utilisé chez Linear, Vercel, Notion. Plus polyvalent que les
* pictogrammes hand-codés : se compose à partir de n'importe quelle icône
* Remix, scale automatiquement avec les tokens couleur sémantique, et reste
* cohérent en dark mode.
*
* Préférer en Tile pour les pages d'index produits/fonctionnalités.
*/
export function IconBlock({
icon,
size = "md",
color = "brand",
variant = "soft",
label,
className,
...rest
}: IconBlockProps) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cx(
"mmg-iconblock",
`mmg-iconblock--${size}`,
`mmg-iconblock--${color}`,
variant !== "soft" && `mmg-iconblock--${variant}`,
className,
)}
{...rest}
>
<Icon name={icon} />
</span>
);
}
+225
View File
@@ -0,0 +1,225 @@
import type { HTMLAttributes, ReactNode, AnchorHTMLAttributes, ElementType } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
import { IconBlock, type IconBlockProps } from "./IconBlock";
import { Pictogram, type PictogramName } from "./Pictogram";
/* — Container ————————————————————————————— */
export type ContainerProps = HTMLAttributes<HTMLDivElement> & {
variant?: "default" | "narrow" | "wide" | "fluid";
as?: ElementType;
};
export function Container({
variant = "default",
as: As = "div",
className,
children,
...rest
}: ContainerProps) {
const Tag = As as ElementType;
return (
<Tag
className={cx(
"mmg-container",
variant !== "default" && `mmg-container--${variant}`,
className,
)}
{...rest}
>
{children}
</Tag>
);
}
/* — Section ————————————————————————————— */
export type SectionProps = HTMLAttributes<HTMLElement> & {
size?: "sm" | "md" | "lg";
variant?: "default" | "surface" | "muted" | "brand-soft";
};
export function Section({
size = "md",
variant = "default",
className,
children,
...rest
}: SectionProps) {
return (
<section
className={cx(
"mmg-section",
size !== "md" && `mmg-section--${size}`,
variant !== "default" && `mmg-section--${variant}`,
className,
)}
{...rest}
>
{children}
</section>
);
}
/* — Stack & Inline ————————————————————————————— */
export function Stack({
gap = "md",
className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { gap?: "xs" | "sm" | "md" | "lg" | "xl" }) {
return (
<div className={cx("mmg-stack", `mmg-stack--${gap}`, className)} {...rest}>
{children}
</div>
);
}
export function Inline({
align,
className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { align?: "end" | "between" | "center" }) {
return (
<div
className={cx("mmg-inline", align && `mmg-inline--${align}`, className)}
{...rest}
>
{children}
</div>
);
}
/* — Card ————————————————————————————— */
export function Card({
raised,
flat,
noPadding,
className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & {
raised?: boolean;
flat?: boolean;
noPadding?: boolean;
}) {
return (
<div
className={cx(
"mmg-card",
raised && "mmg-card--raised",
flat && "mmg-card--flat",
noPadding && "mmg-card--no-padding",
className,
)}
{...rest}
>
{children}
</div>
);
}
Card.Header = function CardHeader({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx("mmg-card__header", className)} {...rest}>{children}</div>;
};
Card.Title = function CardTitle({ children, className, ...rest }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cx("mmg-card__title", className)} {...rest}>{children}</h3>;
};
Card.Desc = function CardDesc({ children, className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cx("mmg-card__desc", className)} {...rest}>{children}</p>;
};
Card.Footer = function CardFooter({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx("mmg-card__footer", className)} {...rest}>{children}</div>;
};
/* — Tile ————————————————————————————— */
export type TileProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
title: ReactNode;
desc?: ReactNode;
/** Petite icône carrée (style basique, 48×48). */
icon?: IconName;
/** IconBlock — pattern recommandé : icône fill colorée sur fond rond. */
iconBlock?: IconName;
/** Couleur sémantique de l'IconBlock. */
iconColor?: IconBlockProps["color"];
/** Variante visuelle de l'IconBlock. */
iconVariant?: IconBlockProps["variant"];
/** Pictogramme illustré (legacy v0.2). */
pictogram?: PictogramName;
/** Disposition horizontale (icône à gauche). */
horizontal?: boolean;
/** Cache la flèche d'invitation. */
noArrow?: boolean;
/** Taille — sm (compact), md (défaut), lg (hero). */
size?: "sm" | "md" | "lg";
};
export function Tile({
title,
desc,
icon,
iconBlock,
iconColor = "brand",
iconVariant = "soft",
pictogram,
horizontal,
noArrow,
size = "md",
className,
children,
...rest
}: TileProps) {
const blockSize = size === "sm" ? "sm" : size === "lg" ? "lg" : "md";
return (
<a
className={cx(
"mmg-tile",
size !== "md" && `mmg-tile--${size}`,
horizontal && "mmg-tile--horizontal",
className,
)}
{...rest}
>
{iconBlock ? (
<IconBlock icon={iconBlock} color={iconColor} variant={iconVariant} size={blockSize} />
) : pictogram ? (
<Pictogram name={pictogram} className="mmg-tile__pictogram" />
) : icon ? (
<span className="mmg-tile__icon">
<Icon name={icon} />
</span>
) : null}
<div className="mmg-stack mmg-stack--xs" style={{ flex: 1 }}>
<span className="mmg-tile__title">
{title}
{!noArrow && (
<span className="mmg-tile__arrow" aria-hidden>
<Icon name="arrow-right-line" size="md" />
</span>
)}
</span>
{desc && <span className="mmg-tile__desc">{desc}</span>}
{children}
</div>
</a>
);
}
/* — Hero ————————————————————————————— */
export function Hero({
title,
lead,
actions,
className,
...rest
}: HTMLAttributes<HTMLElement> & {
title: ReactNode;
lead?: ReactNode;
actions?: ReactNode;
}) {
return (
<section className={cx("mmg-hero", className)} {...rest}>
<div className="mmg-container">
<h1 className="mmg-hero__title">{title}</h1>
{lead && <p className="mmg-hero__lead">{lead}</p>}
{actions && <div className="mmg-inline">{actions}</div>}
</div>
</section>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { type ReactNode } from "react";
import * as RadixMenu from "@radix-ui/react-dropdown-menu";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type MenuItem =
| {
type?: "item";
label: ReactNode;
icon?: IconName;
onSelect?: () => void;
danger?: boolean;
href?: string;
disabled?: boolean;
shortcut?: string;
}
| { type: "divider" }
| { type: "label"; label: ReactNode };
export type MenuProps = {
trigger: ReactNode;
items: MenuItem[];
align?: "start" | "center" | "end";
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
};
/**
* Menu (dropdown) — wrapper Radix DropdownMenu.
*
* Accessibilité gérée par Radix : navigation flèches, type-ahead, Escape,
* focus management, ARIA roles. Aucun appel a11y custom.
*
* Pour les sous-menus, items radio/checkbox, ouvrir
* MenuPrimitive et composer manuellement.
*/
export function Menu({
trigger,
items,
align = "start",
side = "bottom",
sideOffset = 6,
}: MenuProps) {
return (
<RadixMenu.Root>
<RadixMenu.Trigger asChild>{trigger}</RadixMenu.Trigger>
<RadixMenu.Portal>
<RadixMenu.Content
align={align}
side={side}
sideOffset={sideOffset}
collisionPadding={8}
className="mmg-menu"
>
{items.map((item, i) => {
if (item.type === "divider") {
return <RadixMenu.Separator key={i} className="mmg-menu__divider" />;
}
if (item.type === "label") {
return (
<RadixMenu.Label key={i} className="mmg-menu__label">
{item.label}
</RadixMenu.Label>
);
}
const { label, icon, onSelect, danger, href, disabled, shortcut } = item;
const cn = cx("mmg-menu__item", danger && "mmg-menu__item--danger");
const inner = (
<>
{icon && <Icon name={icon} />}
<span style={{ flex: 1 }}>{label}</span>
{shortcut && <span className="mmg-menu__shortcut">{shortcut}</span>}
</>
);
if (href) {
return (
<RadixMenu.Item key={i} className={cn} disabled={disabled} asChild>
<a href={href}>{inner}</a>
</RadixMenu.Item>
);
}
return (
<RadixMenu.Item
key={i}
className={cn}
disabled={disabled}
onSelect={onSelect}
>
{inner}
</RadixMenu.Item>
);
})}
</RadixMenu.Content>
</RadixMenu.Portal>
</RadixMenu.Root>
);
}
export { RadixMenu as MenuPrimitive };
+114
View File
@@ -0,0 +1,114 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type MetricTrend = "up" | "down" | "flat";
export type MetricCardProps = {
/** Label de la métrique (ex: "MRR", "NPS", "Tickets ouverts"). */
label: ReactNode;
/** Valeur principale (ex: "12 480 €", "8.2"). */
value: ReactNode;
/** Variation chiffrée (ex: "+12.4%", "-3"). Affichée sous la valeur. */
delta?: ReactNode;
/** Sens de la tendance — détermine la couleur (success/danger/neutre). */
trend?: MetricTrend;
/** Si true, inverse la sémantique : down=success (ex: "tickets en attente"). */
invertTrend?: boolean;
/** Période / sous-titre (ex: "vs mois dernier"). */
period?: ReactNode;
/** Icône optionnelle à gauche du label. */
icon?: IconName;
/** Slot pour un Sparkline ou autre graphique inline. */
sparkline?: ReactNode;
/** Si fourni, rend la carte cliquable (drill-down). */
href?: string;
onClick?: () => void;
className?: string;
};
const TREND_ICON: Record<MetricTrend, IconName> = {
up: "arrow-right-up-line",
down: "arrow-right-up-line", // rotated via CSS
flat: "subtract-line",
};
/**
* MetricCard — carte KPI avec valeur + delta + tendance + sparkline.
*
* Pattern Linear / Vercel / Stripe Dashboard : une grosse valeur, une
* variation colorée (vert si positif, rouge si négatif), période, et
* éventuellement un sparkline en arrière-plan ou inline.
*
* Sémantique :
* - trend "up" : vert par défaut (croissance = bien)
* - trend "down" : rouge par défaut (décroissance = mal)
* - trend "flat" : neutre
* - `invertTrend` inverse pour les KPI où "moins, c'est mieux"
* (tickets ouverts, latence, churn).
*/
export function MetricCard({
label,
value,
delta,
trend = "flat",
invertTrend,
period,
icon,
sparkline,
href,
onClick,
className,
}: MetricCardProps) {
const Comp: "a" | "button" | "div" = href ? "a" : onClick ? "button" : "div";
const semantic =
trend === "flat"
? "neutral"
: (trend === "up" && !invertTrend) || (trend === "down" && invertTrend)
? "success"
: "danger";
return (
<Comp
className={cx(
"mmg-metric-card",
(href || onClick) && "mmg-metric-card--interactive",
className,
)}
href={href}
type={Comp === "button" ? "button" : undefined}
onClick={onClick}
>
<div className="mmg-metric-card__head">
{icon && (
<span className="mmg-metric-card__icon" aria-hidden>
<Icon name={icon} size="sm" />
</span>
)}
<span className="mmg-metric-card__label">{label}</span>
</div>
<div className="mmg-metric-card__value">{value}</div>
{(delta || period) && (
<div className="mmg-metric-card__footer">
{delta && (
<span
className={cx(
"mmg-metric-card__delta",
`mmg-metric-card__delta--${semantic}`,
trend === "down" && "mmg-metric-card__delta--rotated",
)}
>
<Icon name={TREND_ICON[trend]} size="xs" />
{delta}
</span>
)}
{period && <span className="mmg-metric-card__period">{period}</span>}
</div>
)}
{sparkline && <div className="mmg-metric-card__sparkline">{sparkline}</div>}
</Comp>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { cx } from "./utils";
import { Icon } from "./Icon";
export type PaginationProps = {
page: number;
pageCount: number;
onChange: (p: number) => void;
className?: string;
};
export function Pagination({ page, pageCount, onChange, className }: PaginationProps) {
const pages = Array.from({ length: pageCount }, (_, i) => i + 1);
return (
<nav aria-label="Pagination">
<ul className={cx("mmg-pagination", className)}>
<li>
<button
type="button"
onClick={() => onChange(Math.max(1, page - 1))}
disabled={page === 1}
aria-label="Page précédente"
>
<Icon name="arrow-left-line" />
</button>
</li>
{pages.map((p) => (
<li key={p}>
<button
type="button"
aria-current={p === page ? "page" : undefined}
onClick={() => onChange(p)}
>
{p}
</button>
</li>
))}
<li>
<button
type="button"
onClick={() => onChange(Math.min(pageCount, page + 1))}
disabled={page === pageCount}
aria-label="Page suivante"
>
<Icon name="arrow-right-line" />
</button>
</li>
</ul>
</nav>
);
}
+696
View File
@@ -0,0 +1,696 @@
import type { CSSProperties, JSX } from "react";
import { cx } from "./utils";
/**
* Pictogrammes ManageMate — 80×80, palette artwork stable cross-theme.
*
* Tokens utilisés (définis dans tokens.css) :
* --mmg-color-art-major rose marque
* --mmg-color-art-minor rose foncé
* --mmg-color-art-light fond pâle (rose en light, sombre teinté en dark)
* --mmg-color-art-dark silhouette sombre (clair en dark mode)
* --mmg-color-art-accent bleu pour accent rare
*
* Inspiration : DSFR & defense.gouv.fr — illustrations plates, 2-3 couleurs,
* silhouette claire, pas plus de 4 formes par picto.
*/
export type PictogramName =
// — Démarches / documents ——————————————
| "certificate"
| "transfer"
| "document"
| "duplicate"
| "address"
// — Produits SIRH —————————————————————
| "people"
| "payroll"
| "calendar"
| "training"
| "shopping"
| "news"
// — Marketing / général ——————————————
| "rocket"
| "shield"
| "settings"
// — Nouveaux v0.2 ————————————————————
| "success"
| "alert"
| "search"
| "support"
| "growth"
| "target"
| "innovation"
| "savings"
| "handshake"
| "cloud"
| "lock-secure"
| "globe"
| "mobile"
| "analytics"
| "gift"
| "messaging"
// — v0.5 marine ————————————————————
| "marine-casquette"
| "marine-ancre"
| "marine-porte-avion"
// — v0.6 ManageMate Group products ——————————————
| "synapse"
| "hrtime"
| "forge"
| "orbit"
| "automation"
| "insights"
| "integration"
| "workflow";
const MAJOR = "var(--mmg-color-art-major)";
const MINOR = "var(--mmg-color-art-minor)";
const LIGHT = "var(--mmg-color-art-light)";
const DARK = "var(--mmg-color-art-dark)";
const ACCENT = "var(--mmg-color-art-accent)";
const PICTOS: Record<PictogramName, JSX.Element> = {
certificate: (
<>
<rect x="14" y="10" width="52" height="62" rx="4" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<line x1="22" y1="24" x2="58" y2="24" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="22" y1="32" x2="50" y2="32" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="22" y1="40" x2="46" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<circle cx="56" cy="58" r="10" fill={MAJOR} />
<path d="M52 58 L55 61 L61 55" stroke="white" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</>
),
transfer: (
<>
<rect x="6" y="34" width="32" height="22" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<circle cx="14" cy="56" r="4" fill={DARK} />
<circle cx="30" cy="56" r="4" fill={DARK} />
<rect x="42" y="40" width="32" height="22" rx="3" fill={MAJOR} />
<circle cx="50" cy="62" r="4" fill={DARK} />
<circle cx="66" cy="62" r="4" fill={DARK} />
<path d="M36 26 L44 26 L44 22 L52 30 L44 38 L44 34 L36 34 Z" fill={MAJOR} />
</>
),
document: (
<>
<path d="M22 8 L46 8 L58 20 L58 70 Q58 74 54 74 L22 74 Q18 74 18 70 L18 12 Q18 8 22 8 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<path d="M46 8 L46 20 L58 20" fill="none" stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
<line x1="26" y1="34" x2="50" y2="34" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="26" y1="44" x2="50" y2="44" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="26" y1="54" x2="42" y2="54" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
</>
),
duplicate: (
<>
<rect x="10" y="20" width="40" height="50" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="22" y="10" width="40" height="50" rx="3" fill="white" stroke={MAJOR} strokeWidth="2" />
<line x1="30" y1="26" x2="54" y2="26" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="30" y1="34" x2="50" y2="34" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="30" y1="42" x2="46" y2="42" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
</>
),
address: (
<>
<ellipse cx="40" cy="72" rx="14" ry="3" fill={LIGHT} />
<path d="M40 8 C28 8 20 16 20 28 C20 44 40 70 40 70 C40 70 60 44 60 28 C60 16 52 8 40 8 Z" fill={MAJOR} />
<circle cx="40" cy="28" r="8" fill="white" />
</>
),
people: (
<>
<circle cx="28" cy="26" r="10" fill={MAJOR} />
<circle cx="52" cy="26" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<path d="M10 60 Q10 46 28 46 Q46 46 46 60 L46 70 L10 70 Z" fill={MAJOR} />
<path d="M34 60 Q34 46 52 46 Q70 46 70 60 L70 70 L46 70 L46 60 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
</>
),
payroll: (
<>
<rect x="10" y="18" width="60" height="44" rx="4" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="10" y="26" width="60" height="6" fill={MAJOR} />
<circle cx="40" cy="48" r="10" fill="white" stroke={MAJOR} strokeWidth="2" />
<text x="40" y="52" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif"></text>
<line x1="18" y1="44" x2="26" y2="44" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="18" y1="52" x2="26" y2="52" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="54" y1="44" x2="62" y2="44" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="54" y1="52" x2="62" y2="52" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
</>
),
calendar: (
<>
<rect x="10" y="16" width="60" height="56" rx="4" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="10" y="16" width="60" height="14" rx="4" fill={MAJOR} />
<line x1="22" y1="10" x2="22" y2="22" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
<line x1="58" y1="10" x2="58" y2="22" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
<rect x="20" y="38" width="8" height="8" fill={MAJOR} />
<rect x="36" y="38" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
<rect x="52" y="38" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
<rect x="20" y="52" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
<rect x="36" y="52" width="8" height="8" fill={MAJOR} />
<rect x="52" y="52" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
</>
),
training: (
<>
<path d="M10 30 L40 16 L70 30 L40 44 Z" fill={MAJOR} />
<path d="M22 36 L22 54 Q22 64 40 64 Q58 64 58 54 L58 36" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<line x1="68" y1="32" x2="68" y2="50" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<circle cx="68" cy="54" r="3" fill={MAJOR} />
</>
),
shopping: (
<>
<path d="M14 20 L20 20 L26 56 Q26 60 30 60 L60 60 Q64 60 64 56 L68 30 L24 30" fill={LIGHT} stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
<circle cx="32" cy="68" r="4" fill={MAJOR} />
<circle cx="58" cy="68" r="4" fill={MAJOR} />
<path d="M30 36 L30 44 M40 36 L40 44 M50 36 L50 44 M60 36 L60 44" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
</>
),
news: (
<>
<rect x="8" y="18" width="56" height="50" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="64" y="30" width="10" height="38" rx="2" fill="white" stroke={MAJOR} strokeWidth="2" />
<rect x="14" y="24" width="20" height="14" fill={MAJOR} />
<line x1="38" y1="26" x2="58" y2="26" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="38" y1="32" x2="58" y2="32" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="38" y1="38" x2="54" y2="38" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="14" y1="46" x2="58" y2="46" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="14" y1="52" x2="58" y2="52" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
<line x1="14" y1="58" x2="50" y2="58" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
</>
),
rocket: (
<>
<path d="M40 8 Q52 16 52 36 L52 50 L28 50 L28 36 Q28 16 40 8 Z" fill={MAJOR} />
<circle cx="40" cy="28" r="6" fill="white" />
<path d="M28 50 L18 60 L18 68 L28 64 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
<path d="M52 50 L62 60 L62 68 L52 64 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
<path d="M34 50 L34 64 L40 70 L46 64 L46 50 Z" fill={ACCENT} />
</>
),
shield: (
<>
<path d="M40 6 L66 16 L66 38 Q66 60 40 74 Q14 60 14 38 L14 16 Z" fill={MAJOR} />
<path d="M40 14 L60 22 L60 38 Q60 54 40 66 Q20 54 20 38 L20 22 Z" fill={LIGHT} />
<path d="M30 38 L37 45 L52 30" stroke={MAJOR} strokeWidth="3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</>
),
settings: (
<>
<circle cx="40" cy="40" r="28" fill={LIGHT} />
<path d="M40 14 L40 18 M40 62 L40 66 M14 40 L18 40 M62 40 L66 40 M22 22 L25 25 M55 55 L58 58 M22 58 L25 55 M55 25 L58 22" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<circle cx="40" cy="40" r="14" fill={MAJOR} />
<circle cx="40" cy="40" r="6" fill="white" />
</>
),
// ─── NOUVEAUX v0.2 ─────────────────────────────────
success: (
<>
{/* trophée / médaille */}
<ellipse cx="40" cy="72" rx="22" ry="3" fill={LIGHT} />
<path d="M28 12 L52 12 L52 28 Q52 44 40 50 Q28 44 28 28 Z" fill={MAJOR} />
<path d="M28 18 L18 18 L18 28 Q18 38 28 38" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<path d="M52 18 L62 18 L62 28 Q62 38 52 38" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<rect x="34" y="50" width="12" height="14" fill={MINOR} />
<rect x="26" y="62" width="28" height="6" rx="2" fill={DARK} />
<path d="M36 24 L40 30 L46 18" stroke="white" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</>
),
alert: (
<>
<path d="M40 8 L72 64 L8 64 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
<rect x="37" y="28" width="6" height="20" rx="2" fill={MAJOR} />
<circle cx="40" cy="56" r="3" fill={MAJOR} />
</>
),
search: (
<>
<circle cx="32" cy="32" r="20" fill={LIGHT} stroke={MAJOR} strokeWidth="3" />
<circle cx="32" cy="32" r="10" fill="white" stroke={MAJOR} strokeWidth="2" />
<line x1="48" y1="48" x2="68" y2="68" stroke={MAJOR} strokeWidth="6" strokeLinecap="round" />
</>
),
support: (
<>
{/* casque audio + bulle */}
<path d="M14 44 Q14 18 40 18 Q66 18 66 44 L66 54" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<rect x="10" y="44" width="14" height="22" rx="4" fill={MAJOR} />
<rect x="56" y="44" width="14" height="22" rx="4" fill={MAJOR} />
<path d="M58 64 Q58 72 50 72 L42 72" fill="none" stroke={DARK} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="40" cy="72" r="3" fill={DARK} />
</>
),
growth: (
<>
{/* graphe en barres + flèche montante */}
<line x1="12" y1="68" x2="68" y2="68" stroke={DARK} strokeWidth="2.5" strokeLinecap="round" />
<line x1="12" y1="68" x2="12" y2="14" stroke={DARK} strokeWidth="2.5" strokeLinecap="round" />
<rect x="20" y="48" width="10" height="20" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="34" y="36" width="10" height="32" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="48" y="22" width="10" height="46" fill={MAJOR} />
<path d="M16 56 L30 42 L44 30 L60 16" stroke={MAJOR} strokeWidth="3" fill="none" strokeLinecap="round" />
<path d="M52 16 L60 16 L60 24" stroke={MAJOR} strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</>
),
target: (
<>
<circle cx="40" cy="40" r="32" fill={LIGHT} />
<circle cx="40" cy="40" r="22" fill="white" stroke={MAJOR} strokeWidth="2.5" />
<circle cx="40" cy="40" r="12" fill={MAJOR} />
<circle cx="40" cy="40" r="4" fill="white" />
<path d="M58 22 L70 10 M58 22 L65 22 M58 22 L58 15" stroke={DARK} strokeWidth="2.5" fill="none" strokeLinecap="round" />
</>
),
innovation: (
<>
{/* ampoule */}
<path d="M40 8 Q22 8 22 28 Q22 38 30 46 L30 56 L50 56 L50 46 Q58 38 58 28 Q58 8 40 8 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
<rect x="32" y="58" width="16" height="6" rx="1" fill={DARK} />
<rect x="34" y="66" width="12" height="4" rx="1" fill={DARK} />
<path d="M40 22 L40 42 M34 28 L40 22 L46 28" stroke={MAJOR} strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="14" cy="20" r="2.5" fill={MAJOR} />
<circle cx="66" cy="20" r="2.5" fill={MAJOR} />
<circle cx="40" cy="74" r="0" />
</>
),
savings: (
<>
{/* tirelire */}
<ellipse cx="40" cy="74" rx="20" ry="2" fill={LIGHT} />
<path d="M16 44 Q16 28 32 24 Q34 18 40 18 Q46 18 48 24 Q64 28 64 44 L64 56 Q64 66 50 66 L30 66 Q16 66 16 56 Z" fill={MAJOR} />
<circle cx="56" cy="38" r="3" fill="white" />
<rect x="34" y="20" width="12" height="3" rx="1" fill={DARK} />
<line x1="32" y1="64" x2="32" y2="72" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
<line x1="48" y1="64" x2="48" y2="72" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
</>
),
handshake: (
<>
{/* poignée de main */}
<rect x="6" y="32" width="22" height="14" rx="2" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="52" y="32" width="22" height="14" rx="2" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<path d="M28 38 L34 38 L40 44 L48 36 L52 36" stroke={MAJOR} strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
<path d="M40 44 L46 50 L52 44 L48 40" fill={MAJOR} />
<path d="M28 32 L48 32" stroke={MAJOR} strokeWidth="2.5" strokeLinecap="round" />
</>
),
cloud: (
<>
<path d="M22 50 Q12 50 12 40 Q12 30 22 28 Q24 18 36 18 Q48 18 50 26 Q60 24 64 32 Q72 32 72 42 Q72 52 62 52 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
<path d="M40 36 L40 56 M32 50 L40 56 L48 50" stroke={MAJOR} strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</>
),
"lock-secure": (
<>
<rect x="16" y="34" width="48" height="36" rx="4" fill={MAJOR} />
<path d="M22 34 L22 24 Q22 8 40 8 Q58 8 58 24 L58 34" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<circle cx="40" cy="48" r="6" fill={LIGHT} />
<rect x="38" y="50" width="4" height="10" rx="1" fill={LIGHT} />
</>
),
globe: (
<>
<circle cx="40" cy="40" r="30" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
<ellipse cx="40" cy="40" rx="14" ry="30" fill="none" stroke={MAJOR} strokeWidth="2" />
<line x1="10" y1="40" x2="70" y2="40" stroke={MAJOR} strokeWidth="2" />
<path d="M14 28 Q40 22 66 28" fill="none" stroke={MAJOR} strokeWidth="2" />
<path d="M14 52 Q40 58 66 52" fill="none" stroke={MAJOR} strokeWidth="2" />
<circle cx="58" cy="22" r="4" fill={ACCENT} />
</>
),
mobile: (
<>
<rect x="22" y="8" width="36" height="64" rx="6" fill={MAJOR} />
<rect x="26" y="14" width="28" height="46" rx="2" fill={LIGHT} />
<circle cx="40" cy="66" r="2.5" fill="white" />
<line x1="34" y1="11" x2="46" y2="11" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
<rect x="32" y="22" width="16" height="2.5" rx="1" fill={MAJOR} />
<rect x="32" y="30" width="12" height="2.5" rx="1" fill={DARK} />
<rect x="32" y="38" width="14" height="2.5" rx="1" fill={DARK} />
</>
),
analytics: (
<>
{/* écran avec graphe */}
<rect x="6" y="14" width="68" height="44" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="32" y="60" width="16" height="4" fill={DARK} />
<rect x="22" y="64" width="36" height="4" rx="1" fill={DARK} />
<polyline points="14,46 24,38 32,42 42,28 52,32 66,20" fill="none" stroke={MAJOR} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="14" cy="46" r="2.5" fill={MAJOR} />
<circle cx="42" cy="28" r="2.5" fill={MAJOR} />
<circle cx="66" cy="20" r="2.5" fill={MAJOR} />
</>
),
gift: (
<>
<rect x="10" y="32" width="60" height="38" rx="3" fill={MAJOR} />
<rect x="10" y="22" width="60" height="14" rx="2" fill={MINOR} />
<rect x="36" y="22" width="8" height="48" fill={LIGHT} />
<path d="M28 22 Q24 14 28 10 Q32 6 36 10 Q40 14 40 22" fill="none" stroke={LIGHT} strokeWidth="3" strokeLinecap="round" />
<path d="M52 22 Q56 14 52 10 Q48 6 44 10 Q40 14 40 22" fill="none" stroke={LIGHT} strokeWidth="3" strokeLinecap="round" />
</>
),
messaging: (
<>
<path d="M10 18 Q10 14 14 14 L66 14 Q70 14 70 18 L70 50 Q70 54 66 54 L32 54 L20 66 L20 54 L14 54 Q10 54 10 50 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
<circle cx="26" cy="34" r="3" fill={MAJOR} />
<circle cx="40" cy="34" r="3" fill={MAJOR} />
<circle cx="54" cy="34" r="3" fill={MAJOR} />
</>
),
// ─── Marine v0.5 (defense.gouv-inspired) ─────────────────
"marine-casquette": (
<>
{/* casquette d'officier de marine — visière arrondie + bande + insigne */}
<ellipse cx="40" cy="68" rx="26" ry="3" fill={LIGHT} />
{/* visière */}
<path d="M14 56 Q14 52 18 50 L62 50 Q66 52 66 56 Q66 60 62 60 L18 60 Q14 60 14 56 Z" fill={MINOR} />
{/* bande dorée (band) */}
<rect x="14" y="44" width="52" height="6" fill={DARK} />
{/* dôme principal */}
<path d="M16 44 Q16 18 40 18 Q64 18 64 44 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
{/* insigne ancré */}
<circle cx="40" cy="32" r="6" fill={MAJOR} />
<line x1="40" y1="28" x2="40" y2="38" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
<path d="M36 36 Q40 40 44 36" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" />
{/* feuille d'olivier sur la visière */}
<path d="M22 54 Q26 56 30 54" stroke={DARK} strokeWidth="1" fill="none" strokeLinecap="round" />
<path d="M50 54 Q54 56 58 54" stroke={DARK} strokeWidth="1" fill="none" strokeLinecap="round" />
</>
),
"marine-ancre": (
<>
{/* ombre */}
<ellipse cx="40" cy="74" rx="20" ry="2" fill={LIGHT} />
{/* anneau supérieur */}
<circle cx="40" cy="14" r="6" fill="none" stroke={MAJOR} strokeWidth="3" />
{/* tige verticale */}
<line x1="40" y1="20" x2="40" y2="60" stroke={MAJOR} strokeWidth="4" strokeLinecap="round" />
{/* barre horizontale (jas) */}
<line x1="26" y1="28" x2="54" y2="28" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
<circle cx="26" cy="28" r="2.5" fill={MAJOR} />
<circle cx="54" cy="28" r="2.5" fill={MAJOR} />
{/* bras gauche + patte */}
<path d="M40 56 Q22 56 16 44" fill="none" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
<path d="M16 44 L12 50 M16 44 L20 48" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
{/* bras droit + patte */}
<path d="M40 56 Q58 56 64 44" fill="none" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
<path d="M64 44 L68 50 M64 44 L60 48" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
{/* corde enroulée — visualisée par 2 ondes */}
<path d="M30 68 Q35 64 40 68 T50 68" fill="none" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
</>
),
// ─── ManageMate Group products v0.6 ───────────────
/* Synapse — flux/connexion (réseau de neurones) */
synapse: (
<>
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
{/* Nœuds (neurones) */}
<circle cx="20" cy="20" r="6" fill={MAJOR} />
<circle cx="60" cy="20" r="6" fill={MAJOR} />
<circle cx="14" cy="48" r="6" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<circle cx="40" cy="40" r="9" fill={MAJOR} />
<circle cx="66" cy="48" r="6" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<circle cx="40" cy="66" r="6" fill={MINOR} />
{/* Synapses (connexions) */}
<line x1="20" y1="20" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="60" y1="20" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="14" y1="48" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="66" y1="48" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="40" y1="40" x2="40" y2="66" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
{/* Pulse au centre */}
<circle cx="40" cy="40" r="4" fill="white" />
</>
),
/* HRTime — équipe + horloge (gestion du temps RH) */
hrtime: (
<>
<ellipse cx="40" cy="72" rx="22" ry="2" fill={LIGHT} />
{/* Cadran d'horloge en arrière-plan */}
<circle cx="40" cy="40" r="28" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
{/* Marques d'heures */}
<line x1="40" y1="14" x2="40" y2="18" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="40" y1="62" x2="40" y2="66" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="14" y1="40" x2="18" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<line x1="62" y1="40" x2="66" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
{/* Aiguilles */}
<line x1="40" y1="40" x2="40" y2="24" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<line x1="40" y1="40" x2="52" y2="44" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
<circle cx="40" cy="40" r="3" fill={MAJOR} />
{/* Trois personnes en silhouette devant l'horloge */}
<circle cx="28" cy="50" r="5" fill={MAJOR} />
<path d="M20 66 Q20 56 28 56 Q36 56 36 66 Z" fill={MAJOR} />
<circle cx="52" cy="50" r="5" fill={MAJOR} />
<path d="M44 66 Q44 56 52 56 Q60 56 60 66 Z" fill={MAJOR} />
</>
),
/* Forge — outils (clé + engrenage = construire) */
forge: (
<>
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
{/* Engrenage central */}
<circle cx="42" cy="38" r="14" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
{/* Dents de l'engrenage */}
<rect x="40" y="18" width="4" height="6" fill={MAJOR} />
<rect x="40" y="52" width="4" height="6" fill={MAJOR} />
<rect x="22" y="36" width="6" height="4" fill={MAJOR} />
<rect x="56" y="36" width="6" height="4" fill={MAJOR} />
<rect x="27" y="22" width="6" height="4" fill={MAJOR} transform="rotate(-45 30 24)" />
<rect x="51" y="22" width="6" height="4" fill={MAJOR} transform="rotate(45 54 24)" />
<rect x="27" y="50" width="6" height="4" fill={MAJOR} transform="rotate(45 30 52)" />
<rect x="51" y="50" width="6" height="4" fill={MAJOR} transform="rotate(-45 54 52)" />
<circle cx="42" cy="38" r="5" fill={MAJOR} />
{/* Clé qui croise l'engrenage */}
<circle cx="14" cy="60" r="6" fill="none" stroke={MAJOR} strokeWidth="3" />
<line x1="18" y1="56" x2="32" y2="42" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
<line x1="28" y1="46" x2="32" y2="50" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
<line x1="34" y1="40" x2="36" y2="42" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
</>
),
/* Orbit — planète + satellites (data orchestration) */
orbit: (
<>
<ellipse cx="40" cy="72" rx="22" ry="2" fill={LIGHT} />
{/* Orbite extérieure (anneau elliptique) */}
<ellipse
cx="40"
cy="40"
rx="32"
ry="14"
fill="none"
stroke={MAJOR}
strokeWidth="2"
strokeDasharray="3 4"
transform="rotate(-25 40 40)"
/>
{/* Planète centrale */}
<circle cx="40" cy="40" r="14" fill={MAJOR} />
<ellipse cx="40" cy="40" rx="14" ry="5" fill={MINOR} opacity="0.7" />
<circle cx="36" cy="36" r="3" fill="white" opacity="0.5" />
{/* Satellites sur l'orbite */}
<circle cx="68" cy="28" r="4" fill={MAJOR} />
<circle cx="14" cy="56" r="3" fill={MAJOR} />
<circle cx="62" cy="58" r="3" fill={MINOR} />
{/* Trail */}
<path d="M68 28 Q66 30 64 30" stroke={MAJOR} strokeWidth="1" strokeLinecap="round" opacity="0.4" />
</>
),
/* Automation — robot/process automatisé */
automation: (
<>
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
{/* Tête du robot */}
<rect x="20" y="20" width="40" height="32" rx="6" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
{/* Antenne */}
<line x1="40" y1="14" x2="40" y2="20" stroke={MAJOR} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="40" cy="12" r="3" fill={MAJOR} />
{/* Yeux */}
<circle cx="30" cy="34" r="4" fill={MAJOR} />
<circle cx="50" cy="34" r="4" fill={MAJOR} />
<circle cx="30" cy="34" r="1.5" fill="white" />
<circle cx="50" cy="34" r="1.5" fill="white" />
{/* Bouche / interface */}
<rect x="32" y="42" width="16" height="3" rx="1" fill={MAJOR} />
{/* Corps avec circuits */}
<rect x="26" y="54" width="28" height="14" rx="3" fill={MAJOR} />
<line x1="32" y1="60" x2="36" y2="60" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
<line x1="40" y1="60" x2="48" y2="60" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
</>
),
/* Insights — loupe + données (analytique) */
insights: (
<>
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
{/* Tableau de bord en arrière */}
<rect x="14" y="14" width="44" height="36" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
<rect x="20" y="20" width="14" height="3" rx="1" fill={MAJOR} opacity="0.6" />
{/* Mini barchart */}
<rect x="20" y="38" width="4" height="8" fill={MAJOR} />
<rect x="26" y="32" width="4" height="14" fill={MAJOR} />
<rect x="32" y="36" width="4" height="10" fill={MAJOR} opacity="0.7" />
<rect x="38" y="28" width="4" height="18" fill={MAJOR} />
<rect x="44" y="34" width="4" height="12" fill={MAJOR} opacity="0.7" />
{/* Loupe en superposition */}
<circle cx="50" cy="50" r="14" fill="white" stroke={MAJOR} strokeWidth="3" />
<line x1="60" y1="60" x2="70" y2="70" stroke={MAJOR} strokeWidth="4" strokeLinecap="round" />
{/* Trend line dans la loupe */}
<polyline points="42,54 46,50 50,52 54,46 58,48" fill="none" stroke={MAJOR} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</>
),
/* Integration — modules connectés (puzzle/blocks) */
integration: (
<>
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
{/* Bloc supérieur gauche */}
<path d="M14 16 L34 16 L34 24 Q34 28 38 28 Q42 28 42 24 L42 16 L42 36 L34 36 Q30 36 30 40 Q30 44 34 44 L42 44 L42 50 L14 50 Z" fill={MAJOR} />
{/* Bloc inférieur droit complémentaire */}
<path d="M42 30 L62 30 L62 50 L62 60 Q62 64 58 64 Q54 64 54 60 L54 56 L42 56 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
{/* Bloc top droit */}
<rect x="48" y="14" width="20" height="14" rx="3" fill={MINOR} />
{/* Petits accent */}
<circle cx="58" cy="21" r="2" fill="white" />
</>
),
/* Workflow — flèches en boucle (process) */
workflow: (
<>
<ellipse cx="40" cy="72" rx="22" ry="2" fill={LIGHT} />
{/* Étape 1 (cercle haut gauche) */}
<circle cx="22" cy="22" r="10" fill={MAJOR} />
<text x="22" y="27" fontSize="14" fontWeight="700" fill="white" textAnchor="middle" fontFamily="system-ui, sans-serif">1</text>
{/* Étape 2 (cercle haut droit) */}
<circle cx="58" cy="22" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
<text x="58" y="27" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">2</text>
{/* Étape 3 (cercle bas droit) */}
<circle cx="58" cy="58" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
<text x="58" y="63" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">3</text>
{/* Étape 4 (cercle bas gauche) */}
<circle cx="22" cy="58" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
<text x="22" y="63" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">4</text>
{/* Flèches entre les étapes */}
<path d="M32 22 L48 22" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<path d="M44 19 L48 22 L44 25" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
<path d="M58 32 L58 48" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<path d="M55 44 L58 48 L61 44" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
<path d="M48 58 L32 58" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<path d="M36 55 L32 58 L36 61" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 48 L22 32" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
<path d="M19 36 L22 32 L25 36" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</>
),
"marine-porte-avion": (
<>
{/* mer + reflets */}
<path d="M0 64 Q20 60 40 64 T80 64 L80 80 L0 80 Z" fill={LIGHT} />
<path d="M4 70 Q14 68 24 70" stroke={ACCENT} strokeWidth="1.5" fill="none" strokeLinecap="round" />
<path d="M50 72 Q60 70 70 72" stroke={ACCENT} strokeWidth="1.5" fill="none" strokeLinecap="round" />
{/* coque grise */}
<path d="M4 56 L76 56 L70 64 L10 64 Z" fill={DARK} />
{/* pont d'envol — plan supérieur avec angle */}
<path d="M2 50 L78 50 L74 56 L6 56 Z" fill="#3D4757" />
{/* îlot (tour de commandement) */}
<rect x="48" y="32" width="12" height="18" fill={DARK} />
<rect x="50" y="34" width="3" height="3" fill={LIGHT} />
<rect x="55" y="34" width="3" height="3" fill={LIGHT} />
<rect x="50" y="40" width="3" height="3" fill={LIGHT} />
<rect x="55" y="40" width="3" height="3" fill={LIGHT} />
{/* radar — mât + plateau */}
<line x1="54" y1="20" x2="54" y2="32" stroke={DARK} strokeWidth="2" />
<ellipse cx="54" cy="20" rx="6" ry="2" fill={MAJOR} />
{/* tour antenne secondaire */}
<line x1="60" y1="26" x2="60" y2="32" stroke={DARK} strokeWidth="1.5" />
<circle cx="60" cy="26" r="1.5" fill={MAJOR} />
{/* avion sur le pont — silhouette simplifiée */}
<path d="M14 48 L24 44 L28 44 L26 48 L18 50 Z" fill={MAJOR} />
<path d="M22 44 L22 40 M26 46 L30 44" stroke={MAJOR} strokeWidth="1.5" strokeLinecap="round" />
{/* marquage centré du pont */}
<line x1="32" y1="53" x2="44" y2="53" stroke="white" strokeWidth="1" strokeDasharray="2 2" />
</>
),
};
export type PictogramProps = {
name: PictogramName;
size?: number;
className?: string;
style?: CSSProperties;
title?: string;
};
export function Pictogram({
name,
size = 80,
className,
style,
title,
}: PictogramProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 80 80"
width={size}
height={size}
className={cx("mmg-pictogram", className)}
style={style}
role={title ? "img" : "presentation"}
aria-label={title}
>
{title && <title>{title}</title>}
{PICTOS[name]}
</svg>
);
}
export const PICTOGRAM_NAMES: PictogramName[] = [
"certificate", "transfer", "document", "duplicate", "address",
"people", "payroll", "calendar", "training", "shopping", "news",
"rocket", "shield", "settings",
"success", "alert", "search", "support", "growth", "target",
"innovation", "savings", "handshake", "cloud", "lock-secure",
"globe", "mobile", "analytics", "gift", "messaging",
"marine-casquette", "marine-ancre", "marine-porte-avion",
"synapse", "hrtime", "forge", "orbit",
"automation", "insights", "integration", "workflow",
];
+55
View File
@@ -0,0 +1,55 @@
import { type ReactNode } from "react";
import * as RadixPopover from "@radix-ui/react-popover";
import { cx } from "./utils";
export type PopoverProps = {
trigger: ReactNode;
children: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
sideOffset?: number;
modal?: boolean;
className?: string;
};
/**
* Popover — wrapper Radix UI Popover.
*
* Couvre les cas usuels (trigger + contenu). Pour des compositions plus
* fines (close button, anchor séparé, sub-popover) utiliser PopoverPrimitive.
*/
export function Popover({
trigger,
children,
open,
onOpenChange,
defaultOpen,
side = "bottom",
align = "start",
sideOffset = 8,
modal,
className,
}: PopoverProps) {
return (
<RadixPopover.Root open={open} onOpenChange={onOpenChange} defaultOpen={defaultOpen} modal={modal}>
<RadixPopover.Trigger asChild>{trigger}</RadixPopover.Trigger>
<RadixPopover.Portal>
<RadixPopover.Content
side={side}
align={align}
sideOffset={sideOffset}
collisionPadding={8}
className={cx("mmg-popover", className)}
>
{children}
<RadixPopover.Arrow className="mmg-popover__arrow" />
</RadixPopover.Content>
</RadixPopover.Portal>
</RadixPopover.Root>
);
}
export { RadixPopover as PopoverPrimitive };
+90
View File
@@ -0,0 +1,90 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type PricingFeature = {
label: ReactNode;
/** Si false, affiche un icône "x" (feature non incluse dans ce tier). */
included?: boolean;
/** Texte d'aide en survol. */
hint?: ReactNode;
};
export type PricingCardProps = {
/** Nom du plan (ex: "Starter", "Pro", "Enterprise"). */
name: ReactNode;
/** Description courte sous le nom. */
description?: ReactNode;
/** Prix principal (ex: "29 €", "Sur devis"). */
price: ReactNode;
/** Suffixe du prix (ex: "/ mois / utilisateur"). */
pricePeriod?: ReactNode;
/** Liste de features. */
features: PricingFeature[];
/** CTA principal. */
cta: ReactNode;
/** Marque ce plan comme "recommandé" (mise en avant visuelle). */
highlighted?: boolean;
/** Badge en haut à droite (ex: "Populaire", "-20%"). */
badge?: ReactNode;
className?: string;
};
/**
* PricingCard — carte de tarification.
*
* Pattern Stripe / Vercel / Linear : nom + prix + features + CTA. Une
* variante `highlighted` met en avant le plan recommandé via une bordure
* accent et un fond légèrement teinté.
*/
export function PricingCard({
name,
description,
price,
pricePeriod,
features,
cta,
highlighted,
badge,
className,
}: PricingCardProps) {
return (
<div
className={cx(
"mmg-pricing-card",
highlighted && "mmg-pricing-card--highlighted",
className,
)}
>
{badge && <div className="mmg-pricing-card__badge">{badge}</div>}
<div className="mmg-pricing-card__head">
<h3 className="mmg-pricing-card__name">{name}</h3>
{description && <p className="mmg-pricing-card__desc">{description}</p>}
</div>
<div className="mmg-pricing-card__price">
<span className="mmg-pricing-card__price-value">{price}</span>
{pricePeriod && <span className="mmg-pricing-card__price-period">{pricePeriod}</span>}
</div>
<ul className="mmg-pricing-card__features">
{features.map((f, i) => (
<li
key={i}
className={cx(
"mmg-pricing-card__feature",
f.included === false && "mmg-pricing-card__feature--excluded",
)}
title={typeof f.hint === "string" ? f.hint : undefined}
>
<Icon
name={f.included === false ? "close-line" : "check-line"}
size="sm"
className="mmg-pricing-card__feature-icon"
/>
<span>{f.label}</span>
</li>
))}
</ul>
<div className="mmg-pricing-card__cta">{cta}</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
import { Avatar, type AvatarStatus } from "./Avatar";
export type ProfileHeaderProps = {
name: ReactNode;
/** Sous-titre (poste, organisation…). */
subtitle?: ReactNode;
/** Description longue / bio. */
bio?: ReactNode;
/** URL de la photo de profil. */
src?: string;
initials?: string;
status?: AvatarStatus;
/** URL de la photo de couverture (cover). Si absente, gradient accent. */
coverSrc?: string;
/** Stats à droite des actions (commits, reviews, années…). */
stats?: { label: ReactNode; value: ReactNode }[];
/** Slot d'actions sous la bio (boutons, menu…). */
actions?: ReactNode;
/** Slot de badges (sous le nom). */
badges?: ReactNode;
className?: string;
};
/**
* ProfileHeader — en-tête de profil avec photo de couverture + avatar
* en débord (LinkedIn / GitHub / Twitter pattern).
*
* - Cover : image fournie ou gradient accent par défaut.
* - Avatar large (xl) en débord sur la cover.
* - Identité (nom + sous-titre + bio).
* - Stats inline + actions (Suivre, Message…).
*/
export function ProfileHeader({
name,
subtitle,
bio,
src,
initials,
status,
coverSrc,
stats,
actions,
badges,
className,
}: ProfileHeaderProps) {
return (
<div className={cx("mmg-profile-header", className)}>
<div className="mmg-profile-header__cover">
{coverSrc ? (
<img src={coverSrc} alt="" aria-hidden />
) : (
<div className="mmg-profile-header__cover-gradient" aria-hidden />
)}
</div>
<div className="mmg-profile-header__body">
<div className="mmg-profile-header__avatar-wrap">
<Avatar
src={src}
initials={initials}
alt={typeof name === "string" ? name : undefined}
status={status}
size="2xl"
bordered
/>
</div>
<div className="mmg-profile-header__main">
<div className="mmg-profile-header__heading">
<div>
<h2 className="mmg-profile-header__name">{name}</h2>
{subtitle && <div className="mmg-profile-header__subtitle">{subtitle}</div>}
{badges && <div className="mmg-profile-header__badges">{badges}</div>}
</div>
{actions && <div className="mmg-profile-header__actions">{actions}</div>}
</div>
{bio && <p className="mmg-profile-header__bio">{bio}</p>}
{stats && stats.length > 0 && (
<dl className="mmg-profile-header__stats">
{stats.map((s, i) => (
<div key={i} className="mmg-profile-header__stat">
<dt className="mmg-profile-header__stat-label">{s.label}</dt>
<dd className="mmg-profile-header__stat-value">{s.value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { HTMLAttributes } from "react";
import { cx } from "./utils";
export type ProgressProps = HTMLAttributes<HTMLDivElement> & {
value?: number;
max?: number;
variant?: "success" | "warning" | "danger";
indeterminate?: boolean;
};
export function Progress({
value,
max = 100,
variant,
indeterminate,
className,
...rest
}: ProgressProps) {
const pct = indeterminate ? 100 : Math.min(100, Math.max(0, ((value ?? 0) / max) * 100));
return (
<div
role="progressbar"
aria-valuenow={indeterminate ? undefined : value}
aria-valuemin={0}
aria-valuemax={max}
className={cx(
"mmg-progress",
variant && `mmg-progress--${variant}`,
indeterminate && "mmg-progress--indeterminate",
className,
)}
{...rest}
>
<div className="mmg-progress__bar" style={{ width: `${pct}%` }} />
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type QuoteProps = {
author?: ReactNode;
children: ReactNode;
className?: string;
};
export function Quote({ author, children, className }: QuoteProps) {
return (
<blockquote className={cx("mmg-quote", className)}>
<p style={{ margin: 0 }}>{children}</p>
{author && <cite className="mmg-quote__author"> {author}</cite>}
</blockquote>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState, type InputHTMLAttributes } from "react";
import { cx } from "./utils";
export type SearchBarProps = Omit<InputHTMLAttributes<HTMLInputElement>, "onSubmit"> & {
onSearch?: (q: string) => void;
buttonLabel?: string;
};
export function SearchBar({
placeholder = "Rechercher…",
onSearch,
buttonLabel = "Rechercher",
className,
...rest
}: SearchBarProps) {
const [val, setVal] = useState("");
return (
<form
className={cx("mmg-search", className)}
role="search"
onSubmit={(e) => {
e.preventDefault();
onSearch?.(val);
}}
>
<input
type="search"
className="mmg-search__input"
placeholder={placeholder}
value={val}
onChange={(e) => setVal(e.target.value)}
{...rest}
/>
<button type="submit" className="mmg-search__btn">
{buttonLabel}
</button>
</form>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { type ReactNode } from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type SheetSide = "left" | "right" | "top" | "bottom";
export type SheetSize = "sm" | "md" | "lg" | "xl" | "full";
export type SheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Côté d'apparition. Défaut "right" (le plus courant pour un sheet desktop). */
side?: SheetSide;
/** Largeur (left/right) ou hauteur (top/bottom). */
size?: SheetSize;
title?: ReactNode;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
/** Cache le bouton de fermeture en haut à droite. */
hideClose?: boolean;
className?: string;
};
/**
* Sheet — panneau latéral / drawer (Radix Dialog avec position custom).
*
* Différence avec Dialog : Sheet glisse depuis un bord (latéral, top, bottom).
* Différence avec Drawer (legacy) : Sheet utilise Radix → focus trap, scroll
* lock, restitution focus, escape, aria-* tout est natif. Préférer Sheet.
*
* Usage typique : édition rapide d'une ressource, navigation mobile,
* filtres avancés, prévisualisation détaillée.
*/
export function Sheet({
open,
onOpenChange,
side = "right",
size = "md",
title,
description,
children,
footer,
hideClose,
className,
}: SheetProps) {
return (
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
<RadixDialog.Portal>
<RadixDialog.Overlay className="mmg-sheet__overlay" />
<RadixDialog.Content
className={cx(
"mmg-sheet",
`mmg-sheet--${side}`,
`mmg-sheet--${size}`,
className,
)}
>
{(title || !hideClose) && (
<header className="mmg-sheet__header">
<div className="mmg-sheet__header-text">
{title && (
<RadixDialog.Title className="mmg-sheet__title">
{title}
</RadixDialog.Title>
)}
{description && (
<RadixDialog.Description className="mmg-sheet__description">
{description}
</RadixDialog.Description>
)}
</div>
{!hideClose && (
<RadixDialog.Close asChild>
<button type="button" className="mmg-sheet__close" aria-label="Fermer">
<Icon name="close-line" size="md" />
</button>
</RadixDialog.Close>
)}
</header>
)}
<div className="mmg-sheet__body">{children}</div>
{footer && <footer className="mmg-sheet__footer">{footer}</footer>}
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
export { RadixDialog as SheetPrimitive };
+364
View File
@@ -0,0 +1,364 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
/**
* Système global de raccourcis clavier — DSMMG.
*
* Architecture :
* - <ShortcutProvider> au sommet de l'app
* - useShortcut(key, handler, options) dans n'importe quel composant
* - <ShortcutCheatsheet> ou hook useShortcutList() pour afficher tous
* les raccourcis enregistrés
*
* Conformité a11y :
* - WCAG 2.1.1 (clavier) : tout le système est navigable au clavier
* - WCAG 2.1.4 (raccourcis caractère seul) : on accepte les modifiers
* Ctrl/Cmd/Alt/Shift, ou les chords (g i) — un caractère unique seul
* est désactivé si une saisie est focus (input, textarea, contenteditable)
*
* Format de clé :
* - "cmd+k", "ctrl+/", "shift+?"
* - Chord : "g i", "g h", "y y"
* - Touche simple : "/" , "?" (uniquement si aucun champ focus)
*/
export type ShortcutOptions = {
/** Catégorie pour le regroupement dans la cheatsheet */
category?: string;
/** Description affichée dans la cheatsheet */
description?: string;
/** Désactive le raccourci si vrai */
disabled?: boolean;
/** Autorise même quand un input/textarea est focusé */
allowInInput?: boolean;
/** Empêche le default du browser */
preventDefault?: boolean;
};
export type ShortcutEntry = {
id: string;
keys: string;
category: string;
description: string;
};
type ShortcutHandler = (event: KeyboardEvent) => void;
type Registered = {
keys: string;
handler: ShortcutHandler;
options: ShortcutOptions;
};
type Ctx = {
register: (id: string, entry: Registered) => () => void;
list: () => ShortcutEntry[];
};
const ShortcutContext = createContext<Ctx | null>(null);
const isMac =
typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
function normalizeCombo(combo: string): string {
return combo
.toLowerCase()
.split("+")
.map((p) => p.trim())
.map((p) => (p === "cmd" || p === "meta" ? (isMac ? "meta" : "ctrl") : p))
.sort()
.join("+");
}
function eventToCombo(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.metaKey) parts.push("meta");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
// Skip the modifier itself if user is just holding modifiers
const k = e.key.toLowerCase();
if (!["control", "meta", "alt", "shift"].includes(k)) parts.push(k);
return parts.sort().join("+");
}
function isInsideEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (target.isContentEditable) return true;
return false;
}
export function ShortcutProvider({ children }: { children: ReactNode }) {
const registry = useRef(new Map<string, Registered>());
const chordBuffer = useRef<{ key: string; expires: number } | null>(null);
const CHORD_WINDOW_MS = 800;
const register = useCallback((id: string, entry: Registered) => {
registry.current.set(id, entry);
return () => {
registry.current.delete(id);
};
}, []);
const list = useCallback((): ShortcutEntry[] => {
return Array.from(registry.current.entries()).map(([id, e]) => ({
id,
keys: e.keys,
category: e.options.category ?? "Général",
description: e.options.description ?? "",
}));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const inEditable = isInsideEditableElement(e.target);
const combo = eventToCombo(e);
const now = Date.now();
// Test chord
if (chordBuffer.current && chordBuffer.current.expires > now) {
const chordCombo = `${chordBuffer.current.key} ${combo}`;
for (const [, entry] of registry.current) {
if (entry.options.disabled) continue;
if (inEditable && !entry.options.allowInInput) continue;
const normalized = entry.keys
.split(" ")
.map(normalizeCombo)
.join(" ");
if (normalized === chordCombo) {
if (entry.options.preventDefault !== false) e.preventDefault();
entry.handler(e);
chordBuffer.current = null;
return;
}
}
}
// Test single combo
for (const [, entry] of registry.current) {
if (entry.options.disabled) continue;
if (inEditable && !entry.options.allowInInput) continue;
const normalized = normalizeCombo(entry.keys);
if (normalized === combo) {
if (entry.options.preventDefault !== false) e.preventDefault();
entry.handler(e);
return;
}
}
// Si la touche n'a pas matché et n'est pas un modifier, mémoriser comme début de chord
if (
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
e.key.length === 1 &&
!inEditable
) {
chordBuffer.current = {
key: combo,
expires: now + CHORD_WINDOW_MS,
};
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const value = useMemo(() => ({ register, list }), [register, list]);
return (
<ShortcutContext.Provider value={value}>
{children}
</ShortcutContext.Provider>
);
}
/**
* Hook pour enregistrer un raccourci local au cycle de vie du composant.
*
* useShortcut("cmd+k", () => setOpen(true), {
* description: "Ouvrir la palette",
* category: "Navigation",
* });
*/
export function useShortcut(
keys: string,
handler: ShortcutHandler,
options: ShortcutOptions = {},
) {
const ctx = useContext(ShortcutContext);
if (!ctx)
throw new Error("useShortcut requiert un <ShortcutProvider> ancêtre.");
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const id = `${keys}-${Math.random().toString(36).slice(2, 8)}`;
return ctx.register(id, {
keys,
handler: (e) => handlerRef.current(e),
options,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keys, options.disabled, options.allowInInput, options.preventDefault]);
}
/**
* Liste tous les raccourcis enregistrés (pour cheatsheet).
*/
export function useShortcutList(): ShortcutEntry[] {
const ctx = useContext(ShortcutContext);
if (!ctx) return [];
const [, force] = useState(0);
// Re-render léger — la liste peut bouger
useEffect(() => {
const t = setInterval(() => force((n) => n + 1), 1000);
return () => clearInterval(t);
}, []);
return ctx.list();
}
/**
* Affichage cheatsheet (ouvert via "?" par défaut).
*/
export function ShortcutCheatsheet({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const list = useShortcutList();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
// Group by category
const groups = list.reduce<Record<string, ShortcutEntry[]>>((acc, e) => {
(acc[e.category] ??= []).push(e);
return acc;
}, {});
return (
<div
className="mmg-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Raccourcis clavier"
className="mmg-modal"
style={{ maxWidth: 640 }}
>
<div className="mmg-modal__header">
<h3 className="mmg-modal__title">Raccourcis clavier</h3>
<button
type="button"
className="mmg-modal__close"
aria-label="Fermer"
onClick={onClose}
>
×
</button>
</div>
<div className="mmg-modal__body">
{Object.keys(groups).length === 0 ? (
<p style={{ color: "var(--mmg-color-text-tertiary)" }}>
Aucun raccourci enregistré dans le contexte actuel.
</p>
) : (
Object.entries(groups).map(([cat, entries]) => (
<div key={cat} style={{ marginBottom: "var(--mmg-space-5)" }}>
<h4
style={{
fontSize: "var(--mmg-font-size-xs)",
fontWeight: "var(--mmg-font-weight-bold)",
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--mmg-color-text-tertiary)",
marginBottom: "var(--mmg-space-2)",
}}
>
{cat}
</h4>
<dl
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: "var(--mmg-space-2) var(--mmg-space-4)",
margin: 0,
}}
>
{entries.map((e) => (
<div
key={e.id}
style={{
display: "contents",
}}
>
<dt
style={{
fontSize: "var(--mmg-font-size-sm)",
color: "var(--mmg-color-text-secondary)",
}}
>
{e.description || e.id}
</dt>
<dd style={{ margin: 0 }}>
<ShortcutKeys keys={e.keys} />
</dd>
</div>
))}
</dl>
</div>
))
)}
</div>
</div>
</div>
);
}
/**
* Affichage joli des keys d'un raccourci (chunks <kbd>).
*/
export function ShortcutKeys({ keys }: { keys: string }) {
const chords = keys.split(" ");
return (
<span style={{ display: "inline-flex", gap: 4 }}>
{chords.map((chord, i) => (
<span key={i} style={{ display: "inline-flex", gap: 2 }}>
{chord.split("+").map((k, j) => (
<kbd key={j} className="mmg-kbd">
{k === "cmd" || k === "meta" ? (isMac ? "⌘" : "Ctrl") : k.toUpperCase()}
</kbd>
))}
{i < chords.length - 1 && <span style={{ opacity: 0.5 }}>then</span>}
</span>
))}
</span>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
export type SkipLinkProps = {
targetId?: string;
children?: ReactNode;
};
export function SkipLink({ targetId = "main", children = "Aller au contenu" }: SkipLinkProps) {
return (
<a className="mmg-skip-link" href={`#${targetId}`}>
{children}
</a>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { type ReactNode } from "react";
import * as RadixSlider from "@radix-ui/react-slider";
import { cx } from "./utils";
export type SliderProps = {
/** Valeur(s) du slider. Tableau pour permettre les ranges (2 thumbs). */
value: number[];
onValueChange: (value: number[]) => void;
/** Valeur min. Défaut 0. */
min?: number;
/** Valeur max. Défaut 100. */
max?: number;
/** Pas (granularité). Défaut 1. */
step?: number;
/** Désactivé. */
disabled?: boolean;
/** Label accessible (lu par les lecteurs d'écran). */
label?: string;
/** Si true, affiche la valeur au-dessus du thumb actif. */
showValue?: boolean;
/** Format de la valeur affichée. Défaut: identité. */
formatValue?: (v: number) => string;
className?: string;
};
/**
* Slider — sélecteur de valeur (single ou range, Radix UI).
*
* - Clavier : flèches (step), Page Up/Down (×10 step), Home/End (min/max).
* - aria-valuenow/min/max/text gérés par Radix.
* - Pour un range, passer un tableau de 2 valeurs.
*
* Style DSMMG : track fin, thumb cerclé pill, accent au remplissage.
* Focus visible : halo accent autour du thumb.
*/
export function Slider({
value,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled,
label,
showValue,
formatValue = (v) => `${v}`,
className,
}: SliderProps) {
return (
<RadixSlider.Root
className={cx("mmg-slider", disabled && "mmg-slider--disabled", className)}
value={value}
onValueChange={onValueChange}
min={min}
max={max}
step={step}
disabled={disabled}
aria-label={label}
>
<RadixSlider.Track className="mmg-slider__track">
<RadixSlider.Range className="mmg-slider__range" />
</RadixSlider.Track>
{value.map((v, i) => (
<RadixSlider.Thumb
key={i}
className="mmg-slider__thumb"
aria-label={label ? `${label} (${i + 1}/${value.length})` : undefined}
>
{showValue && (
<span className="mmg-slider__value" aria-hidden>
{formatValue(v)}
</span>
)}
</RadixSlider.Thumb>
))}
</RadixSlider.Root>
);
}
export { RadixSlider as SliderPrimitive };
+130
View File
@@ -0,0 +1,130 @@
import {
Children,
cloneElement,
forwardRef,
isValidElement,
type CSSProperties,
type HTMLAttributes,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
/**
* Slot — pattern Radix UI / shadcn pour le polymorphisme `asChild`.
*
* Permet à un composant DSMMG de "passer" son comportement (props, ref,
* className fusionnée) à son unique enfant React. Cas d'usage typique :
*
* <MmgButton asChild>
* <Link to="/synapse">Aller à Synapse</Link>
* </MmgButton>
*
* Ici, le `<Link>` reçoit toutes les props du Button (className, onClick,
* aria-*, ref) tout en restant un vrai `<a>` côté DOM. Cela évite de
* dupliquer un Button et un ButtonAsLink, et préserve la sémantique
* native du tag enfant (RGAA 7.1, ARIA rule #1).
*/
export type SlotProps = HTMLAttributes<HTMLElement> & {
children?: ReactNode;
};
/**
* Fusionne deux className ; conserve l'ordre source → destination.
* Pas de dépendance externe.
*/
function mergeClassNames(
parent?: string,
child?: string,
): string | undefined {
if (!parent && !child) return undefined;
if (!parent) return child;
if (!child) return parent;
return `${parent} ${child}`;
}
/**
* Fusionne deux styles ; les styles enfants priment (override).
*/
function mergeStyles(
parent?: CSSProperties,
child?: CSSProperties,
): CSSProperties | undefined {
if (!parent && !child) return undefined;
return { ...parent, ...child };
}
/**
* Fusionne deux event handlers ; appelle l'enfant en premier — si
* `event.defaultPrevented` est vrai, on n'appelle pas le parent
* (laisse l'enfant override le comportement).
*/
function composeEventHandlers<E extends { defaultPrevented?: boolean }>(
parentHandler?: (event: E) => void,
childHandler?: (event: E) => void,
): ((event: E) => void) | undefined {
if (!parentHandler && !childHandler) return undefined;
return (event: E) => {
childHandler?.(event);
if (!event.defaultPrevented) parentHandler?.(event);
};
}
/**
* Fusionne refs (callback ou objet).
*/
function composeRefs<T>(...refs: Array<Ref<T> | undefined>): Ref<T> {
return (node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === "function") ref(node);
else if (ref != null) (ref as React.MutableRefObject<T | null>).current = node;
});
};
}
export const Slot = forwardRef<HTMLElement, SlotProps>(function Slot(
{ children, ...slotProps },
forwardedRef,
) {
if (!isValidElement(children)) {
return null;
}
const childElement = Children.only(children) as ReactElement<
HTMLAttributes<HTMLElement> & { ref?: Ref<HTMLElement> }
>;
const childProps = childElement.props;
const merged: Record<string, unknown> = { ...slotProps };
// className : slot puis enfant (l'enfant override visuellement)
merged.className = mergeClassNames(slotProps.className, childProps.className);
merged.style = mergeStyles(slotProps.style, childProps.style);
// Compose event handlers
for (const key in slotProps) {
if (key.startsWith("on") && typeof (slotProps as any)[key] === "function") {
merged[key] = composeEventHandlers(
(slotProps as any)[key],
(childProps as any)[key],
);
}
}
// Ref combinée
merged.ref = composeRefs(forwardedRef, (childElement as any).ref);
return cloneElement(childElement, merged as never);
});
/**
* Helper pour les composants qui acceptent `asChild` : retourne `Slot` si
* `asChild` est vrai, sinon le tag par défaut.
*/
export function getSlotComponent<T extends keyof React.JSX.IntrinsicElements>(
asChild: boolean | undefined,
defaultTag: T,
): T | typeof Slot {
return asChild ? Slot : defaultTag;
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type StatProps = {
icon?: IconName;
value: ReactNode;
label: ReactNode;
className?: string;
};
export function Stat({ icon, value, label, className }: StatProps) {
return (
<div className={cx("mmg-stat", className)}>
{icon && (
<div className="mmg-stat__icon">
<Icon name={icon} size="md" />
</div>
)}
<div>
<div className="mmg-stat__value">{value}</div>
<div className="mmg-stat__label">{label}</div>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type StepperProps = {
current: number;
total: number;
title: ReactNode;
nextTitle?: ReactNode;
className?: string;
};
export function Stepper({ current, total, title, nextTitle, className }: StepperProps) {
return (
<div className={cx("mmg-stepper", className)}>
<div className="mmg-stepper__progress">
Étape {current} sur {total}
</div>
<div className="mmg-stepper__bar" aria-hidden>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={cx(
"mmg-stepper__bar-item",
i + 1 < current && "mmg-stepper__bar-item--done",
i + 1 === current && "mmg-stepper__bar-item--current",
)}
/>
))}
</div>
<div className="mmg-stepper__title">{title}</div>
{nextTitle && current < total && (
<div className="mmg-stepper__next">
Étape suivante&nbsp;: <strong>{nextTitle}</strong>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type TabItem = { id: string; label: ReactNode };
export type TabsProps = {
items: TabItem[];
value: string;
onChange: (id: string) => void;
className?: string;
};
export function Tabs({ items, value, onChange, className }: TabsProps) {
return (
<div role="tablist" className={cx("mmg-tabs", className)}>
{items.map((t) => (
<button
key={t.id}
role="tab"
type="button"
aria-selected={t.id === value}
className="mmg-tabs__btn"
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { HTMLAttributes } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type TagProps = HTMLAttributes<HTMLElement> & {
selected?: boolean;
onSelect?: () => void;
icon?: IconName;
};
export function Tag({ selected, onSelect, children, className, icon, ...rest }: TagProps) {
const Comp = onSelect ? "button" : "span";
return (
<Comp
type={onSelect ? "button" : undefined}
className={cx(
"mmg-tag",
onSelect && "mmg-tag--clickable",
selected && "mmg-tag--selected",
className,
)}
onClick={onSelect}
aria-pressed={onSelect ? selected : undefined}
{...(rest as object)}
>
{icon && <Icon name={icon} size="sm" />}
{children}
</Comp>
);
}
+135
View File
@@ -0,0 +1,135 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react";
import { cx } from "./utils";
export type TextVariant =
// Display (landing marketing — Hero produits)
| "display-2xl"
| "display-xl"
| "display-lg"
| "display-md"
// Headlines (apps + pages)
| "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
// Body
| "body-lg" | "body" | "body-sm" | "body-xs"
// Auxiliaires
| "eyebrow" | "lead" | "overline" | "caption";
export type TextElement = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
export type TextProps = HTMLAttributes<HTMLElement> & {
/** Variante typographique. Détermine taille/poids/letter-spacing/line-height. */
variant?: TextVariant;
/** Tag HTML rendu. Choisir selon la sémantique, pas selon la taille. */
as?: TextElement;
/** Police mono (JetBrains Mono / SF Mono) pour code, IDs, valeurs tabulaires. */
mono?: boolean;
/** Numérique tabular (alignement vertical des chiffres). Pour stats / tableaux. */
tabular?: boolean;
/** Gradient accent → accent-strong sur le texte (modern Hero touch). */
gradient?: boolean;
/** Effet "rainbow" animé. À utiliser une seule fois par page max. */
rainbow?: boolean;
/** text-wrap: balance — équilibre les retours à la ligne (titres). */
balance?: boolean;
/** text-wrap: pretty — évite veuves/orphelines (body). */
pretty?: boolean;
/** Italique — pour titres d'œuvre, citations, mots-clefs sémantiquement à part. */
italic?: boolean;
/** Italique + couleur d'accent — emphase éditoriale (mot-clef ressortir). */
emphasis?: boolean;
/** Surligneur teinté accent — highlight ponctuel dans un body. */
highlight?: boolean;
/** Souligné épais accent — accent fort sur un mot-clef. */
underline?: boolean;
/** Barré — prix barré, élément déprécié. */
strike?: boolean;
children?: ReactNode;
};
/**
* Text — composant typographique sémantique du DSMMG.
*
* **Choisir le tag (`as`) selon la SÉMANTIQUE, pas selon la taille.**
* Un titre de page reste `<h1>` même si visuellement il s'affiche en
* `display-xl`. Un sous-titre marketing décoratif peut être un `<p>`
* stylé en `display-lg`.
*
* Usage typique sur une landing produit (HRTime, Synapse…) :
*
* <Text as="span" variant="eyebrow">SIRH simple</Text>
* <Text as="h1" variant="display-xl" balance gradient>
* Le temps de vos équipes, au cordeau.
* </Text>
* <Text as="p" variant="lead" pretty>
* Plannings, congés, paie. Une seule plateforme, sans surprise.
* </Text>
*
* Usage app (DataTable, dashboard) :
*
* <Text as="h2" variant="h3">Effectifs</Text>
* <Text as="p" variant="body-sm">432 collaborateurs actifs.</Text>
* <Text as="span" variant="overline">Section</Text>
*/
export function Text({
variant = "body",
as,
mono,
tabular,
gradient,
rainbow,
balance,
pretty,
italic,
emphasis,
highlight,
underline,
strike,
className,
children,
...rest
}: TextProps) {
const Comp: ElementType =
as ??
(variant.startsWith("display") ? "h1" :
variant === "h1" || variant === "h2" || variant === "h3" ||
variant === "h4" || variant === "h5" || variant === "h6" ? variant :
variant === "lead" ? "p" :
"span");
return (
<Comp
className={cx(
`mmg-text-${variant}`,
mono && "mmg-text--mono",
tabular && "mmg-text--tabular",
balance && "mmg-text--balance",
pretty && "mmg-text--pretty",
gradient && "mmg-text--gradient",
rainbow && "mmg-text--rainbow",
italic && "mmg-text--italic",
emphasis && "mmg-text--emphasis",
highlight && "mmg-text--highlight",
underline && "mmg-text--underline",
strike && "mmg-text--strike",
className,
)}
{...rest}
>
{children}
</Comp>
);
}
/* — Helpers raccourcis pour les variants les plus utilisés ———————————— */
export function Display(props: Omit<TextProps, "variant"> & { size?: "md" | "lg" | "xl" | "2xl" }) {
const { size = "xl", ...rest } = props;
return <Text variant={`display-${size}` as TextVariant} balance {...rest} />;
}
export function Eyebrow(props: Omit<TextProps, "variant" | "as">) {
return <Text as="span" variant="eyebrow" {...props} />;
}
export function Lead(props: Omit<TextProps, "variant" | "as">) {
return <Text as="p" variant="lead" pretty {...props} />;
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { ThemePicker } from "./ThemePicker";
import { ACCENT_PRESETS } from "./useAccent";
describe("ThemePicker", () => {
beforeEach(() => {
document.documentElement.removeAttribute("data-mmg-accent");
window.localStorage.clear();
});
it("rend une option par preset", () => {
render(<ThemePicker />);
const radios = screen.getAllByRole("radio");
expect(radios).toHaveLength(ACCENT_PRESETS.length);
});
it("synapse est sélectionné par défaut", () => {
render(<ThemePicker />);
const synapse = screen.getByRole("radio", { name: /synapse/i });
expect(synapse).toHaveAttribute("aria-checked", "true");
});
it("clic sur un preset l'active", async () => {
const user = userEvent.setup();
render(<ThemePicker />);
const blue = screen.getByRole("radio", { name: /^Bleu$/i });
await user.click(blue);
expect(blue).toHaveAttribute("aria-checked", "true");
expect(document.documentElement.getAttribute("data-mmg-accent")).toBe("blue");
expect(window.localStorage.getItem("mmg-accent")).toBe("blue");
});
it("navigation flèche droite passe au preset suivant", async () => {
const user = userEvent.setup();
render(<ThemePicker />);
const synapse = screen.getByRole("radio", { name: /synapse/i });
synapse.focus();
await user.keyboard("{ArrowRight}");
const rose = screen.getByRole("radio", { name: /^Rose vif$/i });
expect(rose).toHaveAttribute("aria-checked", "true");
});
it("navigation flèche gauche revient au précédent (avec wrap)", async () => {
const user = userEvent.setup();
render(<ThemePicker />);
const synapse = screen.getByRole("radio", { name: /synapse/i });
synapse.focus();
await user.keyboard("{ArrowLeft}");
// Wrap : passe au dernier (slate)
const slate = screen.getByRole("radio", { name: /ardoise/i });
expect(slate).toHaveAttribute("aria-checked", "true");
});
it("Home / End naviguent aux extrêmes", async () => {
const user = userEvent.setup();
render(<ThemePicker />);
screen.getByRole("radio", { name: /synapse/i }).focus();
await user.keyboard("{End}");
expect(screen.getByRole("radio", { name: /ardoise/i })).toHaveAttribute("aria-checked", "true");
await user.keyboard("{Home}");
expect(screen.getByRole("radio", { name: /synapse/i })).toHaveAttribute("aria-checked", "true");
});
it("expose un radiogroup labelisé", () => {
render(<ThemePicker />);
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
});
it("n'a pas de violations axe-core", async () => {
const { container } = render(<ThemePicker />);
expect(await axe(container)).toHaveNoViolations();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { useRef, type KeyboardEvent } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
import { ACCENT_PRESETS, useAccent, type AccentName } from "./useAccent";
export type { AccentName };
export type ThemePickerProps = {
/** Légende lue par les lecteurs d'écran. Défaut : "Couleur d'accent". */
legend?: string;
/** Cache le bouton de réinitialisation au défaut Synapse. */
hideReset?: boolean;
className?: string;
};
/**
* ThemePicker — sélecteur de couleur d'accent utilisateur.
*
* Pattern radiogroup : roving tabindex, navigation flèches gauche/droite,
* sélection à Espace ou Entrée. Persiste l'accent choisi dans localStorage.
*
* Accessibilité :
* - role="radiogroup" + aria-labelledby
* - chaque pastille : role="radio" + aria-checked
* - focus-visible géré, contraste validé sur fonds light & dark
* - le label texte ("Rose Synapse", "Bleu", …) est accessible aux lecteurs
* d'écran via aria-label, pas seulement la couleur (RGAA 9 — couleur seule)
*/
export function ThemePicker({ legend = "Couleur d'accent", hideReset, className }: ThemePickerProps) {
const { accent, setAccent, reset } = useAccent();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const headingId = "mmg-theme-picker-heading";
const focusIndex = (i: number) => {
const next = (i + ACCENT_PRESETS.length) % ACCENT_PRESETS.length;
refs.current[next]?.focus();
};
const onKey = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
focusIndex(idx + 1);
setAccent(ACCENT_PRESETS[(idx + 1) % ACCENT_PRESETS.length].name);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
focusIndex(idx - 1);
setAccent(ACCENT_PRESETS[(idx - 1 + ACCENT_PRESETS.length) % ACCENT_PRESETS.length].name);
break;
case "Home":
e.preventDefault();
focusIndex(0);
setAccent(ACCENT_PRESETS[0].name);
break;
case "End":
e.preventDefault();
focusIndex(ACCENT_PRESETS.length - 1);
setAccent(ACCENT_PRESETS[ACCENT_PRESETS.length - 1].name);
break;
}
};
return (
<div className={cx("mmg-theme-picker", className)}>
<div id={headingId} className="mmg-theme-picker__legend">
{legend}
</div>
<div role="radiogroup" aria-labelledby={headingId} className="mmg-theme-picker__group">
{ACCENT_PRESETS.map((preset, i) => {
const checked = preset.name === accent;
return (
<button
key={preset.name}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={preset.label}
tabIndex={checked ? 0 : -1}
className={cx("mmg-theme-picker__swatch", checked && "mmg-theme-picker__swatch--selected")}
style={{ "--mmg-swatch-color": preset.sample } as Record<string, string>}
onClick={() => setAccent(preset.name)}
onKeyDown={(e) => onKey(e, i)}
>
<span className="mmg-theme-picker__swatch-color" aria-hidden />
{checked && (
<span className="mmg-theme-picker__swatch-check" aria-hidden>
<Icon name="check-line" size="sm" />
</span>
)}
<span className="mmg-u-sr-only">{preset.label}</span>
</button>
);
})}
</div>
{!hideReset && accent !== "synapse" && (
<button type="button" className="mmg-theme-picker__reset" onClick={reset}>
Réinitialiser au défaut
</button>
)}
</div>
);
}
+392
View File
@@ -0,0 +1,392 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
type Severity = "info" | "success" | "warning" | "danger";
export type ToastInput = {
title: ReactNode;
description?: ReactNode;
severity?: Severity;
/** Durée avant auto-dismiss (ms). 0 = persistant. Par défaut 5000. */
duration?: number;
/** Action optionnelle dans le toast. */
action?: { label: ReactNode; onClick: () => void };
};
type ToastInternal = ToastInput & { id: string; createdAt: number };
type Ctx = {
toast: (t: ToastInput) => string;
dismiss: (id: string) => void;
clear: () => void;
};
const ToastContext = createContext<Ctx | null>(null);
const SEV_ICON: Record<Severity, IconName> = {
info: "information-fill",
success: "checkbox-circle-fill",
warning: "alert-fill",
danger: "error-warning-fill",
};
export type ToastProviderProps = {
children: ReactNode;
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
/** Nombre max conservé en mémoire avant écrêtage du plus ancien. Défaut 5. */
max?: number;
/** Nombre visible avant collapse en pile (Sonner-style). Défaut 3. */
visibleCount?: number;
/** Durée par défaut (ms) avant auto-dismiss si non précisé. */
defaultDuration?: number;
/** Gap entre toasts en mode expanded (px). Défaut 12. */
gap?: number;
/** Offset entre toasts en pile collapsée (px par cran). Défaut 14. */
stackOffset?: number;
};
const DEFAULT_GAP = 12;
const DEFAULT_STACK_OFFSET = 14;
const SCALE_PER_INDEX = 0.045;
/**
* ToastProvider — système de notifications empilables (Sonner-style).
*
* Architecture :
* - Chaque toast est en `position: absolute`, ancré bottom: 0 (positions
* bottom-*) ou top: 0 (positions top-*).
* - Les heights sont mesurées via ResizeObserver et exposées en CSS vars.
* - Au repos : translateY = -stackOffset × index (collapsed-y),
* scale = 1 - 0.045 × index. Front-most reste pleine taille.
* - Au hover/focus : translateY = -(somme des heights précédentes + gap × i)
* (expanded-y), scale = 1.
* - La hauteur du region s'ajuste : front-most height (collapsed) ou
* somme totale (expanded), pour ne pas voler de clics au contenu.
*
* A11y :
* - aria-live="polite" — les toasts s'annoncent sans interrompre.
* - Severity "danger" → role="alert" (urgent), sinon role="status".
* - Bouton fermer focusable, label complet ("Fermer la notification").
* - Pause des timers au hover/focus, reprise au mouseleave/blur.
*/
export function ToastProvider({
children,
position = "bottom-right",
max = 5,
visibleCount = 3,
defaultDuration = 5000,
gap = DEFAULT_GAP,
stackOffset = DEFAULT_STACK_OFFSET,
}: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastInternal[]>([]);
const [expanded, setExpanded] = useState(false);
const [heights, setHeights] = useState<Record<string, number>>({});
const timers = useRef(new Map<string, number>());
const remainingTimes = useRef(new Map<string, number>());
const pausedRef = useRef(false);
const itemRefs = useRef(new Map<string, HTMLLIElement>());
const observerRef = useRef<ResizeObserver | null>(null);
const isTop = position.startsWith("top");
// — Lifecycle ————————————————————————————————————————
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
const timer = timers.current.get(id);
if (timer) {
window.clearTimeout(timer);
timers.current.delete(id);
}
remainingTimes.current.delete(id);
itemRefs.current.delete(id);
setHeights((h) => {
if (!(id in h)) return h;
const next = { ...h };
delete next[id];
return next;
});
}, []);
const scheduleDismiss = useCallback(
(id: string, duration: number) => {
if (duration <= 0) return;
const t = window.setTimeout(() => dismiss(id), duration);
timers.current.set(id, t);
remainingTimes.current.set(id, duration);
},
[dismiss],
);
const toast = useCallback(
(input: ToastInput) => {
const id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const duration = input.duration ?? defaultDuration;
const item: ToastInternal = { ...input, id, createdAt: Date.now() };
setToasts((prev) => {
const next = [...prev, item];
return next.length > max ? next.slice(-max) : next;
});
// Si la pile est déjà hover/focused, on enchaîne en pause aussi.
if (!pausedRef.current) scheduleDismiss(id, duration);
else remainingTimes.current.set(id, duration);
return id;
},
[defaultDuration, max, scheduleDismiss],
);
const clear = useCallback(() => {
timers.current.forEach((t) => window.clearTimeout(t));
timers.current.clear();
remainingTimes.current.clear();
setToasts([]);
}, []);
const pauseAll = useCallback(() => {
if (pausedRef.current) return;
pausedRef.current = true;
for (const [id, timerId] of timers.current.entries()) {
window.clearTimeout(timerId);
timers.current.delete(id);
}
}, []);
const resumeAll = useCallback(() => {
if (!pausedRef.current) return;
pausedRef.current = false;
for (const [id, remaining] of remainingTimes.current.entries()) {
const t = window.setTimeout(() => dismiss(id), remaining);
timers.current.set(id, t);
}
}, [dismiss]);
useEffect(
() => () => {
timers.current.forEach((t) => window.clearTimeout(t));
observerRef.current?.disconnect();
},
[],
);
// — Mesure des hauteurs via ResizeObserver ————————————
// Indispensable pour calculer l'expanded-y précisément (somme des heights
// précédentes + gap × index). Évite tout layout shift visible.
useLayoutEffect(() => {
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver((entries) => {
let mutated = false;
const updates: Record<string, number> = {};
for (const entry of entries) {
const el = entry.target as HTMLLIElement;
const id = el.dataset.toastId;
if (!id) continue;
const h = el.offsetHeight;
updates[id] = h;
mutated = true;
}
if (mutated) {
setHeights((prev) => {
let changed = false;
const next = { ...prev };
for (const [id, h] of Object.entries(updates)) {
if (next[id] !== h) {
next[id] = h;
changed = true;
}
}
return changed ? next : prev;
});
}
});
observerRef.current = ro;
for (const el of itemRefs.current.values()) ro.observe(el);
return () => ro.disconnect();
}, []);
// Ré-observer lors de l'ajout/retrait
useLayoutEffect(() => {
const ro = observerRef.current;
if (!ro) return;
for (const el of itemRefs.current.values()) ro.observe(el);
}, [toasts.length]);
// Setter de ref qui (ré-)observe le node
const setItemRef = useCallback(
(id: string) => (el: HTMLLIElement | null) => {
const ro = observerRef.current;
if (el) {
itemRefs.current.set(id, el);
ro?.observe(el);
// Capture immédiate pour éviter un flash au premier render
const h = el.offsetHeight;
if (h > 0) {
setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
}
} else {
const old = itemRefs.current.get(id);
if (old && ro) ro.unobserve(old);
itemRefs.current.delete(id);
}
},
[],
);
// — Ordre + offsets calculés ————————————————————————
// ordered[i].stackIndex : 0 = front-most (le plus récent).
// Pour bottom-* : DOM order = ancien → récent (column-reverse côté CSS
// n'est plus utilisé maintenant qu'on est en absolute). Le récent est
// au-dessus visuellement → stackIndex 0 = dernier ajouté.
const ordered = useMemo(() => {
const list = toasts.map((t, i) => ({
...t,
stackIndex: toasts.length - 1 - i,
}));
// Les offsets dépendent des heights (mesurés) :
// - collapsedOffset = stackOffset × stackIndex (signe selon position)
// - expandedOffset = somme des heights des toasts plus récents (stackIndex < self.stackIndex) + gap × count
return list.map((t) => {
const collapsed = stackOffset * t.stackIndex;
let expanded = 0;
for (let j = 0; j < t.stackIndex; j++) {
const sibling = list.find((o) => o.stackIndex === j);
if (!sibling) continue;
const h = heights[sibling.id] ?? 64; // fallback raisonnable avant mesure
expanded += h + gap;
}
return { ...t, collapsed, expanded };
});
}, [toasts, heights, gap, stackOffset]);
// Hauteur du region : front-most en collapsed, somme en expanded.
const regionHeight = useMemo(() => {
if (toasts.length === 0) return 0;
if (expanded) {
return ordered.reduce((sum, t) => sum + (heights[t.id] ?? 64), 0) + gap * (toasts.length - 1);
}
const front = ordered.find((t) => t.stackIndex === 0);
return heights[front?.id ?? ""] ?? 64;
}, [ordered, heights, gap, expanded, toasts.length]);
const ctx = useMemo<Ctx>(() => ({ toast, dismiss, clear }), [toast, dismiss, clear]);
return (
<ToastContext.Provider value={ctx}>
{children}
<ol
className={cx("mmg-toast-region", `mmg-toast-region--${position}`)}
data-position={position}
data-expanded={expanded || undefined}
aria-live="polite"
aria-atomic="false"
aria-label="Notifications"
style={{ height: regionHeight ? `${regionHeight}px` : undefined }}
onMouseEnter={() => {
setExpanded(true);
pauseAll();
}}
onMouseLeave={() => {
setExpanded(false);
resumeAll();
}}
onFocus={() => {
setExpanded(true);
pauseAll();
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setExpanded(false);
resumeAll();
}
}}
>
{ordered.map((t) => {
const isHidden = !expanded && t.stackIndex >= visibleCount;
// Signes selon position : bottom-* → translateY négatif (vers haut),
// top-* → translateY positif (vers bas).
const sign = isTop ? 1 : -1;
const collapsedY = sign * t.collapsed;
const expandedY = sign * t.expanded;
return (
<li
key={t.id}
ref={setItemRef(t.id)}
data-toast-id={t.id}
data-severity={t.severity ?? "info"}
role={t.severity === "danger" ? "alert" : "status"}
aria-hidden={isHidden ? true : undefined}
className="mmg-toast"
data-stack-index={t.stackIndex}
data-front={t.stackIndex === 0 || undefined}
style={
{
"--mmg-toast-collapsed-y": `${collapsedY}px`,
"--mmg-toast-expanded-y": `${expandedY}px`,
"--mmg-toast-scale": `${1 - SCALE_PER_INDEX * t.stackIndex}`,
} as Record<string, string>
}
>
<Icon
name={SEV_ICON[t.severity ?? "info"]}
className="mmg-toast__icon"
size="sm"
style={{
color:
t.severity === "success"
? "var(--mmg-color-success)"
: t.severity === "warning"
? "var(--mmg-color-warning)"
: t.severity === "danger"
? "var(--mmg-color-danger)"
: "var(--mmg-color-info)",
}}
/>
<div className="mmg-toast__body">
<div className="mmg-toast__title">{t.title}</div>
{t.description && <div className="mmg-toast__desc">{t.description}</div>}
{t.action && (
<button
type="button"
className="mmg-toast__action"
onClick={() => {
t.action!.onClick();
dismiss(t.id);
}}
>
{t.action.label}
</button>
)}
</div>
<button
type="button"
className="mmg-toast__close"
aria-label="Fermer la notification"
onClick={() => dismiss(t.id)}
>
<Icon name="close-line" size="sm" />
</button>
</li>
);
})}
</ol>
</ToastContext.Provider>
);
}
/** Hook d'accès au système de toasts. Doit être utilisé sous un ToastProvider. */
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx)
throw new Error(
"useToast doit être utilisé dans un <ToastProvider>. Wrappe ton App.",
);
return ctx;
}
+81
View File
@@ -0,0 +1,81 @@
import { type ReactNode } from "react";
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type ToggleGroupItem = {
value: string;
label: ReactNode;
icon?: IconName;
/** Affiche uniquement l'icône (label devient aria-label). */
iconOnly?: boolean;
disabled?: boolean;
};
type ToggleGroupCommonProps = {
items: ToggleGroupItem[];
size?: "sm" | "md" | "lg";
variant?: "outline" | "solid";
disabled?: boolean;
/** Label accessible du groupe. */
ariaLabel?: string;
className?: string;
};
export type ToggleGroupSingleProps = ToggleGroupCommonProps & {
type: "single";
value: string | undefined;
onValueChange: (value: string) => void;
};
export type ToggleGroupMultipleProps = ToggleGroupCommonProps & {
type: "multiple";
value: string[];
onValueChange: (value: string[]) => void;
};
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
/**
* ToggleGroup — groupe de boutons toggle (single ou multiple).
*
* Différence avec SegmentedControl : ToggleGroup peut être en mode multiple
* (plusieurs boutons actifs simultanément) et ne demande pas de label
* obligatoire au-dessus.
*
* Wrapper Radix : roving tabindex, navigation flèches, aria-pressed.
*/
export function ToggleGroup(props: ToggleGroupProps) {
const { items, size = "md", variant = "outline", disabled, ariaLabel, className } = props;
const root = (
<RadixToggleGroup.Root
type={props.type as "single"}
value={props.value as string}
onValueChange={props.onValueChange as (v: string) => void}
disabled={disabled}
aria-label={ariaLabel}
className={cx(
"mmg-toggle-group",
`mmg-toggle-group--${size}`,
`mmg-toggle-group--${variant}`,
className,
)}
>
{items.map((item) => (
<RadixToggleGroup.Item
key={item.value}
value={item.value}
disabled={item.disabled}
aria-label={item.iconOnly && typeof item.label === "string" ? item.label : undefined}
className="mmg-toggle-group__item"
>
{item.icon && <Icon name={item.icon} size={size === "lg" ? "md" : "sm"} />}
{!item.iconOnly && <span>{item.label}</span>}
</RadixToggleGroup.Item>
))}
</RadixToggleGroup.Root>
);
return root;
}
export { RadixToggleGroup as ToggleGroupPrimitive };
+66
View File
@@ -0,0 +1,66 @@
import { type ReactNode, type ComponentProps } from "react";
import * as RadixTooltip from "@radix-ui/react-tooltip";
import { cx } from "./utils";
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
export type TooltipProps = {
content: ReactNode;
children: ReactNode;
placement?: TooltipPlacement;
/** Délai avant apparition (ms). */
delay?: number;
/** Décalage par rapport à la cible (px). Défaut 8. */
sideOffset?: number;
className?: string;
};
/**
* Tooltip — wrapper Radix.
*
* - Positionnement intelligent (auto-flip si pas de place) via Floating UI.
* - Focus / hover / Escape gérés par Radix.
* - Respecte prefers-reduced-motion.
* - Pour usage avancé : utiliser TooltipPrimitive.* directement.
*
* Exige un <TooltipProvider> à la racine de l'app (déjà inclus dans
* AppShell). Sinon wrap localement avec <Tooltip.Provider>.
*/
export function Tooltip({
content,
children,
placement = "top",
delay = 200,
sideOffset = 8,
className,
}: TooltipProps) {
return (
<RadixTooltip.Root delayDuration={delay}>
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
<RadixTooltip.Portal>
<RadixTooltip.Content
side={placement}
sideOffset={sideOffset}
collisionPadding={8}
className={cx("mmg-tooltip", className)}
>
{content}
<RadixTooltip.Arrow className="mmg-tooltip__arrow" />
</RadixTooltip.Content>
</RadixTooltip.Portal>
</RadixTooltip.Root>
);
}
export type TooltipProviderProps = ComponentProps<typeof RadixTooltip.Provider>;
/**
* Provider à mettre une fois à la racine de l'app pour partager les délais
* et la file d'attente des tooltips (skipDelayDuration).
*/
export function TooltipProvider(props: TooltipProviderProps) {
return <RadixTooltip.Provider delayDuration={200} skipDelayDuration={300} {...props} />;
}
/** Sous-primitives Radix réexportées pour usage avancé (compound). */
export { RadixTooltip as TooltipPrimitive };
+93
View File
@@ -0,0 +1,93 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
import { Avatar, type AvatarStatus } from "./Avatar";
export type UserCardProps = {
name: ReactNode;
/** Rôle / poste / sous-titre. */
role?: ReactNode;
/** URL d'image (sinon initiales générées depuis name). */
src?: string;
initials?: string;
status?: AvatarStatus;
/** Métadonnée optionnelle (e-mail, équipe…). */
meta?: ReactNode;
/** Slot d'actions à droite (Button, Menu trigger…). */
actions?: ReactNode;
/** Densité visuelle. */
size?: "sm" | "md" | "lg";
/** Si fourni, rend la carte cliquable (entièrement). */
href?: string;
onClick?: () => void;
className?: string;
};
function autoInitials(name: ReactNode) {
if (typeof name !== "string") return undefined;
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
}
/**
* UserCard — carte utilisateur compacte (avatar + identité + actions).
*
* Pattern Slack/Linear/Notion : une rangée alignée verticalement avec
* avatar à gauche, identité au milieu, actions à droite. Cliquable en
* entier si href ou onClick.
*/
export function UserCard({
name,
role,
src,
initials,
status,
meta,
actions,
size = "md",
href,
onClick,
className,
}: UserCardProps) {
const Comp: "a" | "button" | "div" = href ? "a" : onClick ? "button" : "div";
const interactive = Boolean(href || onClick);
const avatarSize = size === "sm" ? "sm" : size === "lg" ? "lg" : "md";
const inner = (
<>
<Avatar
src={src}
initials={initials ?? autoInitials(name)}
alt={typeof name === "string" ? name : undefined}
status={status}
size={avatarSize}
/>
<div className="mmg-user-card__body">
<div className="mmg-user-card__name">{name}</div>
{role && <div className="mmg-user-card__role">{role}</div>}
{meta && <div className="mmg-user-card__meta">{meta}</div>}
</div>
{actions && <div className="mmg-user-card__actions">{actions}</div>}
</>
);
return (
<Comp
className={cx(
"mmg-user-card",
size !== "md" && `mmg-user-card--${size}`,
interactive && "mmg-user-card--interactive",
className,
)}
href={href}
type={Comp === "button" ? "button" : undefined}
onClick={onClick}
>
{inner}
</Comp>
);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* cva — class-variance-authority (implémentation locale légère).
*
* Inspiré de https://cva.style — pas de dépendance externe pour garder
* le bundle DSMMG minimal. Permet de définir des variants typés et
* compositionnés pour un composant.
*
* Usage :
*
* const buttonStyles = cva("mmg-btn", {
* variants: {
* variant: {
* primary: "mmg-btn--primary",
* tonal: "mmg-btn--tonal",
* ghost: "mmg-btn--ghost",
* },
* size: {
* sm: "mmg-btn--sm",
* md: "mmg-btn--md",
* lg: "mmg-btn--lg",
* },
* },
* defaultVariants: { variant: "primary", size: "md" },
* compoundVariants: [
* { variant: "ghost", size: "sm", class: "mmg-btn--ghost-sm-tweak" },
* ],
* });
*
* buttonStyles({ variant: "tonal", size: "lg" });
* // → "mmg-btn mmg-btn--tonal mmg-btn--lg"
*/
type VariantsMap = Record<string, Record<string, string>>;
/**
* Map JS object keys back to their external prop type. Les clés "true"/"false"
* sont des strings côté objet mais on les expose comme `boolean` côté API
* pour que `loading={true}` type-check sans cast.
*/
type ExternalKey<K> = K extends "true"
? boolean
: K extends "false"
? boolean
: K;
type VariantPropsFor<V extends VariantsMap> = {
[K in keyof V]?: ExternalKey<keyof V[K]>;
};
type CompoundVariant<V extends VariantsMap> = VariantPropsFor<V> & {
class?: string;
className?: string;
};
export type CvaConfig<V extends VariantsMap> = {
variants?: V;
defaultVariants?: VariantPropsFor<V>;
compoundVariants?: CompoundVariant<V>[];
};
export type CvaResult<V extends VariantsMap> = {
(props?: VariantPropsFor<V> & { className?: string }): string;
/** Pour debug/instrumentation */
variants: V | undefined;
};
export function cva<V extends VariantsMap>(
base: string,
config: CvaConfig<V> = {},
): CvaResult<V> {
const { variants, defaultVariants = {}, compoundVariants = [] } = config;
const fn = (props: (VariantPropsFor<V> & { className?: string }) | undefined = {}) => {
const classes: string[] = [base];
const merged = { ...defaultVariants, ...props } as VariantPropsFor<V> & {
className?: string;
};
if (variants) {
for (const variantKey in variants) {
const value = merged[variantKey] as string | undefined;
if (value != null) {
const variantValueClass = variants[variantKey][value as string];
if (variantValueClass) classes.push(variantValueClass);
}
}
}
for (const cv of compoundVariants) {
const matches = Object.entries(cv).every(([k, v]) => {
if (k === "class" || k === "className") return true;
return (merged as any)[k] === v;
});
if (matches) {
const cn = cv.class ?? cv.className;
if (cn) classes.push(cn);
}
}
if (props?.className) classes.push(props.className);
return classes.filter(Boolean).join(" ");
};
return Object.assign(fn, { variants });
}
/**
* Type helper pour extraire les props variants d'un cva ; à utiliser
* dans les composants pour exposer des types stricts :
*
* type ButtonVariants = VariantProps<typeof buttonStyles>;
*/
export type VariantProps<T extends (...args: any[]) => string> =
T extends (props?: infer P & { className?: string }) => string
? Omit<P, "className">
: never;
+174
View File
@@ -0,0 +1,174 @@
/* ════════════════════════════════════════════════════════════════
@managemate/react — entry barrel
Exports nommés pour permettre tree-shaking.
Chaque composant vit dans son propre fichier (sideEffects: false).
════════════════════════════════════════════════════════════════ */
// — Primitives ——————————————————————————————
export { Slot, type SlotProps } from "./Slot";
export { cva, type VariantProps, type CvaConfig, type CvaResult } from "./cva";
export { useFocusTrap } from "./useFocusTrap";
export { cx } from "./utils";
// — Shortcut system ————————————————————————
export {
ShortcutProvider,
useShortcut,
useShortcutList,
ShortcutCheatsheet,
ShortcutKeys,
type ShortcutEntry,
type ShortcutOptions,
} from "./Shortcut";
// — Icon / Pictogram ——————————————————————————
export { Icon, type IconProps, type IconName } from "./Icon";
export { IconBlock, type IconBlockProps } from "./IconBlock";
export {
Pictogram,
PICTOGRAM_NAMES,
type PictogramProps,
type PictogramName,
} from "./Pictogram";
// — Button ——————————————————————————————
export { Button, type ButtonProps, type ButtonVariants } from "./Button";
// — Forms ——————————————————————————————
export {
Field,
Input,
Textarea,
Select,
Checkbox,
Radio,
Switch,
type InputProps,
type TextareaProps,
type SelectProps,
type SelectOption,
} from "./Form";
// — Layout ——————————————————————————————
export {
Container,
Section,
Stack,
Inline,
Card,
Tile,
Hero,
type ContainerProps,
type SectionProps,
type TileProps,
} from "./Layout";
// — Feedback ——————————————————————————————
export {
Alert,
Notice,
Badge,
Modal,
ToastRegion,
Spinner,
type ToastItem,
} from "./Feedback";
// — Chrome (Header / Footer / Nav / Shell) ————————
export { Header, type HeaderProps, type HeaderNavItem, type NavItemSimple, type NavItemMega, type MegaMenuLink, type MegaMenuColumn, type MegaMenuFeatured } from "./Header";
export { Footer, type FooterProps, type FooterColumn } from "./Footer";
export { Breadcrumb, type BreadcrumbProps } from "./Breadcrumb";
export { Tabs, type TabsProps, type TabItem } from "./Tabs";
export { Pagination, type PaginationProps } from "./Pagination";
export { AppShell, Sidebar, AppMain, Topbar, type SidebarProps, type SidebarItem, type SidebarSection, type TopbarProps } from "./AppShell";
export { Avatar, type AvatarProps, type AvatarSize, type AvatarStatus, type AvatarShape } from "./Avatar";
export { AvatarGroup, type AvatarGroupProps } from "./AvatarGroup";
export { UserCard, type UserCardProps } from "./UserCard";
export { ProfileHeader, type ProfileHeaderProps } from "./ProfileHeader";
export { MetricCard, type MetricCardProps, type MetricTrend } from "./MetricCard";
export { PricingCard, type PricingCardProps, type PricingFeature } from "./PricingCard";
export { FeatureCard, type FeatureCardProps } from "./FeatureCard";
export { Text, Display, Eyebrow, Lead, type TextProps, type TextVariant, type TextElement } from "./Text";
export { Stat, type StatProps } from "./Stat";
export { SkipLink, type SkipLinkProps } from "./SkipLink";
// — Advanced (chacun dans son propre fichier) ————————
export { Stepper, type StepperProps } from "./Stepper";
export { Accordion, type AccordionProps } from "./Accordion";
export { Tag, type TagProps } from "./Tag";
export { Progress, type ProgressProps } from "./Progress";
export { Drawer, type DrawerProps } from "./Drawer";
export { Fab, type FabProps } from "./Fab";
export { Highlight, type HighlightProps } from "./Highlight";
export { Quote, type QuoteProps } from "./Quote";
export { Callout, type CalloutProps } from "./Callout";
export { Banner, type BannerProps } from "./Banner";
export { FileUpload, type FileUploadProps } from "./FileUpload";
export { SearchBar, type SearchBarProps } from "./SearchBar";
// — Overlays (Radix-backed) ——————————————————————
export {
Tooltip,
TooltipProvider,
TooltipPrimitive,
type TooltipProps,
type TooltipPlacement,
type TooltipProviderProps,
} from "./Tooltip";
export { Popover, PopoverPrimitive, type PopoverProps } from "./Popover";
export { Menu, MenuPrimitive, type MenuItem, type MenuProps } from "./Menu";
export { Dialog, DialogPrimitive, type DialogProps } from "./Dialog";
export { ConfirmDialog, type ConfirmDialogProps } from "./ConfirmDialog";
export { Combobox, type ComboboxProps, type ComboboxOption } from "./Combobox";
export { Sheet, SheetPrimitive, type SheetProps, type SheetSide, type SheetSize } from "./Sheet";
export { HoverCard, HoverCardPrimitive, type HoverCardProps } from "./HoverCard";
export { ContextMenu, ContextMenuPrimitive, type ContextMenuProps, type ContextMenuItem } from "./ContextMenu";
export { Slider, SliderPrimitive, type SliderProps } from "./Slider";
export { ToggleGroup, ToggleGroupPrimitive, type ToggleGroupProps, type ToggleGroupItem } from "./ToggleGroup";
// — DataTable / Tables / Pickers ——————————————
export {
DataTable,
type DataTableProps,
type DataTableColumn,
} from "./DataTable";
export {
CommandMenu,
useCommandMenu,
type Command,
} from "./CommandMenu";
export { DatePicker, type DatePickerProps } from "./DatePicker";
export {
DateRangePicker,
type DateRangePickerProps,
type DateRange,
} from "./DateRangePicker";
// — Toast (provider + hook) ——————————————————————
export { ToastProvider, useToast, type ToastInput } from "./Toast";
// — EmptyState ——————————————————————————————
export { EmptyState, type EmptyStateProps } from "./EmptyState";
// — Extras ——————————————————————————————
export {
SegmentedControl,
DescriptionList,
Sparkline,
Kbd,
type SegmentedItem,
type DListItem,
} from "./Extras";
// — Article ——————————————————————————————
export { ArticlePage, type ArticlePageProps } from "./ArticlePage";
export { ArticleHeader, type ArticleHeaderProps } from "./ArticleHeader";
export { ArticleAside, type ArticleAsideProps } from "./ArticleAside";
export { ArticleFooter, type ArticleFooterProps } from "./ArticleFooter";
export { ArticleCallout, type ArticleCalloutProps } from "./ArticleCallout";
export { ArticleTOC, type ArticleTOCProps } from "./ArticleTOC";
// — Theming (Phase 2) ——————————————————————————
export { ThemePicker, type ThemePickerProps, type AccentName } from "./ThemePicker";
export { useAccent, applyAccent, ACCENT_PRESETS } from "./useAccent";
export { useTheme, applyTheme, type ThemeMode } from "./useTheme";
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, expect } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
import * as axeMatchers from "vitest-axe/matchers";
expect.extend(matchers);
expect.extend(axeMatchers);
afterEach(() => {
cleanup();
});
// Polyfills attendus par certains composants
if (!("ResizeObserver" in globalThis)) {
// @ts-expect-error — minimal stub for jsdom
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
if (!("IntersectionObserver" in globalThis)) {
// @ts-expect-error — minimal stub for jsdom
globalThis.IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
};
}
// matchMedia polyfill (utilisé par Radix UI pour reduced-motion)
if (typeof window !== "undefined" && !window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}),
});
}
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useState } from "react";
export type AccentName =
| "synapse"
| "rose"
| "blue"
| "violet"
| "green"
| "amber"
| "red"
| "cyan"
| "slate";
export const ACCENT_PRESETS: { name: AccentName; label: string; sample: string }[] = [
{ name: "synapse", label: "Rose Synapse", sample: "#D12B6A" },
{ name: "rose", label: "Rose vif", sample: "#E11D48" },
{ name: "blue", label: "Bleu", sample: "#2563EB" },
{ name: "violet", label: "Violet", sample: "#7C3AED" },
{ name: "green", label: "Vert", sample: "#0E9F6E" },
{ name: "amber", label: "Ambre", sample: "#D97706" },
{ name: "red", label: "Rouge", sample: "#DC2626" },
{ name: "cyan", label: "Cyan", sample: "#0891B2" },
{ name: "slate", label: "Ardoise", sample: "#475569" },
];
const STORAGE_KEY = "mmg-accent";
const DEFAULT_ACCENT: AccentName = "synapse";
/**
* Applique un accent au document. Pose [data-mmg-accent="..."] sur <html>.
* Persiste optionnellement dans localStorage. À appeler tôt (avant React)
* pour éviter le flash visuel non désiré.
*/
export function applyAccent(accent: AccentName, persist = true) {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (accent === DEFAULT_ACCENT) {
root.removeAttribute("data-mmg-accent");
} else {
root.setAttribute("data-mmg-accent", accent);
}
if (persist && typeof window !== "undefined") {
try {
window.localStorage.setItem(STORAGE_KEY, accent);
} catch {
// localStorage may be unavailable (private mode, server) — silently ignore
}
}
}
/**
* Récupère l'accent stocké côté navigateur, ou le défaut si rien n'est posé.
* Safe SSR : retourne DEFAULT_ACCENT si window n'existe pas.
*/
export function readStoredAccent(): AccentName {
if (typeof window === "undefined") return DEFAULT_ACCENT;
try {
const v = window.localStorage.getItem(STORAGE_KEY);
if (v && ACCENT_PRESETS.some((p) => p.name === v)) return v as AccentName;
} catch {
/* noop */
}
return DEFAULT_ACCENT;
}
export type UseAccentReturn = {
accent: AccentName;
setAccent: (a: AccentName) => void;
reset: () => void;
};
/**
* Hook pour lire et changer l'accent global. Synchronise localStorage et
* l'attribut [data-mmg-accent] sur <html>. Le composant ThemePicker
* consomme ce hook.
*/
export function useAccent(): UseAccentReturn {
const [accent, setAccentState] = useState<AccentName>(() => readStoredAccent());
// Applique au mount pour SSR-safe (l'attribut peut être absent côté serveur).
useEffect(() => {
applyAccent(accent, false);
}, [accent]);
const setAccent = useCallback((a: AccentName) => {
setAccentState(a);
applyAccent(a, true);
}, []);
const reset = useCallback(() => {
setAccent(DEFAULT_ACCENT);
}, [setAccent]);
return { accent, setAccent, reset };
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useRef } from "react";
const FOCUSABLE = [
'a[href]:not([disabled])',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(",");
/**
* Piège le focus à l'intérieur du conteneur tant que `active` est vrai.
* - Tab cycle vers le premier élément après le dernier (et inversement).
* - Au montage : focus sur le premier élément focusable.
* - Au démontage : restitution du focus à l'élément précédemment actif.
*
* Conforme WCAG SC 2.4.3 (Focus Order) et WAI-ARIA Authoring Practices
* pour les dialogues modaux.
*/
export function useFocusTrap<T extends HTMLElement>(active: boolean) {
const ref = useRef<T>(null);
useEffect(() => {
if (!active) return;
const container = ref.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus initial : premier focusable, sinon le conteneur lui-même
const focusables = container.querySelectorAll<HTMLElement>(FOCUSABLE);
const first = focusables[0] ?? container;
// tabindex sur le conteneur pour qu'il puisse recevoir le focus
if (first === container && !container.hasAttribute("tabindex")) {
container.setAttribute("tabindex", "-1");
}
// setTimeout pour laisser l'animation d'ouverture se poser
const t = window.setTimeout(() => first.focus({ preventScroll: false }), 30);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusables = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null);
if (focusables.length === 0) {
e.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
window.clearTimeout(t);
document.removeEventListener("keydown", onKeyDown);
// Restitution du focus
if (previouslyFocused && document.contains(previouslyFocused)) {
previouslyFocused.focus({ preventScroll: true });
}
};
}, [active]);
return ref;
}
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from "react";
export type ThemeMode = "light" | "dark" | "system";
const STORAGE_KEY = "mmg-theme";
export function applyTheme(mode: ThemeMode, persist = true) {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (mode === "system") {
root.removeAttribute("data-mmg-theme");
} else {
root.setAttribute("data-mmg-theme", mode);
}
if (persist && typeof window !== "undefined") {
try {
window.localStorage.setItem(STORAGE_KEY, mode);
} catch {
/* noop */
}
}
}
function readStoredTheme(): ThemeMode {
if (typeof window === "undefined") return "system";
try {
const v = window.localStorage.getItem(STORAGE_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {
/* noop */
}
return "system";
}
export function useTheme() {
const [theme, setThemeState] = useState<ThemeMode>(() => readStoredTheme());
useEffect(() => {
applyTheme(theme, false);
}, [theme]);
const setTheme = useCallback((t: ThemeMode) => {
setThemeState(t);
applyTheme(t, true);
}, []);
const toggle = useCallback(() => {
// light → dark → system → light
setThemeState((prev) => {
const next: ThemeMode = prev === "light" ? "dark" : prev === "dark" ? "system" : "light";
applyTheme(next, true);
return next;
});
}, []);
return { theme, setTheme, toggle };
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Concatène des classNames en filtrant les falsy.
*
* Accepte string, number (0/NaN ignorés), boolean (false ignoré),
* null/undefined, BigInt (0n ignoré, pour les contextes JSX modernes),
* et Record<string, boolean> (clés ajoutées si la valeur est truthy).
*
* Pratique avec les patterns `condition && "class"` qui produisent
* facilement des unions type-larges en TS strict.
*/
type ClassValue =
| string
| number
| bigint
| boolean
| null
| undefined
| Record<string, boolean | undefined>;
export function cx(...args: ClassValue[]): string {
const out: string[] = [];
for (const a of args) {
if (!a) continue;
if (typeof a === "string") {
out.push(a);
} else if (typeof a === "object") {
for (const k in a) if (a[k]) out.push(k);
}
}
return out.join(" ");
}