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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dinawo
2026-05-04 22:29:35 +02:00
parent 62317f2ad7
commit 133feff75d
69 changed files with 3433 additions and 7 deletions
+47
View File
@@ -0,0 +1,47 @@
---
name: 🐛 Bug
about: Signaler un bug du DSMMG
labels: bug
---
## Résumé
<!-- 1-2 phrases. -->
## Reproduction
Un repro CodeSandbox ou StackBlitz est idéal. Sinon, étapes :
1. Aller sur …
2. Faire …
3. Observer …
## Comportement attendu
<!-- Ce qui devrait se passer. -->
## Comportement observé
<!-- Ce qui se passe vraiment. Joindre captures / vidéos / logs console. -->
## Environnement
- Version DSMMG : <!-- ex. 0.2.0 -->
- Browser + version : <!-- ex. Chrome 130, Firefox 132, Safari 18 -->
- OS : <!-- ex. Windows 11, macOS 14, iOS 17 -->
- Mode : light / dark / system
- Accent : synapse / blue / … / custom
- Densité : comfortable / cozy / compact
- Reduced motion activé : oui / non
## Composant(s) concerné(s)
<!-- Button, DataTable, Tooltip, etc. -->
## A11y
- [ ] Reproductible au clavier ?
- [ ] Reproductible avec lecteur d'écran ?
- [ ] Régression de contraste ?
## Idée de cause / fix (optionnel)
+34
View File
@@ -0,0 +1,34 @@
---
name: ✨ Feature
about: Proposer une nouvelle fonctionnalité ou un nouveau composant
labels: feature
---
## Problème à résoudre
<!-- Quel besoin produit ? Quelle douleur actuelle ? -->
## Solution proposée
<!-- Décrire l'API, les variants, les cas d'usage. -->
## Composants existants couvrant partiellement
<!-- Y a-t-il un composant existant proche ? -->
## Références (autres DS)
<!-- Comment Radix / shadcn / Linear / Vercel / Stripe / Atlassian DS / IBM Carbon le font ? -->
## Accessibilité
<!-- Considérations a11y : ARIA roles, keyboard nav, contraste, RTL. -->
## Alternatives écartées
## Effort estimé
- [ ] S (≤ 1j)
- [ ] M (1 sem)
- [ ] L (2-3 sem — RFC requise)
- [ ] XL (1 mois+ — RFC + roadmap)
+69
View File
@@ -0,0 +1,69 @@
---
name: 📜 RFC
about: Proposition formelle pour un changement majeur
labels: rfc
---
> Une RFC est requise pour : nouveau composant non-trivial,
> breaking change sur l'API publique, refonte de tokens, nouvelle
> dépendance externe, changement architectural.
## Titre
RFC #XXXX — <!-- titre court -->
## Statut
- [ ] Draft
- [ ] In review
- [ ] Accepted
- [ ] Rejected
- [ ] Superseded by #...
## Auteur(s)
@…
## Date
YYYY-MM-DD
## Contexte
<!-- Pourquoi cette RFC ? Quel problème résout-elle ? Données utilisateur,
contraintes business, contraintes techniques. -->
## Proposition
<!-- La solution proposée. Code, schémas, exemples. -->
## API publique (si applicable)
```tsx
// avant
<OldComponent prop="x" />
// après
<NewComponent variant="x" />
```
## Alternatives écartées
<!-- Ce qu'on a considéré et pourquoi on l'a écarté. -->
## Coût migration
- Codemod possible : oui / non
- Effort consommateur estimé :
- Période de deprecation prévue :
## Risques
- A11y :
- Performance :
- DX :
- Lock-in / dépendances :
## Décision
<!-- À remplir après discussion. Date + résumé des arguments. -->
+45
View File
@@ -0,0 +1,45 @@
## Résumé
<!-- 1-2 phrases. Le "pourquoi" plus que le "quoi" — le diff montre déjà le quoi. -->
## Changements
- [ ] Ajout — nouveau composant / token / variant
- [ ] Fix — bugfix
- [ ] Refacto — pas de surface publique modifiée
- [ ] Doc — uniquement docs / stories / commentaires
- [ ] Breaking — change l'API publique (préciser ci-dessous)
### Détail des breaking changes (si applicable)
<!-- Lister précisément ce qui change, avec exemples avant/après. -->
## Tests
- [ ] Tests unitaires ajoutés / mis à jour
- [ ] Stories Storybook ajoutées / mises à jour
- [ ] Test axe-core (pas de violation a11y)
- [ ] Test clavier (Tab, Esc, flèches, Enter, Espace)
- [ ] Testé en dark mode
- [ ] Testé sur les 9 presets accent (au moins synapse + un autre)
- [ ] Testé en zoom 200 % / reflow 320px
- [ ] Testé avec `prefers-reduced-motion: reduce`
- [ ] Lint contraste WCAG passe (`pnpm lint:contrast`)
## Documentation
- [ ] Page MDX du composant dans `docs/` (anatomie, props, do/don't, a11y)
- [ ] Story Storybook avec autodocs
- [ ] CHANGELOG via Changesets (`pnpm changeset`)
## Captures / preuves visuelles
<!-- Avant / après si modification visuelle. -->
## Checklist finale
- [ ] J'ai lu [CONTRIBUTING.md](../CONTRIBUTING.md)
- [ ] Mon code suit les conventions de naming (`mmg-*`, `--mmg-color-*`)
- [ ] Aucun hex en dur — uniquement des tokens
- [ ] Aucun `--mmg-color-synapse-*` direct dans un composant
- [ ] CI verte
+16
View File
@@ -59,6 +59,22 @@ jobs:
- name: Build Storybook
run: pnpm --filter storybook build
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Visual regression (Playwright)
run: pnpm test:visual
env:
CI: true
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
retention-days: 14
- name: Bundle size budget
run: pnpm size
+5
View File
@@ -37,6 +37,11 @@ storybook-static
# Test artifacts
coverage
.nyc_output
playwright-report
playwright/.cache
test-results
e2e/__screenshots__/*-actual.png
e2e/__screenshots__/*-diff.png
# Demo specifics
demo/_dev.log
+16
View File
@@ -0,0 +1,16 @@
# DSMMG — npm/pnpm config workspace
# Usage privé : tout @managemate va sur le Verdaccio local par défaut.
# En CI ou pour les contributeurs externes, override via env vars.
# Registre par défaut pour le scope @managemate
@managemate:registry=https://npm.dinawo.fr/
# Reste sur npm public
registry=https://registry.npmjs.org/
# pnpm — éviter les conflits de hoisting auto sur les peers Radix
strict-peer-dependencies=false
auto-install-peers=true
# Lockfile cohérent pour CI
prefer-frozen-lockfile=true
+33
View File
@@ -0,0 +1,33 @@
# DSMMG — CODEOWNERS
# Règle : ces personnes/équipes sont automatiquement assignées en
# review sur les PRs touchant les fichiers concernés.
# Doc Gitea : https://docs.gitea.com/usage/code-owners
# Mainteneurs principaux du DS — review obligatoire sur tout
* @lololefarmer
# Tokens & couleurs — exige un designer dans la review
/packages/tokens/ @lololefarmer
/packages/css/src/tokens/ @lololefarmer
/packages/css/src/tokens.css @lololefarmer
/scripts/lint-contrast.mjs @lololefarmer
DESIGN.md @lololefarmer
# CSS de composants — review front
/packages/css/src/components/ @lololefarmer
# Composants React — review front senior
/packages/react/src/ @lololefarmer
# Tests & Storybook
/storybook/ @lololefarmer
/packages/react/src/*.test.tsx @lololefarmer
# CI & release
/.github/workflows/ @lololefarmer
/.changeset/ @lololefarmer
# Documentation
/docs/ @lololefarmer
README.md @lololefarmer
CONTRIBUTING.md @lololefarmer
+72
View File
@@ -0,0 +1,72 @@
# Code de conduite — DSMMG
Inspiré du [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)
et adapté au contexte ManageMate Group.
## Notre engagement
En tant que membres, contributeurs et mainteneurs du DSMMG, nous nous
engageons à faire de la participation à ce projet une expérience
exempte de harcèlement pour tout le monde, indépendamment de l'âge,
de la corpulence, du handicap visible ou invisible, de l'origine
ethnique, des caractères sexuels, de l'identité ou expression de genre,
du niveau d'expérience, de l'éducation, du statut socio-économique,
de la nationalité, de l'apparence personnelle, de la religion ou de
l'orientation sexuelle.
## Comportements attendus
- Faire preuve d'**empathie** et de **bienveillance**.
- Respecter les opinions, points de vue et expériences différents.
- Donner et accepter avec grâce les retours constructifs.
- Assumer la responsabilité de nos erreurs, présenter nos excuses aux
personnes affectées, et apprendre de nos erreurs.
- Privilégier ce qui est le mieux pour la communauté, pas seulement
pour nous individuellement.
## Comportements inacceptables
- Langage ou imagerie sexualisé, attention ou avances sexuelles
importunes.
- Trolling, commentaires insultants ou désobligeants, attaques
personnelles ou politiques.
- Harcèlement public ou privé.
- Publication d'informations privées de tiers (adresse, e-mail, etc.)
sans permission explicite.
- Toute autre conduite raisonnablement considérée comme inappropriée
dans un cadre professionnel.
## Application
Les responsables du projet sont chargés de clarifier et faire
respecter nos standards de comportement. Ils prendront des mesures
correctives appropriées et équitables en réponse à tout comportement
qu'ils jugent inapproprié.
Les mainteneurs ont le droit et la responsabilité de supprimer,
modifier ou rejeter les commentaires, commits, code, modifications de
documentation et autres contributions qui ne sont pas alignés avec ce
code de conduite.
## Application interne
Les violations peuvent être signalées au mainteneur principal du
projet ou à `conduct@managemate.fr`. Toutes les plaintes seront
examinées avec sérieux et donneront lieu à une réponse jugée
nécessaire et appropriée. Confidentialité garantie.
## Sanctions possibles
1. **Correction** — avertissement privé, explication écrite de la
violation et de la conduite attendue.
2. **Avertissement** — avertissement officiel + interdiction
d'interaction avec la victime pendant une période définie.
3. **Bannissement temporaire** — interdiction de toute interaction
avec le projet pendant une période donnée.
4. **Bannissement définitif** — interdiction permanente de toute
interaction publique avec le projet.
## Attribution
Adapté du [Contributor Covenant version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),
disponible sous Creative Commons Attribution 4.0.
+129
View File
@@ -0,0 +1,129 @@
# Contribuer au DSMMG
Merci de prendre le temps de contribuer. Ce guide décrit le workflow
attendu pour ajouter / modifier un composant, fixer un bug, ou faire
évoluer le design system.
## Setup local
```sh
git clone https://git.dinawo.fr/ManageMate-Group/DSMMG.git
cd DSMMG
pnpm install
pnpm build # build tous les packages dans le bon ordre
pnpm storybook # lance Storybook localement (port 6006)
pnpm demo # lance la demo Vite (port 5180)
pnpm --filter @managemate/react test:watch
```
Pré-requis : Node ≥ 20, pnpm ≥ 9, Git.
## Workflow d'une PR
1. **Créer une branche** depuis `main` :
- `feat/<name>` — nouvelle fonctionnalité
- `fix/<name>` — correction de bug
- `chore/<name>` — outillage, refacto sans surface publique
- `docs/<name>` — documentation
2. **Coder + tests** :
- Tests unitaires (`*.test.tsx`) avec Vitest + axe-core
- Stories Storybook (`*.stories.tsx`) avec autodocs
- Doc MDX pour les composants publics
3. **Ajouter un changeset** : `pnpm changeset` (sélectionner les
packages impactés et le bump approprié — patch / minor / major).
4. **Push** + ouvrir la PR sur `main`.
5. **CI doit passer** :
- lint + typecheck
- tests Vitest
- axe-core (pas de violation a11y)
- lint contraste WCAG (`pnpm lint:contrast`)
- build Storybook + visual regression
- bundle size budget (`pnpm size`)
6. **1 review minimum** d'un CODEOWNER avant merge.
## Conventions de code
### Naming
- **Tokens couleur** : `--mmg-color-*` (jamais `--mmg-color-synapse-*`
directement dans un composant — toujours `--mmg-color-accent-*`).
- **Classes CSS** : `mmg-<comp>` BEM-like (`mmg-btn`, `mmg-btn--primary`,
`mmg-btn__icon`).
- **Utilitaires CSS** : `mmg-u-*` (`mmg-u-stack`, `mmg-u-text-primary`).
- **Composants React** : PascalCase (`Button`, `ProfileHeader`,
`MetricCard`). **Un fichier par composant**, pas de monolithe.
- **Tests** : `<Component>.test.tsx`, **stories** : `<Component>.stories.tsx`.
### Sémantique
- Choisir le tag HTML selon **la sémantique**, pas selon la taille
visuelle. Un titre de page reste `<h1>` même s'il est stylé en
`display-xl`.
- Préférer `<button>` à `<div onClick>`. Préférer `<a href>` pour la
navigation.
### Accessibilité (non-négociable)
- Tester en clavier (Tab, Shift+Tab, Esc, flèches, Enter, Espace).
- Tester avec un lecteur d'écran si possible (NVDA / VoiceOver).
- Tester en zoom 200 % et reflow 320px.
- Tester avec `prefers-reduced-motion` activé.
- Toute couleur d'info doit être redondée avec icône / texte / pattern
(RGAA 9 — couleur seule interdite).
- Aucun `outline: none` sans focus visible alternatif.
- Cible tactile ≥ 44×44 (WCAG SC 2.5.5).
## Ajouter un nouveau composant
1. Créer `packages/react/src/<Component>.tsx` (un fichier, exports
nommés, types exportés).
2. Si CSS dédié : `packages/css/src/components/<comp>.css` + l'importer
dans `packages/css/src/index.css` avec `layer(components)`.
3. **Story** : `storybook/stories/<Component>.stories.tsx` — au minimum
un default + variants + states (default/hover/focus/loading/empty/
error/disabled).
4. **Tests** : `packages/react/src/<Component>.test.tsx` — render, axe,
clavier, interactions critiques.
5. **Doc MDX** : `docs/src/content/docs/components/<component>.md`
anatomie, props, do/don't, accessibilité.
6. **Export** dans `packages/react/src/index.tsx`.
7. **Changeset** minor.
> **Un composant non documenté n'est pas livré.**
## RFC pour les changements majeurs
Voir [RFC process](docs/src/content/docs/contrib/rfc.md). Une RFC est
requise pour :
- nouveau composant non-trivial (DataTable v2, Calendar, Editor…)
- breaking change sur l'API publique
- refonte d'un token ou d'un groupe de tokens
- nouvelle dépendance externe
- changement architectural
## Versioning (SemVer strict)
- **major** : breaking change sur l'API publique
- **minor** : addition non-breaking (nouveau composant, nouvelle prop)
- **patch** : bugfix, doc, perf interne sans surface publique
Les 4 packages versionnés (`tokens`, `css`, `react`, `icons`) sont
**fixed** (même version) — pas d'incompatibilités cross-package.
## Code de conduite
Voir [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Toute communication
toxique, harcèlement, ou comportement excluant entraîne l'exclusion du
projet.
## Sécurité
Les vulnérabilités doivent être signalées en privé. Voir
[SECURITY.md](SECURITY.md).
## Licence
Tout contribution est faite sous la licence du projet
(voir [LICENSE](LICENSE)). En soumettant une PR, vous accordez à
ManageMate Group le droit d'utiliser votre contribution dans le cadre
du DSMMG.
+50
View File
@@ -0,0 +1,50 @@
DSMMG — Design System ManageMate Group
Copyright (c) 2026 ManageMate Group. Tous droits réservés.
LICENCE PROPRIÉTAIRE — USAGE INTERNE MANAGEMATE GROUP
Cette licence régit l'utilisation des packages :
@managemate/css, @managemate/react, @managemate/icons, @managemate/tokens
ainsi que le code source du repository associé.
1. PROPRIÉTÉ INTELLECTUELLE
Le DSMMG (Design System ManageMate Group) et l'ensemble de ses
composants logiciels, designs, tokens, illustrations et documentation
sont la propriété exclusive de ManageMate Group SAS. Aucun transfert
de propriété n'est effectué par cette licence.
2. USAGE AUTORISÉ
L'utilisation du DSMMG est autorisée :
- dans tous les produits et services développés par ManageMate Group
ou ses filiales (Synapse, HRTime, Forge, Orbit, MSLM,
Espace-Client, sites publics et tout autre produit interne) ;
- par les contractuels et prestataires sous accord de
confidentialité, dans le strict cadre de leur mission pour
ManageMate Group.
3. INTERDICTIONS
Sont strictement interdits sans accord écrit préalable de
ManageMate Group :
- toute redistribution publique du code source ou des binaires ;
- toute utilisation commerciale en dehors des produits ManageMate ;
- toute publication sur un registre public (npm, GitHub Packages…) ;
- toute reverse-engineering, modification ou extraction des
composants pour intégration dans un système tiers ;
- toute mention de marque ou usage du logo DSMMG sans autorisation.
4. ABSENCE DE GARANTIE
Le logiciel est fourni "TEL QUEL", sans garantie d'aucune sorte,
expresse ou implicite, y compris notamment les garanties d'aptitude
à un usage particulier ou de non-violation des droits de tiers.
ManageMate Group ne pourra être tenu responsable de tout dommage
résultant de l'utilisation du DSMMG.
5. RGAA / WCAG
Le DSMMG fait l'effort de respecter le RGAA 4.1 / WCAG 2.2 AA,
mais l'intégrateur final reste responsable de la conformité de
l'application complète. Les composants sont des briques, pas une
garantie totale.
6. CONTACT
Pour toute demande de licence étendue ou d'usage hors périmètre :
licensing@managemate.fr
+72
View File
@@ -0,0 +1,72 @@
# Politique de sécurité — DSMMG
## Versions supportées
| Version | Statut |
|---------|--------|
| 0.2.x | ✅ Active |
| 0.1.x | ❌ Plus supportée — migrer vers 0.2 (cf. `docs/src/content/docs/intro/migration.md`) |
Une version mineure reçoit des correctifs de sécurité jusqu'à la
sortie de la mineure suivante. La mineure précédente reçoit
uniquement les correctifs critiques pendant 90 jours.
## Signaler une vulnérabilité
**Ne pas ouvrir d'issue publique pour une vulnérabilité.**
Envoyer un e-mail à **security@managemate.fr** avec :
1. Description du problème.
2. Étapes de reproduction (scénario minimal, screenshots si pertinent).
3. Impact estimé (confidentialité, intégrité, disponibilité).
4. Version(s) affectée(s).
5. Suggestion de correctif (optionnel).
Vous recevrez :
- **Accusé de réception sous 48h ouvrées.**
- **Évaluation initiale sous 5 jours ouvrés.**
- Communication régulière sur l'avancement.
- Une mention dans le CHANGELOG (avec votre accord) si la
vulnérabilité est confirmée.
## Périmètre de sécurité
Les composants du DSMMG sont des briques d'UI. Les responsabilités sécurité :
| Couche | Qui ? |
|--------|-------|
| Validation côté client (formats, longueurs) | Composant DSMMG |
| Échappement XSS dans le rendu React | React + composant |
| Validation serveur, autorisation, CSRF | **L'application consommatrice** |
| Stockage et chiffrement des données | **L'application consommatrice** |
| Protection contre injections (SQL, command) | **L'application consommatrice** |
Le DSMMG ne contient pas de logique d'authentification, de gestion
de tokens, ni de stockage de secrets.
## Bonnes pratiques pour l'intégrateur
- **Valider côté serveur** toute entrée venant du client, même si
elle a été contrôlée par un composant DSMMG.
- **Échappement systématique** lors de l'injection de contenu
utilisateur dans un composant (le DSMMG ne fait pas de sanitization
HTML — seulement le rendu React natif).
- **Politique CSP** stricte côté app : interdire `'unsafe-inline'`,
whitelister explicitement les domaines de fonts (Google Fonts
pour Figtree).
- **Mises à jour** : surveiller les avis de sécurité de Radix UI,
Floating UI, et autres dépendances upstream du DSMMG.
## Audits
Le DSMMG est audité automatiquement à chaque release :
- `pnpm audit` sur le lockfile pnpm
- `pnpm lint:contrast` (vérifie les ratios de contraste WCAG AA)
- `axe-core` sur tous les composants en CI
- `size-limit` pour détecter les régressions de bundle
## Crédits
Merci aux personnes ayant signalé des vulnérabilités responsables :
- *(en attente)*
+22
View File
@@ -0,0 +1,22 @@
services:
verdaccio:
image: verdaccio/verdaccio:6
container_name: dsmmg-verdaccio
restart: unless-stopped
ports:
- "4873:4873"
volumes:
- verdaccio-storage:/verdaccio/storage
- ./verdaccio/config.yaml:/verdaccio/conf/config.yaml:ro
environment:
VERDACCIO_PROTOCOL: http
VERDACCIO_PORT: 4873
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:4873/-/ping"]
interval: 30s
timeout: 5s
retries: 3
volumes:
verdaccio-storage:
name: dsmmg-verdaccio-storage
@@ -0,0 +1,72 @@
---
title: Avatar / AvatarGroup
description: Photo de profil avec initiales fallback, status indicator, AvatarGroup empilé.
---
## Avatar
```tsx
import { Avatar } from "@managemate/react";
<Avatar src="/photo.png" alt="Marie Dupont" size="lg" status="online" />
<Avatar initials="MD" alt="Marie Dupont" size="lg" />
```
| Prop | Type | Défaut |
|---|---|---|
| `src` | `string` | — |
| `initials` | `string` | — |
| `alt` | `string` | — (toujours fournir pour a11y) |
| `size` | `"xs"|"sm"|"md"|"lg"|"xl"|"2xl"` | `"md"` |
| `shape` | `"circle"|"square"` | `"circle"` |
| `status` | `"online"|"away"|"busy"|"offline"` | — |
| `color` | `"auto"|"neutral"|"brand"|"blue"|...` | `"auto"` |
| `bordered` | `boolean` | `false` |
### Couleur auto
Si `initials` est fourni sans `src`, une couleur est générée déterministiquement depuis les initiales (palette `brand / blue / green / amber / violet`). Stable entre renders pour le même nom.
### Status indicator
Petit point en bas-droite avec `aria-label` :
- `online` → vert "En ligne"
- `away` → ambre "Absent"
- `busy` → rouge "Occupé"
- `offline` → gris "Hors ligne"
## AvatarGroup
```tsx
<AvatarGroup
max={4}
total={12} // affiche +8 même si on passe seulement 4 avatars
size="md"
avatars={[
{ initials: "MD", alt: "Marie" },
{ initials: "JM", alt: "Jean" },
{ initials: "SB", alt: "Sophie" },
]}
/>
```
Empilage avec overlap négatif. Hover sur un avatar le remonte en z-index pour identifier qui c'est.
## Do / Don't
**Do**
- Toujours fournir `alt` (nom complet), même avec image.
- Préférer `initials` à `src` quand l'image est absente — pas d'icône générique.
- Utiliser `bordered` quand l'avatar est sur un fond chargé (Hero, AvatarGroup).
**Don't**
- Pas d'avatar sans `alt` (RGAA 1.1).
- Pas d'image inline sans `loading="lazy"` (déjà géré).
- Pas de status `online` sans rafraîchissement temps réel — sinon mensonger.
## A11y
- `role="img"` + `aria-label` quand pas d'image (juste initiales).
- `<img alt>` sinon.
- Status indicator a son propre `aria-label` (annoncé séparément).
- Couleur auto-générée passe AA (background `*-100`, color `*-800`).
@@ -0,0 +1,90 @@
---
title: Dialog / Sheet / Drawer
description: Overlays modaux — Radix Dialog avec focus trap, scroll lock, restitution focus.
---
## Quel composant choisir ?
| Composant | Usage | Animation |
|---|---|---|
| `<Dialog>` | Centré, focus sur une action courte. Confirmation, formulaire compact. | Fade + scale |
| `<Sheet>` | Glisse depuis un bord (4 sides × 5 sizes). Édition longue, navigation mobile, filtres. | Slide |
| `<Drawer>` | Variante simplifiée de Sheet (left/right uniquement, callback `onClose`). | Slide |
| `<ConfirmDialog>` | Confirm destructive (focus initial sur Annuler). | = Dialog sm |
Tous backed Radix → focus trap, scroll lock, restitution focus, Esc, click backdrop.
## Dialog
```tsx
const [open, setOpen] = useState(false);
<Dialog
open={open}
onOpenChange={setOpen}
title="Modifier l'utilisateur"
description="Mettez à jour les informations."
size="md" // sm | md | lg | xl | full
footer={
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<Button variant="ghost" onClick={() => setOpen(false)}>Annuler</Button>
<Button variant="primary" onClick={save}>Sauvegarder</Button>
</div>
}
>
<Input label="Nom" />
</Dialog>
```
## Sheet
```tsx
<Sheet
open={open}
onOpenChange={setOpen}
side="right" // left | right | top | bottom
size="md" // sm 320 | md 480 | lg 640 | xl 820 | full
title="Édition rapide"
description="Sauvegarde immédiate à chaque changement."
footer={...}
>
<Form>...</Form>
</Sheet>
```
## ConfirmDialog (destructive)
```tsx
<ConfirmDialog
open={open}
onOpenChange={setOpen}
destructive
title="Supprimer 3 collaborateurs ?"
description="Cette action est irréversible."
confirmLabel="Supprimer définitivement"
onConfirm={() => api.delete(ids)}
/>
```
Focus initial sur **Annuler** (pas Confirm) pour éviter les actions destructives accidentelles.
## A11y
- `role="dialog"` + `aria-modal="true"` natif.
- Title lié via `aria-labelledby`, description via `aria-describedby`.
- Focus trap : Tab cycle uniquement dans le dialog.
- Restitution focus à l'élément d'origine à la fermeture.
- Esc ferme.
- Click backdrop ferme (sauf si `modal={false}` posé).
## Do / Don't
**Do**
- Un titre explicite (RGAA 9.2 — relations).
- Description si l'action a des conséquences (suppression, paiement).
- Bouton primary à droite, ghost/cancel à gauche.
**Don't**
- Ne pas empiler les Dialogs (UX cauchemardesque). Si vraiment besoin, utiliser un Sheet + Wizard pattern.
- Ne pas utiliser pour un message non-bloquant — préférer Toast.
- Ne pas ouvrir automatiquement au load sans interaction utilisateur (RGAA 13.1 — pas d'apparition non sollicitée).
+80
View File
@@ -0,0 +1,80 @@
---
title: Input / Textarea / Select
description: Champs de formulaire DSMMG — Field-wrapped, accessibles, états couverts.
---
## Anatomie
```
┌──────────────────────────────────────┐
│ Label (semi) * │ ← --required
├──────────────────────────────────────┤
│ [icon] Valeur saisie │ ← Input
├──────────────────────────────────────┤
│ Hint │ ← --tertiary
│ Erreur │ ← --danger (si invalid)
└──────────────────────────────────────┘
```
## API
```tsx
import { Input, Textarea, Select } from "@managemate/react";
<Input
label="E-mail"
type="email"
required
hint="Nous ne partageons jamais votre e-mail."
error={emailError} // string | undefined
prefix={<Icon name="mail-line" />}
size="md" // sm | md | lg
/>
<Textarea label="Notes" rows={4} />
<Select
label="Pays"
options={[
{ value: "fr", label: "France" },
{ value: "be", label: "Belgique" },
]}
/>
```
## Tailles
`sm` (28px), `md` (40px, défaut), `lg` (48px). La densité globale
(`[data-mmg-density]`) ajuste automatiquement le padding.
## États
| | |
|---|---|
| Default | Border `--mmg-color-border` |
| Hover | Border `--mmg-color-border-strong` |
| Focus | Border accent + halo `--mmg-shadow-focus` |
| Disabled | Bg `--mmg-color-state-disabled-bg`, cursor not-allowed |
| Error | Border `--mmg-color-danger`, message `aria-describedby` |
| Success | Border `--mmg-color-success` |
## Do / Don't
**Do**
- Toujours fournir un `<Label>` visible (jamais le placeholder seul — RGAA 11.1).
- `aria-describedby` automatique entre input et hint/error.
- Validation côté serveur en complément (le client n'est jamais source de vérité).
**Don't**
- Ne pas désactiver un input sans expliquer pourquoi (préférer rendre actif + bloquer la submit avec message).
- Ne pas mettre du contenu HTML dans `placeholder` — il est lu par les lecteurs d'écran comme du texte.
- Ne pas utiliser `<Select>` pour > 8 options — préférer `<Combobox>`.
## A11y
- `<label htmlFor>` natif, généré automatiquement.
- `aria-invalid` posé quand `error` est fourni.
- Touch target ≥ 44 (l'input md fait 40, mais un padding cliquable + label cliquable fait 48 effectif).
- Erreurs annoncées via `role="alert"` quand elles apparaissent.
@@ -0,0 +1,60 @@
---
title: MetricCard
description: KPI dashboard — valeur géante, delta coloré, tendance, sparkline optionnel.
---
## API
```tsx
import { MetricCard, Sparkline } from "@managemate/react";
<MetricCard
label="MRR"
value="84 320 €"
delta="+12.4%"
trend="up"
invertTrend={false} // true = "moins c'est mieux" (tickets, churn, latence)
period="vs mois dernier"
icon="money-euro-circle-line"
sparkline={<Sparkline data={[20, 28, 25, 32, 30, 38, 42, 48]} width={200} height={48} />}
href="/dashboard/mrr" // ou onClick
/>
```
## Sémantique trend
| `trend` | `invertTrend` | Couleur delta |
|---|---|---|
| `"up"` | `false` (défaut) | success (vert) |
| `"up"` | `true` | danger (rouge) |
| `"down"` | `false` | danger (rouge) |
| `"down"` | `true` | success (vert) |
| `"flat"` | — | neutre |
Exemple : "tickets ouverts" en hausse = mauvais → `trend="up" invertTrend`.
## Variantes interactives
- Si `href` ou `onClick` → la carte devient cliquable (drill-down vers la page de détail).
- Hover : lift `translateY(-2px)` + border `--mmg-color-border-strong`.
- Focus visible accent.
## Layout
Idéal en grille 4-cols :
```tsx
<div className="mmg-grid mmg-grid--gap-md">
{kpis.map(k => (
<div key={k.id} className="mmg-col-3">
<MetricCard {...k} />
</div>
))}
</div>
```
## A11y
- Si interactif (`href`/`onClick`) : `<a>` ou `<button>` natif, focusable.
- Le delta utilise icône + texte → couleur jamais seule (RGAA 9).
- `font-variant-numeric: tabular-nums` sur valeur et delta pour alignement vertical.
@@ -0,0 +1,59 @@
---
title: PricingCard
description: Carte de tarification pour landings.
---
## API
```tsx
import { PricingCard, Button } from "@managemate/react";
<PricingCard
name="Pro"
description="Pour les PME en croissance."
price="19 €"
pricePeriod="/utilisateur/mois"
highlighted // bordure accent + gradient subtil + ombre teintée
badge="Populaire"
features={[
{ label: "Collaborateurs illimités" },
{ label: "Support prioritaire 24/7" },
{ label: "API & webhooks" },
{ label: "SSO / SAML", included: false },
]}
cta={<Button variant="primary" block>Choisir Pro</Button>}
/>
```
## Patterns
### 3 plans avec le tier "Pro" highlighté
```tsx
<div className="mmg-grid mmg-grid--gap-md">
<div className="mmg-col-4"><PricingCard name="Starter" {...starter} /></div>
<div className="mmg-col-4"><PricingCard name="Pro" {...pro} highlighted badge="Populaire" /></div>
<div className="mmg-col-4"><PricingCard name="Enterprise" {...enterprise} /></div>
</div>
```
### Features incluses / non incluses
`included: false` affiche une croix grise + texte barré + couleur quaternary. Permet de comparer rapidement les tiers.
## A11y
- Badge a `role` implicite — pas besoin de `aria-label` supplémentaire.
- Liste `<ul>` sémantique pour les features.
- Le CTA est un `<Button>` natif, focusable.
## Do / Don't
**Do**
- Maximum 3-5 features par tier (sinon utiliser un tableau de comparaison séparé).
- Mettre `highlighted` sur **un seul** tier.
- Prix proéminent (`display-md`-like).
**Don't**
- Pas plus de 4 tiers (surcharge cognitive).
- Pas de prix barré sans contexte clair (utiliser `<Text strike>` dans le `price` si vraiment besoin).
@@ -0,0 +1,57 @@
---
title: Slider
description: Sélecteur de valeur — single ou range, clavier complet, focus halo.
---
## API
```tsx
import { Slider } from "@managemate/react";
// Single (volume, opacité, zoom)
const [v, setV] = useState([62]);
<Slider label="Volume" value={v} onValueChange={setV} min={0} max={100} step={1} />
// Range (fourchette de prix)
const [range, setRange] = useState([800, 2400]);
<Slider label="Prix" value={range} onValueChange={setRange} min={0} max={5000} step={50} />
```
## Clavier
| Touche | Action |
|---|---|
| `←` / `↓` | Décrémente d'un step |
| `→` / `↑` | Incrémente d'un step |
| `PgUp` / `PgDn` | ±10 × step |
| `Home` / `End` | Min / Max |
| `Tab` | Passe au thumb suivant (range) |
## A11y
- `role="slider"` + `aria-valuenow/min/max/text` natif Radix.
- `aria-label` sur chaque thumb.
- Focus halo accent visible.
- Touch target ≥ 44 (thumb 18px + zone tactile invisible).
## Affichage de la valeur
Au-dessus du slider, en label-adjacent (toujours visible, pas tooltip qui disparaît) :
```tsx
<label style={{ display: "flex", justifyContent: "space-between" }}>
<span>Volume</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{v[0]}%</span>
</label>
<Slider value={v} onValueChange={setV} label="Volume" />
```
## Do / Don't
**Do**
- Toujours afficher la valeur courante quelque part (label, tooltip, hint).
- `step` cohérent avec l'unité (price step=50, volume step=5, opacité step=0.05).
**Don't**
- Pas de slider pour > 20 valeurs distinctes — préférer un Select numéroté.
- Pas de slider pour des valeurs critiques sans confirmation (sliding peut déraper).
@@ -0,0 +1,70 @@
---
title: ThemePicker
description: Sélecteur de couleur d'accent utilisateur.
---
## API
```tsx
import { ThemePicker, useAccent } from "@managemate/react";
<ThemePicker legend="Couleur d'accent" hideReset={false} />
// ou impératif
const { accent, setAccent, reset } = useAccent();
setAccent("blue");
```
| Prop | Type | Défaut |
|---|---|---|
| `legend` | `string` | `"Couleur d'accent"` |
| `hideReset` | `boolean` | `false` |
| `className` | `string` | — |
## Pattern radiogroup
- `role="radiogroup"` avec `aria-labelledby` sur la légende.
- Chaque pastille : `role="radio"` + `aria-checked` + `aria-label` (nom du preset).
- **Roving tabindex** : seul le preset actif est `tabIndex=0`, les autres `-1`.
- Navigation clavier :
- `←/↑` preset précédent (wrap)
- `→/↓` preset suivant (wrap)
- `Home` premier preset
- `End` dernier preset
- Sélection à `Espace` ou en navigant aux flèches (sélection automatique au focus, pattern Radix RadioGroup).
## Persistance
Le hook `useAccent` :
- Lit `localStorage["mmg-accent"]` au mount.
- Écrit à chaque changement.
- Pose `[data-mmg-accent="<preset>"]` sur `<html>`.
Pour éviter le flash au premier render (FOUC), inliner ce script dans `<head>` SSR :
```html
<script>
(function () {
try {
var v = localStorage.getItem("mmg-accent");
if (v && v !== "synapse") document.documentElement.setAttribute("data-mmg-accent", v);
} catch (e) {}
})();
</script>
```
## Presets disponibles
| Preset | Hex (light) | Usage suggéré |
|---|---|---|
| `synapse` (défaut) | `#D12B6A` | Corporate ManageMate |
| `rose` | `#E11D48` | Variante rose plus vif |
| `blue` | `#2563EB` | Banking / fintech |
| `violet` | `#7C3AED` | Forge, créatif, AI |
| `green` | `#0E9F6E` (700) | HRTime, environnemental |
| `amber` | `#D97706` (600) | Orbit, attention positive |
| `red` | `#DC2626` | Sites événementiels |
| `cyan` | `#0891B2` (700) | Analytics |
| `slate` | `#475569` (700) | Neutre haut contraste |
Chaque preset est validé WCAG AA contre fonds light/dark + accent-on (texte sur CTA primary).
+72
View File
@@ -0,0 +1,72 @@
---
title: Toast
description: Notification temporaire empilable façon Sonner.
---
## API
```tsx
import { ToastProvider, useToast } from "@managemate/react";
// 1. Provider à la racine
<ToastProvider position="bottom-right" max={5} visibleCount={3} defaultDuration={5000}>
<App />
</ToastProvider>
// 2. Dans un composant
function Save() {
const { toast, dismiss, clear } = useToast();
return (
<Button
onClick={() => toast({
title: "Modifications sauvegardées",
description: "3 collaborateurs mis à jour.",
severity: "success",
duration: 5000,
action: { label: "Annuler", onClick: () => undo() },
})}
>
Sauvegarder
</Button>
);
}
```
## Comportement (Sonner-style)
- **Au repos** : front-most pleine taille. Les autres empilés derrière avec `translateY` négatif et `scale` décroissant. 3 visibles max.
- **Hover/focus** : la pile se déploie en colonne avec gap. Tous lisibles.
- **Mouseleave/blur** : la pile se recolle.
- **Pause des timers** au hover/focus, reprise au mouseleave.
## Severity
| Severity | Usage | Role ARIA |
|---|---|---|
| `info` | Information neutre | `status` (poli) |
| `success` | Action réussie | `status` |
| `warning` | Attention requise | `status` |
| `danger` | Erreur, action échouée | `alert` (urgent) |
## Position
`bottom-right` (défaut), `bottom-left`, `top-right`, `top-left`.
## Do / Don't
**Do**
- 1-2 phrases max par toast.
- `duration: 0` pour les erreurs critiques (l'utilisateur ferme).
- Action `Annuler` pour les opérations réversibles.
**Don't**
- Pas de toast pour signaler du contenu important hors écran — utiliser `<Alert>` inline.
- Pas de toast `danger` empilé en spam — limiter via `max={5}`.
## A11y
- `aria-live="polite"` sur la region (annonce non-disruptive).
- `aria-atomic="false"` — chaque toast s'annonce individuellement.
- `role="alert"` pour danger (urgent), `role="status"` sinon.
- Bouton fermer focusable, label "Fermer la notification".
- Pause au focus → l'utilisateur peut lire à son rythme.
@@ -0,0 +1,67 @@
---
title: ToggleGroup
description: Groupe de boutons toggle — single (radio) ou multiple (checkbox).
---
## API
```tsx
import { ToggleGroup } from "@managemate/react";
// Single
<ToggleGroup
type="single"
ariaLabel="Mode d'affichage"
value={view}
onValueChange={setView}
items={[
{ value: "list", label: "Liste", icon: "menu-line" },
{ value: "grid", label: "Grille", icon: "apps-2-line" },
{ value: "kanban", label: "Kanban", icon: "stack-line" },
]}
/>
// Multiple
<ToggleGroup
type="multiple"
ariaLabel="Filtres statut"
value={filters}
onValueChange={setFilters} // string[]
items={[
{ value: "actif", label: "Actifs" },
{ value: "absent", label: "Absents" },
{ value: "conge", label: "En congé" },
]}
/>
```
## Variants
- `variant="outline"` (défaut) — fond muted, item actif sur surface + halo.
- `variant="solid"` — item actif passe en accent plein.
## Sizes
`sm` (28px), `md` (defaut), `lg` (40px).
## A11y
Wrapper Radix UI ToggleGroup :
- `role="group"` + `aria-label`
- Roving tabindex (Tab cycle entre groupes, flèches dans le groupe)
- `aria-pressed` sur chaque item
## Quand préférer Tabs / SegmentedControl / ToggleGroup
| | ToggleGroup | Tabs | SegmentedControl |
|---|---|---|---|
| Multi-select possible | ✓ | — | — |
| Switch de **panels** | — | ✓ | — |
| Switch de **vue/mode** | ✓ | ✓ | ✓ |
| Style "pill segmented" | ✓ | — | ✓ |
| Style "underline tabs" | — | ✓ | — |
Règle de pouce :
- **Tabs** quand chaque option ouvre un panel de contenu différent.
- **ToggleGroup multiple** quand on filtre une vue (filtres composables).
- **SegmentedControl** = équivalent ToggleGroup single Apple HIG.
@@ -0,0 +1,76 @@
---
title: Tooltip
description: Tooltip Radix UI — auto-flip, focus management, prefers-reduced-motion.
---
## Quand utiliser
- Action courte (label d'un icon-only button : "Paramètres").
- Information secondaire qui ne mérite pas de prendre de l'espace.
**Ne PAS utiliser** pour :
- Information critique → utiliser `<Alert>` ou texte visible.
- Contenu interactif (boutons, liens) → `Tooltip` est non-interactive. Utiliser `<Popover>` ou `<HoverCard>`.
## Setup
`<TooltipProvider>` à mettre **une seule fois** à la racine de l'app. Sans Provider, le Tooltip ne s'ouvre pas.
```tsx
// app.tsx
import { TooltipProvider } from "@managemate/react";
<TooltipProvider>
<App />
</TooltipProvider>
```
## API
```tsx
import { Tooltip, Button } from "@managemate/react";
<Tooltip content="Paramètres" placement="bottom" delay={200}>
<Button variant="ghost" icon="settings-3-line" iconOnly aria-label="Paramètres" />
</Tooltip>
```
| Prop | Type | Défaut | |
|---|---|---|---|
| `content` | `ReactNode` | — | Contenu affiché |
| `placement` | `"top"|"bottom"|"left"|"right"` | `"top"` | Auto-flip si pas de place |
| `delay` | `number` | `200` | Délai d'apparition (ms) |
| `sideOffset` | `number` | `8` | Décalage par rapport à la cible |
## A11y
- Géré par Radix — `role="tooltip"`, focus management, escape, hover-bridge.
- Apparaît au **hover** ET au **focus clavier**.
- Esc ferme et restitue le focus à l'élément déclencheur.
- Respecte `prefers-reduced-motion`.
## Light vs Dark
| Mode | Background | Texte |
|---|---|---|
| Light | `neutral-900` (très foncé) | `neutral-0` (blanc) |
| Dark | `bg-raised` + bord subtil | `text-primary` |
Le Tooltip ne se fond pas dans le bg-page sombre car il "pop" via élévation + bordure.
## Usage avancé
Pour des tooltips composés (avec image, multi-paragraphe), préférer `<HoverCard>`.
Pour piloter l'ouverture manuellement :
```tsx
import { TooltipPrimitive } from "@managemate/react";
<TooltipPrimitive.Root open={isOpen} onOpenChange={setOpen}>
<TooltipPrimitive.Trigger asChild>...</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content className="mmg-tooltip">...</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
```
+45
View File
@@ -0,0 +1,45 @@
# Visual regression — Playwright
## Local
```sh
pnpm install
pnpm exec playwright install chromium # première fois uniquement
pnpm test:visual # lance les tests
pnpm test:visual:update # met à jour le baseline
pnpm test:visual:report # ouvre le rapport HTML
```
## CI
Le workflow `.github/workflows/ci.yml` exécute les tests visuels sur
chaque PR. Si un test échoue :
1. Télécharger l'artifact `playwright-report` depuis l'action.
2. Comparer screenshots actuels / baseline.
3. **Régression réelle** → fix le composant.
4. **Changement intentionnel** → bumper le baseline via PR :
```sh
pnpm test:visual:update
git add e2e/__screenshots__
git commit -m "chore(visual): update baseline pour <comp>"
```
## Stratégie
- Screenshots des **stories critiques** uniquement (cf. `e2e/visual.spec.ts`).
- Light + Dark via projects Playwright.
- Threshold strict (0.2) — toute différence visible casse la CI.
- Baseline dans `e2e/__screenshots__/` versionné en git.
- Animations désactivées pour éviter la flakiness.
## Faux positifs courants
- **Fonts non chargées** : Playwright attend `networkidle` mais Figtree
via Google Fonts peut être lent. Si flake, augmenter le timeout ou
embarquer la font en local.
- **Anti-aliasing différent OS** : on teste en CI Linux (Chromium).
Les screenshots locaux Windows / macOS peuvent différer légèrement.
Toujours regen le baseline depuis CI.
- **Date dynamique** : si une story affiche `new Date()`, mocker via
`page.clock` ou éviter dans la story (utiliser une date fixée).
+64
View File
@@ -0,0 +1,64 @@
import { test, expect } from "@playwright/test";
/**
* Visual regression — DSMMG
*
* Screenshots les stories critiques en light + dark + 3 presets accent.
* Le baseline est dans `e2e/__screenshots__/` ; toute régression
* casse la CI. Pour mettre à jour le baseline (volontaire) :
*
* pnpm exec playwright test --update-snapshots
*/
const STORIES = [
// Forms
"forms-button--primary",
"forms-button--all-variants",
"forms-slider--single",
"forms-toggle-group--single",
// Overlays
"overlays-tooltip--placements",
"overlays-dialog--default",
"overlays-sheet--right",
// Marketing
"marketing-pricingcard--trio",
"marketing-featurecard--colors",
// Profile
"profile-profileheader--default",
"profile-usercard--sizes",
// Data display
"data-display-metriccard--grid",
"data-display-avatar--sizes",
"data-display-avatar--status",
"data-display-badge--variants",
// Navigation
"navigation-tabs--default",
"navigation-pagination--default",
// Theming
"theming-themepicker--in-card",
// Tokens
"tokens-colors--accent",
"tokens-colors--semantic",
];
for (const id of STORIES) {
test(`story: ${id}`, async ({ page }) => {
await page.goto(`/iframe.html?id=${id}&viewMode=story`);
// Attend que le loader Storybook disparaisse
await page.waitForSelector("#storybook-root", { state: "attached" });
await page.waitForLoadState("networkidle");
// Snapshot du contenu de la story (pas du chrome Storybook)
const root = page.locator("#storybook-root");
await expect(root).toHaveScreenshot(`${id}.png`, {
animations: "disabled",
});
});
}
test.describe("Themes critical paths", () => {
test("Hero — light + dark", async ({ page }) => {
await page.goto("/iframe.html?id=tokens-colors--accent&viewMode=story");
await page.waitForLoadState("networkidle");
await expect(page.locator("#storybook-root")).toHaveScreenshot("hero-light.png");
});
});
+7 -3
View File
@@ -25,12 +25,16 @@
"version-packages": "changeset version",
"release": "pnpm build && changeset publish",
"migrate:tokens": "node scripts/migrate-tokens.mjs",
"lint:contrast": "node scripts/lint-contrast.mjs"
"lint:contrast": "node scripts/lint-contrast.mjs",
"test:visual": "playwright test",
"test:visual:update": "playwright test --update-snapshots",
"test:visual:report": "playwright show-report"
},
"devDependencies": {
"@changesets/cli": "^2.27.11",
"size-limit": "^11.1.6",
"@size-limit/preset-small-lib": "^11.1.6"
"@playwright/test": "^1.49.1",
"@size-limit/preset-small-lib": "^11.1.6",
"size-limit": "^11.1.6"
},
"engines": {
"node": ">=20",
+30 -4
View File
@@ -10,15 +10,20 @@
data-align : "start"/"center"/"end"
════════════════════════════════════════════════════════════════ */
/* — Tooltip ——————————————————————————————————— */
/* — Tooltip ———————————————————————————————————
Light : surface dark (neutral-900) + texte blanc → contraste max sur fond clair.
Dark : surface bg-raised + bordure subtile + texte primary → "pop" au-dessus de bg-page.
Bug fixé : avant on utilisait text-primary comme bg + text-inverse comme
texte. En dark, text-primary devient quasi-blanc et text-inverse reste
blanc → blanc sur blanc, invisible. */
.mmg-tooltip {
z-index: var(--mmg-z-tooltip);
padding: var(--mmg-space-2) var(--mmg-space-3);
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-medium);
line-height: 1.4;
color: var(--mmg-color-text-inverse);
background: var(--mmg-color-text-primary);
color: var(--mmg-color-neutral-0);
background: var(--mmg-color-neutral-900);
border-radius: var(--mmg-radius-sm);
box-shadow: var(--mmg-shadow-2);
user-select: none;
@@ -30,7 +35,28 @@
animation: mmg-tooltip-out 100ms var(--mmg-ease-default);
}
.mmg-tooltip__arrow {
fill: var(--mmg-color-text-primary);
fill: var(--mmg-color-neutral-900);
}
/* Dark — surface élevée + bord subtil pour pop au-dessus de bg-page
(qui est #0B0D14, très proche de neutral-900). */
[data-mmg-theme="dark"] .mmg-tooltip {
background: var(--mmg-color-gray-d-raised);
color: var(--mmg-color-text-primary);
border: 1px solid var(--mmg-color-border);
}
[data-mmg-theme="dark"] .mmg-tooltip__arrow {
fill: var(--mmg-color-gray-d-raised);
}
@media (prefers-color-scheme: dark) {
:root:not([data-mmg-theme="light"]) .mmg-tooltip {
background: var(--mmg-color-gray-d-raised);
color: var(--mmg-color-text-primary);
border: 1px solid var(--mmg-color-border);
}
:root:not([data-mmg-theme="light"]) .mmg-tooltip__arrow {
fill: var(--mmg-color-gray-d-raised);
}
}
@keyframes mmg-tooltip-in {
from { opacity: 0; transform: scale(0.96); }
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Accordion } from "./Accordion";
describe("Accordion", () => {
it("est fermé par défaut", () => {
render(<Accordion label="Section">Contenu</Accordion>);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
});
it("toggle au clic", async () => {
const user = userEvent.setup();
render(<Accordion label="Section">Contenu</Accordion>);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
});
it("toggle au clavier (Enter / Space)", async () => {
const user = userEvent.setup();
render(<Accordion label="Section">Contenu</Accordion>);
const btn = screen.getByRole("button");
btn.focus();
await user.keyboard("{Enter}");
expect(btn).toHaveAttribute("aria-expanded", "true");
await user.keyboard(" ");
expect(btn).toHaveAttribute("aria-expanded", "false");
});
it("aria-controls relié au panel", () => {
render(<Accordion label="x">y</Accordion>);
const id = screen.getByRole("button").getAttribute("aria-controls");
expect(document.getElementById(id!)).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(<Accordion label="x">y</Accordion>);
expect(await axe(container)).toHaveNoViolations();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { Avatar } from "./Avatar";
describe("Avatar", () => {
it("rend les initiales", () => {
render(<Avatar initials="md" alt="Marie Dupont" />);
expect(screen.getByText("MD")).toBeInTheDocument();
});
it("limite à 2 caractères", () => {
render(<Avatar initials="abcdef" alt="x" />);
expect(screen.getByText("AB")).toBeInTheDocument();
});
it("rend une image quand src est fourni", () => {
render(<Avatar src="/avatar.png" alt="Marie" />);
const img = screen.getByRole("img", { name: "Marie" });
expect(img).toHaveAttribute("src", "/avatar.png");
});
it("expose un statut accessible", () => {
render(<Avatar initials="MD" alt="Marie" status="online" />);
expect(screen.getByLabelText("En ligne")).toBeInTheDocument();
});
it("génère une couleur stable depuis les initiales", () => {
const { container, rerender } = render(<Avatar initials="MD" alt="x" />);
const className1 = container.firstChild?.className ?? "";
rerender(<Avatar initials="MD" alt="x" />);
expect(container.firstChild?.className).toBe(className1);
});
it("a11y axe-core", async () => {
const { container } = render(
<>
<Avatar initials="MD" alt="Marie Dupont" status="online" />
<Avatar src="/x.png" alt="Jean Martin" status="busy" size="lg" />
<Avatar initials="SB" alt="Sophie Bernard" shape="square" />
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { AvatarGroup } from "./AvatarGroup";
describe("AvatarGroup", () => {
const data = [
{ initials: "MD", alt: "Marie" },
{ initials: "JM", alt: "Jean" },
{ initials: "SB", alt: "Sophie" },
{ initials: "TL", alt: "Thomas" },
{ initials: "ER", alt: "Emma" },
{ initials: "LB", alt: "Lohann" },
];
it("limite l'affichage à max", () => {
render(<AvatarGroup avatars={data} max={3} />);
expect(screen.getByText("+3")).toBeInTheDocument();
expect(screen.getByLabelText(/3 autres/)).toBeInTheDocument();
});
it("respecte le total custom", () => {
render(<AvatarGroup avatars={data.slice(0, 3)} max={3} total={12} />);
expect(screen.getByText("+9")).toBeInTheDocument();
});
it("expose role group", () => {
render(<AvatarGroup avatars={data.slice(0, 2)} />);
expect(screen.getByRole("group")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(<AvatarGroup avatars={data} max={4} />);
expect(await axe(container)).toHaveNoViolations();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { axe } from "vitest-axe";
import { Badge } from "./Feedback";
describe("Badge", () => {
it("rend les variants sans crash", () => {
const { container } = render(
<>
<Badge>Default</Badge>
<Badge variant="brand">Brand</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
<Badge variant="info">Info</Badge>
</>,
);
expect(container.querySelectorAll(".mmg-badge")).toHaveLength(6);
});
it("a11y axe-core", async () => {
const { container } = render(
<>
<Badge variant="brand">Nouveau</Badge>
<Badge variant="success">Actif</Badge>
<Badge variant="warning">En attente</Badge>
<Badge variant="danger">Erreur</Badge>
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { ConfirmDialog } from "./ConfirmDialog";
function Wrapped({ destructive }: { destructive?: boolean }) {
const [open, setOpen] = useState(false);
const [confirmed, setConfirmed] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<span data-testid="state">{confirmed ? "yes" : "no"}</span>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
destructive={destructive}
title="Supprimer ?"
description="Action irréversible."
confirmLabel="Supprimer"
onConfirm={() => setConfirmed(true)}
/>
</>
);
}
describe("ConfirmDialog", () => {
it("appelle onConfirm puis ferme", async () => {
const user = userEvent.setup();
render(<Wrapped destructive />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
await user.click(screen.getByRole("button", { name: "Supprimer" }));
expect(screen.getByTestId("state")).toHaveTextContent("yes");
});
it("Annuler ferme sans appeler onConfirm", async () => {
const user = userEvent.setup();
render(<Wrapped destructive />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
await user.click(screen.getByRole("button", { name: "Annuler" }));
expect(screen.getByTestId("state")).toHaveTextContent("no");
});
it("a11y axe-core (ouvert)", async () => {
const user = userEvent.setup();
const { container } = render(<Wrapped destructive />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
expect(await axe(container)).toHaveNoViolations();
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Drawer } from "./Drawer";
function Wrapped() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<Drawer open={open} onClose={() => setOpen(false)} title="Filtres">
<p>Contenu drawer</p>
</Drawer>
</>
);
}
describe("Drawer", () => {
it("ouvre + Esc ferme", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Open"));
expect(await screen.findByRole("dialog")).toHaveAccessibleName("Filtres");
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("a11y axe-core", async () => {
const user = userEvent.setup();
const { container } = render(<Wrapped />);
await user.click(screen.getByText("Open"));
await screen.findByRole("dialog");
expect(await axe(container)).toHaveNoViolations();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { FeatureCard } from "./FeatureCard";
describe("FeatureCard", () => {
it("rend titre + description + lien", () => {
render(
<FeatureCard
icon="rocket-2-fill"
title="Onboarding éclair"
description="Créez un user en 90s."
link={{ label: "Voir", href: "/x" }}
/>,
);
expect(screen.getByText("Onboarding éclair")).toBeInTheDocument();
expect(screen.getByText("Créez un user en 90s.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Voir/ })).toHaveAttribute("href", "/x");
});
it("variante glow ajoute la classe", () => {
const { container } = render(
<FeatureCard icon="rocket-2-fill" title="x" glowOnHover />,
);
expect(container.querySelector(".mmg-feature-card--glow")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<FeatureCard
icon="shield-check-fill"
iconColor="green"
title="RGPD by design"
description="Audit ANSSI."
link={{ label: "Notre politique", href: "#" }}
glowOnHover
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Menu } from "./Menu";
const items = [
{ label: "Modifier", icon: "edit-line" as const, onSelect: () => {} },
{ label: "Dupliquer", icon: "file-copy-line" as const, onSelect: () => {} },
{ type: "divider" } as const,
{ label: "Supprimer", icon: "delete-bin-line" as const, danger: true, onSelect: () => {} },
];
describe("Menu", () => {
it("ouvre + flèches naviguent + Enter sélectionne", async () => {
const user = userEvent.setup();
let selected = "";
const items2 = [
{ label: "A", onSelect: () => (selected = "A") },
{ label: "B", onSelect: () => (selected = "B") },
];
render(<Menu trigger={<button type="button">Open</button>} items={items2} />);
await user.click(screen.getByText("Open"));
await screen.findByRole("menu");
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(selected).toBe("B");
});
it("Escape ferme", async () => {
const user = userEvent.setup();
render(<Menu trigger={<button type="button">Open</button>} items={items} />);
await user.click(screen.getByText("Open"));
await screen.findByRole("menu");
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("a11y axe-core (ouvert)", async () => {
const user = userEvent.setup();
const { container } = render(<Menu trigger={<button type="button">Open</button>} items={items} />);
await user.click(screen.getByText("Open"));
await screen.findByRole("menu");
expect(await axe(container)).toHaveNoViolations();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { MetricCard } from "./MetricCard";
describe("MetricCard", () => {
it("rend label + valeur", () => {
render(<MetricCard label="MRR" value="84 320 €" />);
expect(screen.getByText("MRR")).toBeInTheDocument();
expect(screen.getByText("84 320 €")).toBeInTheDocument();
});
it("affiche delta + period", () => {
render(
<MetricCard label="x" value="100" delta="+12.4%" period="vs M-1" trend="up" />,
);
expect(screen.getByText("+12.4%")).toBeInTheDocument();
expect(screen.getByText("vs M-1")).toBeInTheDocument();
});
it("invertTrend inverse la sémantique colorée", () => {
const { container, rerender } = render(
<MetricCard label="x" value="1" delta="+5%" trend="up" />,
);
const success1 = container.querySelector(".mmg-metric-card__delta--success");
expect(success1).not.toBeNull();
rerender(
<MetricCard label="x" value="1" delta="+5%" trend="up" invertTrend />,
);
const danger = container.querySelector(".mmg-metric-card__delta--danger");
expect(danger).not.toBeNull();
});
it("interactive si href", () => {
render(<MetricCard label="x" value="1" href="/m" />);
expect(screen.getByRole("link")).toHaveAttribute("href", "/m");
});
it("a11y axe-core", async () => {
const { container } = render(
<MetricCard
label="MRR"
value="84 320 €"
delta="+12.4%"
trend="up"
period="vs mois dernier"
icon="money-euro-circle-line"
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Pagination } from "./Pagination";
function Wrapped() {
const [page, setPage] = useState(1);
return <Pagination page={page} pageCount={5} onChange={setPage} />;
}
describe("Pagination", () => {
it("page courante a aria-current=page", () => {
render(<Pagination page={3} pageCount={5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "3" })).toHaveAttribute("aria-current", "page");
});
it("clic sur une page change la valeur", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("button", { name: "3" }));
expect(screen.getByRole("button", { name: "3" })).toHaveAttribute("aria-current", "page");
});
it("précédent désactivé sur page 1", () => {
render(<Pagination page={1} pageCount={5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "Page précédente" })).toBeDisabled();
});
it("suivant désactivé sur dernière page", () => {
render(<Pagination page={5} pageCount={5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "Page suivante" })).toBeDisabled();
});
it("a11y axe-core", async () => {
const { container } = render(<Wrapped />);
expect(await axe(container)).toHaveNoViolations();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Popover } from "./Popover";
describe("Popover", () => {
it("trigger ouvre + escape ferme", async () => {
const user = userEvent.setup();
render(
<Popover trigger={<button type="button">Open</button>}>
<p>Contenu</p>
</Popover>,
);
await user.click(screen.getByText("Open"));
expect(await screen.findByText("Contenu")).toBeInTheDocument();
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Contenu")).not.toBeInTheDocument();
});
it("a11y axe-core (fermé)", async () => {
const { container } = render(
<Popover trigger={<button type="button">Open</button>}>
<p>Contenu</p>
</Popover>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { PricingCard } from "./PricingCard";
import { Button } from "./Button";
const features = [
{ label: "Jusqu'à 10 collaborateurs" },
{ label: "Support 24/7" },
{ label: "API & webhooks", included: false },
];
describe("PricingCard", () => {
it("rend nom + prix + features", () => {
render(
<PricingCard
name="Starter"
description="TPE"
price="9 €"
pricePeriod="/mois"
features={features}
cta={<Button>Choisir</Button>}
/>,
);
expect(screen.getByText("Starter")).toBeInTheDocument();
expect(screen.getByText("9 €")).toBeInTheDocument();
expect(screen.getByText("/mois")).toBeInTheDocument();
expect(screen.getByText("API & webhooks")).toBeInTheDocument();
});
it("highlighted ajoute la classe", () => {
const { container } = render(
<PricingCard name="Pro" price="19 €" features={features} highlighted cta={<Button>x</Button>} />,
);
expect(container.querySelector(".mmg-pricing-card--highlighted")).toBeInTheDocument();
});
it("badge optionnel", () => {
render(
<PricingCard name="Pro" price="x" features={features} badge="Populaire" cta={<Button>x</Button>} />,
);
expect(screen.getByText("Populaire")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<PricingCard
name="Pro"
description="PME en croissance."
price="19 €"
pricePeriod="/utilisateur/mois"
highlighted
badge="Populaire"
features={features}
cta={<Button variant="primary" block>Choisir</Button>}
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { ProfileHeader } from "./ProfileHeader";
describe("ProfileHeader", () => {
it("rend nom + sous-titre + bio", () => {
render(
<ProfileHeader
name="Marie Dupont"
subtitle="Lead Developer"
bio="Frontend depuis 2020."
initials="MD"
/>,
);
expect(screen.getByText("Marie Dupont")).toBeInTheDocument();
expect(screen.getByText("Lead Developer")).toBeInTheDocument();
expect(screen.getByText("Frontend depuis 2020.")).toBeInTheDocument();
});
it("rend les stats", () => {
render(
<ProfileHeader
name="x"
initials="x"
stats={[
{ label: "Commits", value: "184" },
{ label: "Reviews", value: "23" },
]}
/>,
);
expect(screen.getByText("Commits")).toBeInTheDocument();
expect(screen.getByText("184")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<ProfileHeader
name="Marie Dupont"
subtitle="Lead Developer · Synapse"
bio="Frontend lead."
initials="MD"
status="online"
stats={[{ label: "Commits", value: "184" }]}
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Sheet } from "./Sheet";
function Wrapped({ side }: { side?: "left" | "right" | "top" | "bottom" }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Ouvrir</button>
<Sheet open={open} onOpenChange={setOpen} side={side} title="Édition" description="Modifier l'utilisateur">
<p>Contenu</p>
</Sheet>
</>
);
}
describe("Sheet", () => {
it("est fermé par défaut", () => {
render(<Wrapped />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("s'ouvre via le trigger", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
});
it("a un titre lié via aria-labelledby (Radix)", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveAccessibleName("Édition");
});
it("se ferme avec Escape", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
await screen.findByRole("dialog");
await user.keyboard("{Escape}");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("a11y axe-core (ouvert)", async () => {
const user = userEvent.setup();
const { container } = render(<Wrapped />);
await user.click(screen.getByText("Ouvrir"));
await screen.findByRole("dialog");
expect(await axe(container)).toHaveNoViolations();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Slider } from "./Slider";
function Single() {
const [v, setV] = useState([50]);
return <Slider label="Volume" value={v} onValueChange={setV} />;
}
function Range() {
const [v, setV] = useState([20, 80]);
return <Slider label="Range" value={v} onValueChange={setV} />;
}
describe("Slider", () => {
it("expose un thumb avec aria-valuenow", () => {
render(<Single />);
const slider = screen.getByRole("slider");
expect(slider).toHaveAttribute("aria-valuenow", "50");
});
it("range expose 2 thumbs", () => {
render(<Range />);
expect(screen.getAllByRole("slider")).toHaveLength(2);
});
it("flèche droite incrémente", async () => {
const user = userEvent.setup();
render(<Single />);
const slider = screen.getByRole("slider");
slider.focus();
await user.keyboard("{ArrowRight}");
expect(slider).toHaveAttribute("aria-valuenow", "51");
});
it("Home / End naviguent aux extrêmes", async () => {
const user = userEvent.setup();
render(<Single />);
screen.getByRole("slider").focus();
await user.keyboard("{End}");
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "100");
await user.keyboard("{Home}");
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "0");
});
it("a11y axe-core", async () => {
const { container } = render(<Single />);
expect(await axe(container)).toHaveNoViolations();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Switch } from "./Form";
describe("Switch", () => {
it("rend en checkbox-like", () => {
render(<Switch label="Notifications" />);
expect(screen.getByRole("checkbox", { name: "Notifications" })).toBeInTheDocument();
});
it("toggle au clavier (Space)", async () => {
const user = userEvent.setup();
render(<Switch label="x" />);
const sw = screen.getByRole("checkbox");
sw.focus();
await user.keyboard(" ");
expect(sw).toBeChecked();
});
it("respecte defaultChecked", () => {
render(<Switch label="x" defaultChecked />);
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("disabled empêche les clicks", async () => {
const user = userEvent.setup();
render(<Switch label="x" disabled />);
await user.click(screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox")).not.toBeChecked();
});
it("a11y axe-core", async () => {
const { container } = render(
<>
<Switch label="Notifications" />
<Switch label="Marketing" defaultChecked />
<Switch label="Désactivé" disabled />
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Tabs } from "./Tabs";
function Wrapped() {
const [v, setV] = useState("a");
return (
<Tabs
value={v}
onChange={setV}
items={[
{ id: "a", label: "Aperçu" },
{ id: "b", label: "Détails" },
{ id: "c", label: "Données" },
]}
/>
);
}
describe("Tabs", () => {
it("expose role tablist + tabs", () => {
render(<Wrapped />);
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getAllByRole("tab")).toHaveLength(3);
});
it("clic sur un tab le sélectionne", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("tab", { name: "Détails" }));
expect(screen.getByRole("tab", { name: "Détails" })).toHaveAttribute("aria-selected", "true");
});
it("a11y axe-core", async () => {
const { container } = render(<Wrapped />);
expect(await axe(container)).toHaveNoViolations();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { Text, Display, Eyebrow, Lead } from "./Text";
describe("Text", () => {
it("rend en h1 par défaut pour display", () => {
const { container } = render(<Text variant="display-xl">Hero</Text>);
expect(container.querySelector("h1")).toBeInTheDocument();
});
it("rend en p pour lead", () => {
const { container } = render(<Text variant="lead">Lead</Text>);
expect(container.querySelector("p")).toBeInTheDocument();
});
it("respecte la prop as", () => {
const { container } = render(<Text variant="h1" as="h2">Section</Text>);
expect(container.querySelector("h2")).toBeInTheDocument();
expect(container.querySelector("h1")).not.toBeInTheDocument();
});
it("applique les modificateurs en classes", () => {
const { container } = render(
<Text variant="body" italic emphasis highlight>x</Text>,
);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain("mmg-text--italic");
expect(el.className).toContain("mmg-text--emphasis");
expect(el.className).toContain("mmg-text--highlight");
});
it("helpers Display / Eyebrow / Lead", () => {
render(
<>
<Eyebrow>SIRH</Eyebrow>
<Display size="xl">Hero</Display>
<Lead>Sub</Lead>
</>,
);
expect(screen.getByText("SIRH")).toBeInTheDocument();
expect(screen.getByText("Hero")).toBeInTheDocument();
expect(screen.getByText("Sub")).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(
<article>
<Eyebrow>SIRH</Eyebrow>
<Display size="xl">Le temps de vos équipes</Display>
<Lead>Plannings, congés, paie. Une plateforme.</Lead>
<Text>
Body normal avec <Text as="em" italic>terme étranger</Text> et{" "}
<Text as="strong" emphasis>mot-clef</Text>.
</Text>
</article>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { ToastProvider, useToast } from "./Toast";
function Trigger() {
const { toast } = useToast();
return (
<button
onClick={() =>
toast({ title: "Sauvegardé", description: "Modifs OK.", severity: "success" })
}
>
Trigger
</button>
);
}
describe("Toast", () => {
it("affiche un toast au trigger", async () => {
const user = userEvent.setup();
render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await user.click(screen.getByText("Trigger"));
expect(await screen.findByText("Sauvegardé")).toBeInTheDocument();
});
it("danger severity → role=alert", async () => {
const user = userEvent.setup();
function T() {
const { toast } = useToast();
return (
<button onClick={() => toast({ title: "Erreur", severity: "danger" })}>x</button>
);
}
render(
<ToastProvider>
<T />
</ToastProvider>,
);
await user.click(screen.getByText("x"));
expect(await screen.findByRole("alert")).toBeInTheDocument();
});
it("close button dismisse", async () => {
const user = userEvent.setup();
render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await user.click(screen.getByText("Trigger"));
await screen.findByText("Sauvegardé");
await user.click(screen.getByLabelText("Fermer la notification"));
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Sauvegardé")).not.toBeInTheDocument();
});
it("a11y axe-core", async () => {
const user = userEvent.setup();
const { container } = render(
<ToastProvider>
<Trigger />
</ToastProvider>,
);
await user.click(screen.getByText("Trigger"));
await screen.findByText("Sauvegardé");
expect(await axe(container)).toHaveNoViolations();
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { ToggleGroup } from "./ToggleGroup";
const ITEMS = [
{ value: "list", label: "Liste" },
{ value: "grid", label: "Grille" },
{ value: "kanban", label: "Kanban" },
];
function Single() {
const [v, setV] = useState<string | undefined>("list");
return (
<ToggleGroup type="single" value={v} onValueChange={setV} ariaLabel="Vue" items={ITEMS} />
);
}
function Multiple() {
const [v, setV] = useState<string[]>(["list", "grid"]);
return (
<ToggleGroup type="multiple" value={v} onValueChange={setV} ariaLabel="Filtres" items={ITEMS} />
);
}
describe("ToggleGroup", () => {
it("rend tous les items en single", () => {
render(<Single />);
expect(screen.getAllByRole("radio")).toHaveLength(3);
});
it("clic toggle l'item en single", async () => {
const user = userEvent.setup();
render(<Single />);
await user.click(screen.getByRole("radio", { name: "Grille" }));
expect(screen.getByRole("radio", { name: "Grille" })).toHaveAttribute("data-state", "on");
});
it("rend en mode multiple plusieurs actifs", () => {
render(<Multiple />);
const buttons = screen.getAllByRole("button");
expect(buttons.filter((b) => b.getAttribute("data-state") === "on")).toHaveLength(2);
});
it("a11y axe-core", async () => {
const { container } = render(<Single />);
expect(await axe(container)).toHaveNoViolations();
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Tooltip, TooltipProvider } from "./Tooltip";
const wrap = (ui: React.ReactNode) => render(<TooltipProvider>{ui}</TooltipProvider>);
describe("Tooltip", () => {
it("affiche au hover", async () => {
const user = userEvent.setup();
wrap(
<Tooltip content="Action sécurisée">
<button type="button">Hover me</button>
</Tooltip>,
);
await user.hover(screen.getByRole("button"));
expect(await screen.findByRole("tooltip")).toHaveTextContent("Action sécurisée");
});
it("affiche au focus clavier", async () => {
const user = userEvent.setup();
wrap(
<Tooltip content="Tip">
<button type="button">Focus me</button>
</Tooltip>,
);
await user.tab();
expect(await screen.findByRole("tooltip")).toBeInTheDocument();
});
it("se ferme avec Escape", async () => {
const user = userEvent.setup();
wrap(
<Tooltip content="Tip" delay={0}>
<button type="button">x</button>
</Tooltip>,
);
await user.hover(screen.getByRole("button"));
await screen.findByRole("tooltip");
await user.keyboard("{Escape}");
// Radix removes role=tooltip after escape
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = wrap(
<Tooltip content="Tip">
<button type="button">x</button>
</Tooltip>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
it("rend nom + role", () => {
render(<UserCard name="Marie Dupont" role="Lead Dev" />);
expect(screen.getByText("Marie Dupont")).toBeInTheDocument();
expect(screen.getByText("Lead Dev")).toBeInTheDocument();
});
it("génère les initiales auto depuis le nom", () => {
render(<UserCard name="Marie Dupont" role="Lead" />);
expect(screen.getByText("MD")).toBeInTheDocument();
});
it("rend en lien si href fourni", () => {
render(<UserCard name="x" role="x" href="/profile/1" />);
expect(screen.getByRole("link")).toHaveAttribute("href", "/profile/1");
});
it("rend en bouton si onClick fourni", async () => {
const user = userEvent.setup();
let clicks = 0;
render(<UserCard name="x" role="x" onClick={() => clicks++} />);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(1);
});
it("focusable au clavier en mode interactif", async () => {
const user = userEvent.setup();
render(<UserCard name="x" role="x" href="#" />);
await user.tab();
expect(screen.getByRole("link")).toHaveFocus();
});
it("a11y axe-core", async () => {
const { container } = render(
<UserCard
name="Marie Dupont"
role="Lead Developer"
initials="MD"
status="online"
meta="marie@test.fr"
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig, devices } from "@playwright/test";
/**
* DSMMG — Playwright config (visual regression sur Storybook).
*
* Stratégie : on build Storybook en static, on le sert localement,
* on prend des screenshots des stories critiques (light + dark) et
* on compare au baseline. Les régressions visuelles cassent la CI.
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [["html", { open: "never" }], ["github"]] : "list",
timeout: 30_000,
expect: {
/**
* Threshold de différence visuelle. 0.2 = très strict (pixel-near).
* Ajuster si flakiness sur le rendu des fonts.
*/
toHaveScreenshot: {
threshold: 0.2,
maxDiffPixelRatio: 0.005,
animations: "disabled",
caret: "hide",
},
},
use: {
baseURL: "http://localhost:6006",
trace: "on-first-retry",
video: "retain-on-failure",
screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 },
colorScheme: "light",
},
projects: [
{
name: "chromium-light",
use: { ...devices["Desktop Chrome"], colorScheme: "light" },
},
{
name: "chromium-dark",
use: { ...devices["Desktop Chrome"], colorScheme: "dark" },
},
// Ajouter Firefox / WebKit en CI quand le baseline sera stable
],
webServer: {
command: "pnpm --filter storybook build && pnpm --filter storybook preview",
url: "http://localhost:6006",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
+38
View File
@@ -11,6 +11,9 @@ importers:
'@changesets/cli':
specifier: ^2.27.11
version: 2.31.0(@types/node@24.12.2)
'@playwright/test':
specifier: ^1.49.1
version: 1.59.1
'@size-limit/preset-small-lib':
specifier: ^11.1.6
version: 11.2.0(size-limit@11.2.0)
@@ -1470,6 +1473,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -3321,6 +3329,11 @@ packages:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4301,6 +4314,16 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
polished@4.3.1:
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
engines: {node: '>=10'}
@@ -6816,6 +6839,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -8844,6 +8871,9 @@ snapshots:
jsonfile: 4.0.0
universalify: 0.1.2
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -10212,6 +10242,14 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
playwright-core@1.59.1: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
polished@4.3.1:
dependencies:
'@babel/runtime': 7.29.2
+27
View File
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Accordion, Stack } from "@managemate/react";
const meta = {
title: "Disclosure/Accordion",
component: Accordion,
tags: ["autodocs"],
} satisfies Meta<typeof Accordion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<Stack>
<Accordion label="Combien coûte un abonnement Pro ?">
19 /utilisateur/mois engagement annuel. 21 /mois sans engagement.
</Accordion>
<Accordion label="Puis-je migrer depuis mon outil actuel ?">
Oui, depuis Sage, Cegid, ADP, Lucca ou via fichier CSV. Compter 7 jours.
</Accordion>
<Accordion label="Êtes-vous conformes RGPD ?" defaultOpen>
Hébergement France, chiffrement AES-256, audit ANSSI annuel. Détails sur notre page sécurité.
</Accordion>
</Stack>
),
};
+52
View File
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Avatar, AvatarGroup } from "@managemate/react";
const meta = {
title: "Data display/Avatar",
component: Avatar,
tags: ["autodocs"],
} satisfies Meta<typeof Avatar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Initials: Story = { args: { initials: "MD", alt: "Marie Dupont" } };
export const Image: Story = { args: { src: "https://i.pravatar.cc/96?u=md", alt: "Marie" } };
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
<Avatar initials="XS" alt="x" size="xs" />
<Avatar initials="SM" alt="x" size="sm" />
<Avatar initials="MD" alt="x" size="md" />
<Avatar initials="LG" alt="x" size="lg" />
<Avatar initials="XL" alt="x" size="xl" />
<Avatar initials="2X" alt="x" size="2xl" />
</div>
),
};
export const Status: Story = {
render: () => (
<div style={{ display: "flex", gap: 16 }}>
<Avatar initials="MD" alt="online" status="online" size="lg" />
<Avatar initials="JM" alt="away" status="away" size="lg" />
<Avatar initials="SB" alt="busy" status="busy" size="lg" />
<Avatar initials="TL" alt="offline" status="offline" size="lg" />
</div>
),
};
export const Square: Story = { args: { initials: "MD", alt: "x", shape: "square", size: "lg" } };
export const Group: Story = {
render: () => (
<AvatarGroup
avatars={[
{ initials: "MD", alt: "Marie" },
{ initials: "JM", alt: "Jean" },
{ initials: "SB", alt: "Sophie" },
{ initials: "TL", alt: "Thomas" },
{ initials: "ER", alt: "Emma" },
]}
max={4}
/>
),
};
+53
View File
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Badge, Icon } from "@managemate/react";
const meta = {
title: "Data display/Badge",
component: Badge,
tags: ["autodocs"],
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Badge>Default</Badge>
<Badge variant="brand">Brand</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="solid">Solid</Badge>
</div>
),
};
export const WithDot: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<span className="mmg-badge mmg-badge--success">
<span className="mmg-badge__dot mmg-badge__dot--pulse" />
En ligne
</span>
<span className="mmg-badge mmg-badge--warning">
<span className="mmg-badge__dot" />
En attente
</span>
<span className="mmg-badge mmg-badge--danger">
<span className="mmg-badge__dot" />
Erreur
</span>
</div>
),
};
export const WithIcon: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
<Badge variant="brand"><Icon name="star-fill" size="xs" /> Nouveau</Badge>
<Badge variant="success"><Icon name="checkbox-circle-fill" size="xs" /> Actif</Badge>
</div>
),
};
+38
View File
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FeatureCard } from "@managemate/react";
const meta = {
title: "Marketing/FeatureCard",
component: FeatureCard,
tags: ["autodocs"],
} satisfies Meta<typeof FeatureCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "rocket-2-fill",
iconColor: "brand",
title: "Onboarding éclair",
description: "Créez un collaborateur, attribuez ses accès, déclenchez son premier cycle de paie en moins de 90 secondes.",
link: { label: "Voir le workflow", href: "#" },
},
};
export const WithGlow: Story = {
args: { ...(Default.args as object), glowOnHover: true },
};
export const Colors: Story = {
render: () => (
<div className="mmg-grid mmg-grid--gap-md">
<div className="mmg-col-4"><FeatureCard icon="rocket-2-fill" iconColor="brand" title="Brand" description="Accent rose Synapse." glowOnHover /></div>
<div className="mmg-col-4"><FeatureCard icon="shield-check-fill" iconColor="green" title="Green" description="Sécurité, RGPD." glowOnHover /></div>
<div className="mmg-col-4"><FeatureCard icon="line-chart-fill" iconColor="violet" title="Violet" description="Analytics." glowOnHover /></div>
<div className="mmg-col-4"><FeatureCard icon="bank-card-fill" iconColor="blue" title="Blue" description="Finance." /></div>
<div className="mmg-col-4"><FeatureCard icon="alert-fill" iconColor="amber" title="Amber" description="Alertes." /></div>
<div className="mmg-col-4"><FeatureCard icon="settings-3-fill" iconColor="neutral" title="Neutral" description="Configuration." /></div>
</div>
),
};
+44
View File
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HoverCard, Avatar, Badge, Inline } from "@managemate/react";
const meta = {
title: "Overlays/HoverCard",
component: HoverCard,
tags: ["autodocs"],
} satisfies Meta<typeof HoverCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Profile: Story = {
render: () => (
<HoverCard
trigger={
<a href="#" style={{ color: "var(--mmg-color-accent)", fontWeight: 600 }}>
@marie.dupont
</a>
}
>
<Inline gap="md">
<Avatar initials="MD" alt="Marie" size="lg" />
<div>
<div style={{ fontWeight: 700 }}>Marie Dupont</div>
<div style={{ color: "var(--mmg-color-text-tertiary)", fontSize: "var(--mmg-font-size-sm)" }}>Lead Dev · Synapse</div>
<div style={{ marginTop: 8, fontSize: "var(--mmg-font-size-sm)" }}>
Lead frontend, mainteneuse du DSMMG.
</div>
</div>
</Inline>
</HoverCard>
),
};
export const Badge_: Story = {
name: "Badge metadata",
render: () => (
<HoverCard trigger={<Badge variant="brand">Synapse v4.2.1</Badge>}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>Synapse v4.2.1</div>
<div style={{ color: "var(--mmg-color-text-tertiary)", fontSize: "var(--mmg-font-size-sm)" }}>Sortie le 24 avril 2026</div>
</HoverCard>
),
};
+44
View File
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Menu, ContextMenu, Button } from "@managemate/react";
const meta = {
title: "Overlays/Menu",
component: Menu,
tags: ["autodocs"],
} satisfies Meta<typeof Menu>;
export default meta;
type Story = StoryObj<typeof meta>;
const items = [
{ label: "Voir le détail", icon: "eye-line" as const, shortcut: "↵" },
{ label: "Modifier", icon: "edit-line" as const, shortcut: "E" },
{ label: "Dupliquer", icon: "file-copy-line" as const, shortcut: "⌘D" },
{ type: "divider" } as const,
{ type: "label", label: "Visibilité" } as const,
{ label: "Partager", icon: "share-line" as const },
{ label: "Archiver", icon: "inbox-line" as const },
{ type: "divider" } as const,
{ label: "Supprimer", icon: "delete-bin-line" as const, danger: true, shortcut: "⌫" },
];
export const Default: Story = {
render: () => (
<Menu trigger={<Button variant="tertiary" icon="more-2-line">Actions</Button>} items={items} />
),
};
export const Context: Story = {
name: "ContextMenu — clic droit",
render: () => (
<ContextMenu items={items}>
<div style={{
display: "grid", placeItems: "center", height: 140,
border: "2px dashed var(--mmg-color-border)", borderRadius: "var(--mmg-radius-md)",
color: "var(--mmg-color-text-tertiary)", cursor: "context-menu",
}}>
Clic droit ici
</div>
</ContextMenu>
),
};
+46
View File
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MetricCard, Sparkline } from "@managemate/react";
const meta = {
title: "Data display/MetricCard",
component: MetricCard,
tags: ["autodocs"],
} satisfies Meta<typeof MetricCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { label: "MRR", value: "84 320 €", delta: "+12.4%", trend: "up", period: "vs M-1", icon: "money-euro-circle-line" },
};
export const TrendDown: Story = {
args: { label: "Tickets ouverts", value: "32", delta: "-18%", trend: "down", invertTrend: true, period: "cette semaine", icon: "error-warning-line" },
};
export const Flat: Story = {
args: { label: "NPS", value: "62", delta: "0", trend: "flat", period: "stable", icon: "star-line" },
};
export const WithSparkline: Story = {
args: {
label: "Sessions",
value: "12 480",
delta: "+8.2%",
trend: "up",
period: "30 derniers jours",
icon: "line-chart-line",
sparkline: <Sparkline data={[20, 28, 25, 32, 30, 38, 42, 48]} width={200} height={48} />,
},
};
export const Grid: Story = {
render: () => (
<div className="mmg-grid mmg-grid--gap-md">
<div className="mmg-col-3"><MetricCard label="MRR" value="84 320 €" delta="+12.4%" trend="up" icon="money-euro-circle-line" /></div>
<div className="mmg-col-3"><MetricCard label="Churn" value="2.3%" delta="+0.4 pts" trend="up" invertTrend icon="arrow-go-back-line" /></div>
<div className="mmg-col-3"><MetricCard label="NPS" value="62" delta="+4 pts" trend="up" icon="star-line" /></div>
<div className="mmg-col-3"><MetricCard label="Tickets" value="32" delta="-18%" trend="down" invertTrend icon="error-warning-line" /></div>
</div>
),
};
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Pagination } from "@managemate/react";
const meta = {
title: "Navigation/Pagination",
component: Pagination,
tags: ["autodocs"],
} satisfies Meta<typeof Pagination>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
const [page, setPage] = useState(3);
return <Pagination page={page} pageCount={10} onChange={setPage} />;
},
};
+68
View File
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import { PricingCard, Button } from "@managemate/react";
const meta = {
title: "Marketing/PricingCard",
component: PricingCard,
tags: ["autodocs"],
} satisfies Meta<typeof PricingCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const features = [
{ label: "Jusqu'à 10 collaborateurs" },
{ label: "Gestion congés" },
{ label: "Bulletins simplifiés" },
{ label: "Support email" },
{ label: "API & webhooks", included: false },
{ label: "SSO", included: false },
];
export const Default: Story = {
args: {
name: "Starter",
description: "Pour les TPE qui démarrent.",
price: "9 €",
pricePeriod: "/utilisateur/mois",
features,
cta: <Button variant="tertiary" block>Démarrer l'essai</Button>,
},
};
export const Highlighted: Story = {
args: {
name: "Pro",
description: "Pour les PME en croissance.",
price: "19 €",
pricePeriod: "/utilisateur/mois",
highlighted: true,
badge: "Populaire",
features: features.map((f) => ({ ...f, included: true })),
cta: <Button variant="primary" block icon="arrow-right-line" iconPosition="right">Choisir Pro</Button>,
},
};
export const Trio: Story = {
render: () => (
<div className="mmg-grid mmg-grid--gap-md">
<div className="mmg-col-4"><PricingCard {...Default.args!} /></div>
<div className="mmg-col-4"><PricingCard {...Highlighted.args!} /></div>
<div className="mmg-col-4">
<PricingCard
name="Enterprise"
description="Pour les organisations matures."
price="Sur devis"
features={[
{ label: "Tout Pro inclus" },
{ label: "SLA 99.95%" },
{ label: "SSO / SAML / SCIM" },
{ label: "Audit logs" },
{ label: "TAM dédié" },
]}
cta={<Button variant="tertiary" block>Contacter sales</Button>}
/>
</div>
</div>
),
};
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ProfileHeader, Badge, Button, Icon } from "@managemate/react";
const meta = {
title: "Profile/ProfileHeader",
component: ProfileHeader,
tags: ["autodocs"],
} satisfies Meta<typeof ProfileHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: "Marie Dupont",
subtitle: "Lead Developer · Synapse",
bio: "Lead frontend chez ManageMate depuis 2020. Mainteneuse du DSMMG, passionnée d'accessibilité et de motion design fonctionnel.",
initials: "MD",
status: "online",
badges: (
<>
<span className="mmg-badge mmg-badge--success">
<span className="mmg-badge__dot mmg-badge__dot--pulse" />
Disponible
</span>
<span className="mmg-badge mmg-badge--brand">
<Icon name="shield-check-fill" size="xs" /> Owner DSMMG
</span>
<span className="mmg-badge">Frontend · React</span>
</>
),
actions: (
<>
<Button variant="tertiary" icon="message-2-line">Message</Button>
<Button variant="primary" icon="user-add-line">Suivre</Button>
</>
),
stats: [
{ label: "Commits", value: "184" },
{ label: "Reviews", value: "23" },
{ label: "Tickets résolus", value: "412" },
{ label: "Ancienneté", value: "5 ans" },
],
},
};
+42
View File
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Sheet, Button, Input, Switch } from "@managemate/react";
const meta = {
title: "Overlays/Sheet",
component: Sheet,
tags: ["autodocs"],
} satisfies Meta<typeof Sheet>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo({ side }: { side: "left" | "right" | "top" | "bottom" }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="primary" onClick={() => setOpen(true)}>Ouvrir ({side})</Button>
<Sheet
open={open}
onOpenChange={setOpen}
side={side}
title="Édition rapide"
description="Modifiez sans quitter la liste."
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>Annuler</Button>
<Button variant="primary" onClick={() => setOpen(false)}>Sauvegarder</Button>
</>
}
>
<Input label="Nom" defaultValue="Marie Dupont" />
<Switch label="Notifier l'équipe" defaultChecked />
</Sheet>
</>
);
}
export const Right: Story = { render: () => <Demo side="right" /> };
export const Left: Story = { render: () => <Demo side="left" /> };
export const Top: Story = { render: () => <Demo side="top" /> };
export const Bottom: Story = { render: () => <Demo side="bottom" /> };
+50
View File
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Slider } from "@managemate/react";
const meta = {
title: "Forms/Slider",
component: Slider,
tags: ["autodocs"],
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Single: Story = {
render: () => {
const [v, setV] = useState([62]);
return (
<div style={{ width: 320 }}>
<label style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, fontSize: "var(--mmg-font-size-sm)", fontWeight: 600 }}>
<span>Volume</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{v[0]}%</span>
</label>
<Slider label="Volume" value={v} onValueChange={setV} />
</div>
);
},
};
export const Range: Story = {
render: () => {
const [v, setV] = useState([800, 2400]);
return (
<div style={{ width: 320 }}>
<label style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, fontSize: "var(--mmg-font-size-sm)", fontWeight: 600 }}>
<span>Prix</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{v[0]} {v[1]} </span>
</label>
<Slider label="Prix" value={v} onValueChange={setV} min={0} max={5000} step={50} />
</div>
);
},
};
export const Disabled: Story = {
render: () => (
<div style={{ width: 320 }}>
<Slider label="x" value={[40]} onValueChange={() => {}} disabled />
</div>
),
};
+30
View File
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Tabs } from "@managemate/react";
const meta = {
title: "Navigation/Tabs",
component: Tabs,
tags: ["autodocs"],
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
const [v, setV] = useState("a");
return (
<Tabs
value={v}
onChange={setV}
items={[
{ id: "a", label: "Aperçu" },
{ id: "b", label: "Détails" },
{ id: "c", label: "Données" },
{ id: "d", label: "Activité" },
]}
/>
);
},
};
+35
View File
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ToastProvider, useToast, Button } from "@managemate/react";
const meta = {
title: "Feedback/Toast",
tags: ["autodocs"],
decorators: [(Story) => <ToastProvider position="bottom-right" max={5}><Story /></ToastProvider>],
} satisfies Meta;
export default meta;
type Story = StoryObj;
function Trigger() {
const { toast } = useToast();
return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button variant="success" onClick={() => toast({ title: "Sauvegardé", description: "Vos modifs ont été enregistrées.", severity: "success" })}>Success</Button>
<Button variant="tonal" onClick={() => toast({ title: "Maintenance prévue dimanche", severity: "info" })}>Info</Button>
<Button variant="ghost" onClick={() => toast({ title: "Quota presque atteint", description: "85% du stockage utilisé.", severity: "warning" })}>Warning</Button>
<Button variant="danger" onClick={() => toast({ title: "Échec de la synchronisation", severity: "danger", duration: 0 })}>Danger</Button>
<Button variant="primary" icon="stack-line" onClick={() => {
const msgs = [
{ title: "Bulletin Marie", description: "2 850 €", severity: "success" as const },
{ title: "Bulletin Jean", description: "2 200 €", severity: "success" as const },
{ title: "Bulletin Sophie", description: "3 400 €", severity: "success" as const },
{ title: "Bulletin Thomas", description: "2 900 €", severity: "success" as const },
{ title: "Bulletin Emma", description: "1 950 €", severity: "success" as const },
];
msgs.forEach((m, i) => setTimeout(() => toast(m), i * 220));
}}>Empiler 5 toasts</Button>
</div>
);
}
export const Default: Story = { render: () => <Trigger /> };
+52
View File
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { ToggleGroup } from "@managemate/react";
const meta = {
title: "Forms/ToggleGroup",
component: ToggleGroup,
tags: ["autodocs"],
} satisfies Meta<typeof ToggleGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
const items = [
{ value: "list", label: "Liste", icon: "menu-line" as const },
{ value: "grid", label: "Grille", icon: "apps-2-line" as const },
{ value: "kanban", label: "Kanban", icon: "stack-line" as const },
];
export const Single: Story = {
render: () => {
const [v, setV] = useState<string | undefined>("list");
return <ToggleGroup type="single" ariaLabel="Vue" value={v} onValueChange={(x) => x && setV(x)} items={items} />;
},
};
export const Multiple: Story = {
render: () => {
const [v, setV] = useState<string[]>(["list"]);
return <ToggleGroup type="multiple" ariaLabel="Vues" value={v} onValueChange={setV} items={items} />;
},
};
export const Solid: Story = {
render: () => {
const [v, setV] = useState<string | undefined>("grid");
return <ToggleGroup type="single" variant="solid" ariaLabel="Vue" value={v} onValueChange={(x) => x && setV(x)} items={items} />;
},
};
export const Sizes: Story = {
render: () => {
const [v, setV] = useState("list");
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<ToggleGroup type="single" size="sm" ariaLabel="x" value={v} onValueChange={(x) => x && setV(x)} items={items} />
<ToggleGroup type="single" size="md" ariaLabel="x" value={v} onValueChange={(x) => x && setV(x)} items={items} />
<ToggleGroup type="single" size="lg" ariaLabel="x" value={v} onValueChange={(x) => x && setV(x)} items={items} />
</div>
);
},
};
+37
View File
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react";
import { UserCard, Button } from "@managemate/react";
const meta = {
title: "Profile/UserCard",
component: UserCard,
tags: ["autodocs"],
} satisfies Meta<typeof UserCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { name: "Marie Dupont", role: "Lead Developer", initials: "MD", status: "online" },
};
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<UserCard size="sm" name="Sophie Bernard" role="PM" initials="SB" status="online" />
<UserCard name="Jean Martin" role="Designer · Synapse" initials="JM" status="away" />
<UserCard size="lg" name="Thomas Legrand" role="DevOps" initials="TL" status="busy" meta="Disponible après 16h" />
</div>
),
};
export const Interactive: Story = {
args: {
name: "Lohann Bouveresse",
role: "CEO",
initials: "LB",
status: "online",
meta: "ceo@managemate.fr",
href: "#",
actions: <Button size="sm" variant="ghost" icon="more-2-line" iconOnly aria-label="Actions" />,
},
};
+85
View File
@@ -0,0 +1,85 @@
# Verdaccio — registre privé DSMMG
Verdaccio est utilisé pour publier les packages `@managemate/*` en
interne, sans exposition publique. Le registre tourne en local via
Docker Compose et peut être déployé sur l'infra ManageMate
(`npm.dinawo.fr` recommandé).
## Démarrer en local
```sh
docker compose -f docker-compose.verdaccio.yml up -d
open http://localhost:4873
```
## Premier compte (admin)
```sh
npm adduser --registry http://localhost:4873
# Username: admin
# Password: <fort>
# Email: dev@managemate.fr
```
Le htpasswd est stocké dans le volume `dsmmg-verdaccio-storage`.
## Publier les packages
À partir du root du monorepo, après un build complet :
```sh
pnpm build
pnpm changeset version # consume les changesets, bump versions
pnpm -r --filter "@managemate/*" publish --registry http://localhost:4873
```
Ou via le script raccourci :
```sh
pnpm release
```
## Déploiement production
Le `docker-compose.verdaccio.yml` est utilisable tel quel sur un host
docker. Recommandations production :
1. **Reverse proxy** (Caddy, Nginx, Traefik) avec **HTTPS** obligatoire.
Exemple Caddy :
```caddy
npm.dinawo.fr {
reverse_proxy localhost:4873
}
```
2. **Backups** réguliers du volume `dsmmg-verdaccio-storage` (contient
les tarballs publiés et le htpasswd). Cible : snapshot quotidien
conservé 30j.
3. **Rate limiting** sur le reverse proxy (~ 60 req/min/IP) pour éviter
le bruteforce sur le htpasswd.
4. **Auth supplémentaire** possible : intégration LDAP/SAML via
`verdaccio-ldap` ou `verdaccio-saml` plugins si l'org grandit.
5. **Monitoring** : `/_stats/heap` exposé par Verdaccio, à scraper
par Prometheus si infra existe.
## Sécurité
- `auth: htpasswd` uniquement par défaut. Pas d'inscription publique
(max_users: 50 limite).
- `@managemate/*` : `access: $authenticated` — seuls les users
authentifiés peuvent installer.
- Le proxy `uplinks.npmjs` permet de récupérer les deps publiques
(Radix, React, etc.) en passant par Verdaccio (cache).
## Migration vers npm public (futur)
Si le DSMMG devient open source un jour :
1. Renommer le scope `@managemate` → `@managemate-group` (ou autre)
sur npm public.
2. Publier sur https://registry.npmjs.org en `--access public`.
3. Garder Verdaccio en cache local pour la CI.
+65
View File
@@ -0,0 +1,65 @@
# Verdaccio config — registre privé local pour le DSMMG
# Usage : docker compose -f docker-compose.verdaccio.yml up -d
# Puis : pnpm config set @managemate:registry http://localhost:4873
# Puis : pnpm publish -r --filter "@managemate/*"
storage: /verdaccio/storage
plugins: /verdaccio/plugins
web:
title: DSMMG — Registre privé ManageMate Group
primary_color: "#D12B6A"
scope: "@managemate"
auth:
htpasswd:
file: /verdaccio/storage/htpasswd
max_users: 50
algorithm: bcrypt
rounds: 10
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
# Packages DSMMG — privés, accès restreint aux users authentifiés
"@managemate/*":
access: $authenticated
publish: $authenticated
unpublish: $authenticated
# Tout le reste tape sur npmjs en proxy
"@*/*":
access: $all
publish: $authenticated
unpublish: $authenticated
proxy: npmjs
"**":
access: $all
publish: $authenticated
unpublish: $authenticated
proxy: npmjs
server:
keepAliveTimeout: 60
middlewares:
audit:
enabled: true
# Logs
logs: { type: stdout, format: pretty, level: info }
# Sécurité
security:
api:
legacy: true
jwt:
sign:
expiresIn: 90d
verify:
someProp: [some, key]
web:
sign:
expiresIn: 7d
verify:
someProp: [some, key]