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