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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dinawo
2026-05-04 22:07:57 +02:00
parent 5e019857fc
commit 62317f2ad7
172 changed files with 31397 additions and 1 deletions
+37
View File
@@ -0,0 +1,37 @@
---
"@managemate/css": major
"@managemate/react": major
"@managemate/icons": major
---
# DSMMG 0.2 — refonte architecturale
**Breaking changes :**
- **Tokens couleur** : tous les tokens couleur passent sous le préfixe `--mmg-color-*`.
- `--mmg-bg-*``--mmg-color-bg-*`
- `--mmg-text-1/2/3/4``--mmg-color-text-{primary,secondary,tertiary,quaternary}`
- `--mmg-border-*``--mmg-color-border-*`
- `--mmg-brand-*``--mmg-color-accent-*`
- `--mmg-success/warning/danger/info-*``--mmg-color-*`
- `--mmg-art-*``--mmg-color-art-*`
- `--mmg-state-*``--mmg-color-state-*`
- **Tokens typographie** : `--mmg-text-{xs..5xl}``--mmg-font-size-*`, `--mmg-leading-*``--mmg-line-height-*`, `--mmg-weight-*``--mmg-font-weight-*`.
- **Suppression `[data-mmg-product]`** : remplacé par presets utilisateur via `[data-mmg-accent]`.
- **Composants splittés** : `Advanced.tsx`, `Chrome.tsx`, `Article.tsx` éclatés en un fichier par composant. Imports inchangés via le barrel `index.tsx`.
- **Build** : packages désormais distribués via `dist/` (ESM + CJS + types). Les imports depuis `src/` ne sont plus supportés.
**Nouveautés :**
- **9 presets accent** utilisateur (`synapse`, `rose`, `blue`, `violet`, `green`, `amber`, `red`, `cyan`, `slate`) avec rampes complètes et variantes dark.
- Composant `ThemePicker` + hooks `useAccent`, `useTheme`.
- Cascade `@layer reset, tokens, base, components, utilities` pour permettre les surcharges sans `!important`.
- Utilitaires CSS `mmg-u-*` (stack, flex, grid, text, bg, padding, margin…).
**Migration :**
Lancer le codemod fourni pour migrer ton codebase consommateur :
```sh
node scripts/migrate-tokens.mjs
```
+30
View File
@@ -0,0 +1,30 @@
# Changesets
Ce dossier contient les "changesets" — des fichiers Markdown qui décrivent les changements à publier.
## Workflow
1. Pour chaque PR qui modifie un package versionné, ajouter un changeset :
```sh
pnpm changeset
```
2. Sélectionner les packages impactés et le bump (patch / minor / major).
3. Décrire le changement (1-2 phrases, orienté consommateur).
4. Commit le `.changeset/*.md` généré avec ta PR.
## Release
Sur `main` :
```sh
pnpm version-packages # consume les changesets, bump les versions, met à jour CHANGELOG
pnpm release # build + publish (registre privé)
```
## Politique de versioning (SemVer strict)
- **major** : breaking change API publique (rename/suppression d'export, signature de prop modifiée, token renommé).
- **minor** : nouveau composant, nouvelle prop, nouveau token, nouvelle variante.
- **patch** : bugfix, doc, perf interne sans surface publique.
Les 4 packages versionnés sont **fixed** (même version) pour éviter les
incompatibilités de tokens entre `@managemate/css` et `@managemate/react`.
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@managemate/css", "@managemate/react", "@managemate/icons", "@managemate/tokens"]],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["demo", "storybook", "docs"]
}
+71
View File
@@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CI: true
HUSKY: 0
jobs:
build:
name: Build, typecheck, test, a11y
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Build tokens (Style Dictionary)
run: pnpm --filter @managemate/tokens build
- name: Build icons
run: pnpm --filter @managemate/icons build
- name: Build CSS
run: pnpm --filter @managemate/css build
- name: Build React
run: pnpm --filter @managemate/react build
- name: Typecheck
run: pnpm --filter @managemate/react typecheck
- name: Test (Vitest + axe-core)
run: pnpm --filter @managemate/react test
- name: Lint contraste WCAG AA
run: pnpm lint:contrast
- name: Build Storybook
run: pnpm --filter storybook build
- name: Bundle size budget
run: pnpm size
- name: Upload Storybook artifact
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook/storybook-static
retention-days: 7
+49
View File
@@ -0,0 +1,49 @@
name: Release
on:
push:
branches: [main]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
packages: write
jobs:
release:
name: Release / open changeset PR
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Create release PR or publish
uses: changesets/action@v1
with:
publish: pnpm release
version: pnpm version-packages
commit: "chore(release): version packages"
title: "chore(release): version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+46
View File
@@ -0,0 +1,46 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
dist
build
*.tsbuildinfo
.turbo
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Storybook
storybook-static
.storybook/dist
# Test artifacts
coverage
.nyc_output
# Demo specifics
demo/_dev.log
# Misc
.cache
.pnpm-debug.log
+32
View File
@@ -0,0 +1,32 @@
[
{
"name": "@managemate/react — full barrel (ESM)",
"path": "packages/react/dist/index.js",
"limit": "60 KB",
"ignore": ["react", "react-dom", "react/jsx-runtime"]
},
{
"name": "@managemate/react — single Button (tree-shake)",
"path": "packages/react/dist/index.js",
"import": "{ Button }",
"limit": "5 KB",
"ignore": ["react", "react-dom", "react/jsx-runtime"]
},
{
"name": "@managemate/react — Dialog + ConfirmDialog (Radix)",
"path": "packages/react/dist/index.js",
"import": "{ Dialog, ConfirmDialog }",
"limit": "30 KB",
"ignore": ["react", "react-dom", "react/jsx-runtime"]
},
{
"name": "@managemate/css — full bundle",
"path": "packages/css/dist/index.css",
"limit": "40 KB"
},
{
"name": "@managemate/icons — full bundle (192 classes)",
"path": "packages/icons/dist/icons.css",
"limit": "120 KB"
}
]
+468
View File
@@ -0,0 +1,468 @@
---
version: "0.2"
name: DSMMG
description: >
Design System ManageMate Group — référence pour Synapse, HRTime, Forge,
Orbit, MSLM, Espace-Client et sites publics. Headless-first sur Radix UI,
RGAA 4.1 / WCAG 2.2 AA, theming utilisateur via 9 presets accent.
# ═══════════════════════════════════════════════════════════════════
# COULEURS — primitives → semantic → accent (user-themable)
# ═══════════════════════════════════════════════════════════════════
colors:
# — Neutrals (warm-cool mix : frais en light, neutre en dark) ——————
neutral-0: "#FFFFFF"
neutral-50: "#F7F6FB"
neutral-100: "#F0EFF9"
neutral-150: "#EDEDFA"
neutral-200: "#E4E3F4"
neutral-300: "#C9C7E0"
neutral-400: "#AAA8C9"
neutral-500: "#7875A1"
neutral-600: "#56557A"
neutral-700: "#3B3A56"
neutral-800: "#1F1E32"
neutral-900: "#111120"
# — Synapse (rose corporate ManageMate, défaut accent) ——————
synapse-50: "#FEF0F4"
synapse-100: "#FCE0EA"
synapse-200: "#FAD0DF"
synapse-300: "#F4A0BD"
synapse-400: "#ED608E"
synapse-500: "#D12B6A" # ★ marque ManageMate
synapse-600: "#BA245F"
synapse-700: "#A82257"
synapse-800: "#831B45"
synapse-900: "#5A132F"
# — Presets accent user (8 alternatives au synapse) ——————
rose-500: "#E11D48"
blue-500: "#2563EB"
violet-500: "#7C3AED"
green-500: "#0E9F6E"
amber-500: "#D97706"
red-500: "#DC2626"
cyan-500: "#0891B2"
slate-500: "#475569"
# — Sémantique fixe (NE CHANGE JAMAIS avec l'accent user) ——————
success: "#059669"
warning: "#D97706"
danger: "#DC2626"
info: "#2563EB"
# — Active accent (alias dynamique, par défaut synapse) ——————
primary: "{colors.synapse-500}"
secondary: "{colors.neutral-600}"
tertiary: "{colors.neutral-300}"
# ═══════════════════════════════════════════════════════════════════
# TYPOGRAPHIE — Figtree partout, 3 échelles selon contexte
# ═══════════════════════════════════════════════════════════════════
typography:
# — Display (landings produits — HRTime, Synapse, Forge, Orbit) ——
display-2xl:
fontFamily: Figtree
fontSize: 72px
fontWeight: 800
letterSpacing: "-0.035em"
lineHeight: 1.0
display-xl:
fontFamily: Figtree
fontSize: 60px
fontWeight: 800
letterSpacing: "-0.03em"
lineHeight: 1.05
display-lg:
fontFamily: Figtree
fontSize: 48px
fontWeight: 700
letterSpacing: "-0.025em"
lineHeight: 1.1
display-md:
fontFamily: Figtree
fontSize: 36px
fontWeight: 700
letterSpacing: "-0.02em"
lineHeight: 1.15
# — Headlines (apps métier — h1 → h6 sémantiques) ————————
h1:
fontFamily: Figtree
fontSize: 36px
fontWeight: 700
letterSpacing: "-0.02em"
lineHeight: 1.2
h2:
fontFamily: Figtree
fontSize: 30px
fontWeight: 700
letterSpacing: "-0.015em"
lineHeight: 1.25
h3:
fontFamily: Figtree
fontSize: 24px
fontWeight: 700
letterSpacing: "-0.01em"
lineHeight: 1.3
h4:
fontFamily: Figtree
fontSize: 20px
fontWeight: 600
letterSpacing: "-0.005em"
lineHeight: 1.35
h5:
fontFamily: Figtree
fontSize: 17px
fontWeight: 600
lineHeight: 1.4
h6:
fontFamily: Figtree
fontSize: 15px
fontWeight: 600
lineHeight: 1.45
# — Body ————————————————————————————————————————
body-lg:
fontFamily: Figtree
fontSize: 17px
lineHeight: 1.6
body:
fontFamily: Figtree
fontSize: 15px
lineHeight: 1.55
body-sm:
fontFamily: Figtree
fontSize: 13px
lineHeight: 1.5
body-xs:
fontFamily: Figtree
fontSize: 11px
lineHeight: 1.45
# — Auxiliaires ————————————————————————————————
eyebrow:
fontFamily: Figtree
fontSize: 13px
fontWeight: 600
letterSpacing: "0.06em"
textTransform: uppercase
lead:
fontFamily: Figtree
fontSize: 20px
fontWeight: 400
letterSpacing: "-0.005em"
lineHeight: 1.5
overline:
fontFamily: Figtree
fontSize: 11px
fontWeight: 600
letterSpacing: "0.08em"
textTransform: uppercase
caption:
fontFamily: Figtree
fontSize: 11px
lineHeight: 1.4
code:
fontFamily: "JetBrains Mono"
fontSize: 13px
# ═══════════════════════════════════════════════════════════════════
# RAYONS — pill par défaut sur interactifs et conteneurs
# ═══════════════════════════════════════════════════════════════════
rounded:
none: 0
sm: 8px
md: 12px
card: 20px
panel: 24px
icon: 12px
pill: 9999px
# ═══════════════════════════════════════════════════════════════════
# ESPACEMENT — grille 4pt (multiples de 4 ou 8 exclusivement)
# ═══════════════════════════════════════════════════════════════════
spacing:
"0": 0
"1": 4px
"2": 8px
"3": 12px
"4": 16px
"5": 20px
"6": 24px
"7": 32px
"8": 40px
"9": 48px
"10": 64px
"11": 80px
"12": 120px
# ═══════════════════════════════════════════════════════════════════
# MOTION — animation = feedback fonctionnel, jamais décoratif
# ═══════════════════════════════════════════════════════════════════
motion:
duration:
fast: 120ms # micro-interactions (hover, focus)
base: 200ms # transitions UI standard
slow: 320ms # navigation (modals, drawers)
easing:
default: "cubic-bezier(0.4, 0, 0.2, 1)" # matériel, doux
emphasis: "cubic-bezier(0.2, 0.8, 0.2, 1)" # rebond léger
# ═══════════════════════════════════════════════════════════════════
# DENSITÉ — 3 modes adaptables par préférence user
# ═══════════════════════════════════════════════════════════════════
density:
comfortable:
rowHeight: 44px
inputHeight: 40px
paddingX: 16px
paddingY: 12px
cozy:
rowHeight: 36px
inputHeight: 36px
paddingX: 12px
paddingY: 8px
compact:
rowHeight: 28px
inputHeight: 30px
paddingX: 8px
paddingY: 4px
# ═══════════════════════════════════════════════════════════════════
# COMPOSANTS — extraits clé. Liste exhaustive : @managemate/react
# ═══════════════════════════════════════════════════════════════════
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.neutral-0}"
typography: "{typography.body-sm}"
fontWeight: 600
rounded: "{rounded.pill}"
padding: "10px 22px"
minHeight: 40px
button-tonal:
backgroundColor: "{colors.synapse-50}"
textColor: "{colors.synapse-800}"
typography: "{typography.body-sm}"
fontWeight: 600
rounded: "{rounded.pill}"
padding: "10px 22px"
button-ghost:
backgroundColor: transparent
textColor: "{colors.neutral-700}"
typography: "{typography.body-sm}"
fontWeight: 600
card:
backgroundColor: "{colors.neutral-0}"
rounded: "{rounded.card}"
padding: "{spacing.6}"
borderColor: "{colors.neutral-200}"
input:
backgroundColor: "{colors.neutral-0}"
textColor: "{colors.neutral-900}"
borderColor: "{colors.neutral-200}"
rounded: "{rounded.md}"
padding: "10px 14px"
minHeight: 40px
badge:
backgroundColor: "{colors.neutral-150}"
textColor: "{colors.neutral-700}"
typography: "{typography.body-xs}"
fontWeight: 600
rounded: 6px
padding: "2px 8px"
toast:
backgroundColor: "{colors.neutral-0}"
borderColor: "{colors.neutral-200}"
rounded: 14px
padding: "14px 14px 14px 16px"
tile:
backgroundColor: "{colors.neutral-0}"
borderColor: "{colors.neutral-200}"
rounded: "{rounded.card}"
padding: "{spacing.6}"
---
# DSMMG — Design System ManageMate Group
> **Version 0.2** · La source de vérité visuelle, comportementale et accessible pour les produits ManageMate (Synapse, HRTime, Forge, Orbit, MSLM, Espace-Client, sites publics).
## Overview
Le DSMMG est un design system **headless-first** construit sur Radix UI. Trois mots :
- **Cohérence** — un bouton dans n'importe quel produit ManageMate a la même structure et la même couleur de base. La différenciation se fait par contenu, pas par couleur.
- **Accessibilité** — RGAA 4.1 / WCAG 2.2 AA non-négociable. Validé `axe-core` en CI. Plusieurs clients (collectivités, opérateurs publics) sont soumis au RGAA par la loi.
- **Theming utilisateur** — 9 presets accent (rose Synapse par défaut), chaque utilisateur choisit dans ses préférences. La sémantique (success/danger/warning/info) reste fixe.
## Colors
Trois couches verticales :
```
primitives → semantic → accent (user-themable)
```
1. **Primitives** — rampes brutes (`neutral-0..900`, `synapse-50..900`, et 8 presets). Ne JAMAIS référencer directement dans les composants.
2. **Sémantiques**`bg-page`, `text-primary`, `border`, `success/warning/danger/info`. **Stables, ne changent pas avec l'accent user.**
3. **Accent**`--mmg-color-accent-*`. Posé par `[data-mmg-accent="<preset>"]` sur `<html>`. SEUL token couleur user-themable.
### Presets accent disponibles
`synapse` (défaut), `rose`, `blue`, `violet`, `green`, `amber`, `red`, `cyan`, `slate`.
Chaque preset a sa version dark adaptée (saturation -2pts, luminosité +3pts) pour confort visuel.
### Règle absolue
-**Aucun hex en dur dans les composants.**
-**Jamais de `--mmg-color-synapse-*` direct** dans un composant — toujours via `--mmg-color-accent-*`.
## Typography
**Figtree** partout (Google Fonts, sans-serif géométrique). 3 échelles selon contexte :
| Famille | Cas d'usage | Tailles |
|---|---|---|
| **Display** | Hero des landings produits (HRTime, Synapse…) | 36px → 72px |
| **Headlines** | Apps métier (h1-h6 sémantiques) | 15px → 36px |
| **Body** | Texte courant et UI | 11px → 17px |
| **Auxiliaires** | eyebrow, lead, overline, caption | 11px → 20px |
**Modificateurs** : `mono`, `tabular`, `balance` (titres), `pretty` (body), `italic`, `emphasis` (italique + accent), `highlight`, `underline`, `strike`, `gradient`, `rainbow`.
> **Choisir le tag (`as`) selon la SÉMANTIQUE, pas selon la taille.** Un titre de page reste `<h1>` même si visuellement il s'affiche en `display-xl`.
## Layout & Spacing
Grille **4 points** stricte. Multiples de 4 ou 8 uniquement. Aucune valeur intermédiaire (10, 14, 22) qui casse l'alignement vertical.
12 niveaux : `0` (0) → `12` (120px).
**Densité** adaptable par utilisateur via `[data-mmg-density="comfortable|cozy|compact"]`. Touch target minimum **44×44** quel que soit le mode (WCAG SC 2.5.5).
## Elevation & Depth
Light mode :
- `shadow-1` — 0 1px 4px ink + 1px ring
- `shadow-2` — 0 4px 16px ink + 1px ring
- `shadow-3` — 0 12px 32px ink (modals, dropdowns)
- `shadow-elevated` — neutre, pour boutons elevated
Dark mode :
- Ombres = noir + halo blanc subtil 4-6 % (jamais "noir s'évapore")
- backdrop-filter blur 12-14px sur overlays/popovers/toasts pour effet verre dépoli
Ombres teintées accent via `color-mix(in srgb, var(--mmg-color-accent) X%, transparent)` — s'adaptent automatiquement au preset user.
## Shapes
Pill par défaut sur **interactifs** (buttons, inputs, badges-pills) et **conteneurs principaux** (Tile, Card, Hero).
| Token | Valeur | Usage |
|---|---|---|
| `rounded-sm` | 8px | inputs, badges-discrets |
| `rounded-md` | 12px | cards compact, popover, menu |
| `rounded-card` | 20px | cards principales, tiles |
| `rounded-panel` | 24px | modals, sheets |
| `rounded-pill` | 9999px | boutons, badges-pills |
## Motion
Animation = **feedback fonctionnel**, jamais décoratif. Trois durées :
- 120ms — micro-interactions (hover, focus, ripple)
- 200ms — transitions UI standard
- 320ms — navigation (modals, drawers, sheets)
`prefers-reduced-motion: reduce` désactive tout (durée 0.001ms). WCAG SC 2.3.3 AAA.
## Components
Catalogue complet (~62 composants) dans `@managemate/react`. Les principaux :
**Forms** : Button, Input, Textarea, Select, Combobox, Checkbox, Radio, Switch, Slider, ToggleGroup, FileUpload.
**Layout** : Container, Section, Stack, Inline, Card, Tile, Hero.
**Feedback** : Alert, Notice, Banner, Badge (avec dot indicator + pulse), Spinner, Skeleton, Toast (pile Sonner-style empilable).
**Overlays** : Tooltip, Popover, Menu, Dialog, ConfirmDialog, Sheet (4 sides × 5 sizes), Drawer, HoverCard, ContextMenu — **tous Radix-backed**.
**Navigation** : Header (mega-menu DSFR-style), Footer, Breadcrumb, Tabs, Pagination, AppShell, Sidebar, Topbar, SkipLink.
**Profile & Cards** : Avatar (status indicator + auto-color), AvatarGroup, UserCard, ProfileHeader (cover + débord), MetricCard (KPI avec trend), PricingCard, FeatureCard, Stat.
**Article** : ArticlePage, ArticleHeader, ArticleAside, ArticleFooter, ArticleCallout, ArticleTOC.
**Theming** : ThemePicker (radiogroup avec navigation flèches/Home/End).
## Do's and Don'ts
### ✅ Do
- Utiliser les **tokens sémantiques** (`--mmg-color-text-primary`, `--mmg-color-bg-surface`).
- Choisir le tag HTML selon **la sémantique**, pas selon la taille visuelle.
- Tester en clavier : Tab, Shift+Tab, Esc, flèches, Enter, Espace.
- Tester en zoom 200 % et reflow 320px.
- Tester avec `prefers-reduced-motion: reduce` activé.
- Préférer les composants Radix-backed (Tooltip, Menu, Dialog…) aux implémentations custom.
- Documenter le composant dans Storybook + MDX avant de le livrer.
### ❌ Don't
- **Aucun hex en dur** dans les composants.
- **Aucune référence directe** à `--mmg-color-synapse-*` — toujours via `--mmg-color-accent-*`.
- **Pas de couleur seule** porteuse d'info (RGAA 9). Toujours redonder avec icône, texte ou pattern.
- **Pas de placeholder** en remplacement de label (RGAA 11.1).
- **Pas de focus invisible** ni `outline: none` sans alternative.
- **Pas de `<div onClick>`** au lieu de `<button>`.
- **Pas d'animation** sans `prefers-reduced-motion`.
- **Pas de composant** sans navigation clavier complète.
- **Pas de composant** sans test axe-core.
- **Pas de fichier** monolithique > 300 lignes mélangeant N composants.
## Accessibilité (engagement)
- **RGAA 4.1 / WCAG 2.2 AA ≥ 95 %**, validé `axe-core` en CI.
- **Sanctions légales** : jusqu'à 50 000 € par service en ligne non conforme (loi 2005-102 + décret 2019-768).
- **Couverture handicaps** : visuels (cécité, malvoyance, daltonisme 8 % des hommes), auditifs, moteurs (paralysie, tremblements, bras cassé), cognitifs (dyslexie, TDAH, autisme, fatigue, langue non maternelle), temporaires/situationnels (soleil, mauvaise connexion, vieux device).
### Tests automatisés
- `axe-core` 4.10 (vitest-axe) — chaque composant interactif.
- Couverture : `wcag2a + wcag2aa + wcag21a + wcag21aa + best-practice`.
- Threshold CI : 60 % ligne / branche / function / statement (à monter à 80 % v0.3).
### Tests manuels recommandés
- NVDA (Windows), VoiceOver (macOS/iOS).
- Clavier 100 % (souris débranchée).
- Zoom 200 % + reflow 320px.
- `prefers-reduced-motion`, `prefers-contrast: more`, `forced-colors: active`.
## Distribution
Monorepo pnpm avec 4 packages :
| Package | Rôle |
|---|---|
| `@managemate/tokens` | Source DTCG W3C des tokens. Génère CSS, JS/TS, Figma, Tailwind v3, Tailwind v4. |
| `@managemate/css` | CSS vanilla — tokens, base, composants, utilitaires `@layer`. |
| `@managemate/icons` | 96 icônes (line + fill) Remix Icon, subset par icône possible. |
| `@managemate/react` | Composants React typés, headless-first sur Radix UI, tree-shakable (`sideEffects: false`). |
Versioning : SemVer strict. Packages **fixed** (même version) pour éviter les incompatibilités cross-package. Changesets sur `main` → release auto.
---
> Ce fichier est lisible par des agents IA via le format [google-labs-code/design.md](https://github.com/google-labs-code/design.md). Front matter YAML = tokens normatifs, corps Markdown = contexte d'application.
+87 -1
View File
@@ -1,2 +1,88 @@
# DSMMG
# DSMMG — Design System ManageMate Group
Préfixe : `mmg-`. Inspiré de l'architecture du DSFR (gouvernement français) — fondation tokens + CSS vanilla + wrappers React.
## Structure
```
managemate-ds/
├── packages/
│ ├── css/ # @managemate/css — tokens, base, composants vanilla
│ ├── react/ # @managemate/react — wrappers React typés
│ └── icons/ # @managemate/icons — système d'icônes (Remix Icon based)
└── demo/ # vitrine + showcase
```
## Démarrer la vitrine
```bash
pnpm install
pnpm --filter @managemate/icons build
pnpm --filter demo dev
```
Ouvre http://localhost:5180.
## Utilisation dans un projet
### Vanilla HTML / CSS
```html
<link rel="stylesheet" href="@managemate/css">
<link rel="stylesheet" href="@managemate/icons">
<button class="mmg-btn mmg-btn--primary">
<span class="mmg-icon mmg-icon-arrow-right"></span> Continuer
</button>
```
### React (recommandé)
```tsx
import "@managemate/css";
import "@managemate/icons";
import { Button, Hero, Header, Footer } from "@managemate/react";
<Button icon="arrow-right" iconPosition="right">Continuer</Button>
```
## Composants v0.1
| Catégorie | Composants |
|---|---|
| **Layout** | Container, Section, Stack, Inline, Card, Tile, Hero, Grid (`.mmg-col-*`) |
| **Boutons** | Button (6 variants × 5 sizes, icon, loading, block) |
| **Forms** | Field, Input, Textarea, Select, Checkbox, Radio, Switch |
| **Feedback** | Alert, Notice, Badge, Modal, ToastRegion, Spinner, Skeleton |
| **Chrome** | Header, Footer, Breadcrumb, Sidebar, Topbar, Tabs, Pagination, AppShell, Avatar, Stat, SkipLink |
| **Icônes** | 49 icônes via `mmg-icon-<name>` |
## Tokens
Tous les tokens sont des CSS custom properties préfixées `--mmg-*`. Light + dark mode via `[data-mmg-theme]`.
**Couleur de marque** : rose `#D12B6A` corporate, partagée par tous les produits.
## TODO v0.2
- [ ] Tooltip
- [ ] Stepper
- [ ] DatePicker
- [ ] DataTable avancée (tri, filtres, pagination intégrée)
- [ ] Combobox / Autocomplete
- [ ] FileUpload
- [ ] CommandMenu (cmd+k)
- [ ] Storybook
- [ ] Tests visuels
- [ ] Variants line/fill pour toutes les icônes (Remix Icon)
- [ ] Détection Wappalyzer (PR sur webappanalyzer)
## Wappalyzer
Pour la détection automatique :
1. Ajouter `<meta name="generator" content="DSMMG">` dans le `<head>` des sites.
2. Ouvrir une PR sur https://github.com/enthec/webappanalyzer avec un fichier qui matche `class*="mmg-"` + le meta tag.
## Licence
Privée — usage interne ManageMate Group uniquement.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr" data-mmg-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DSMMG — ManageMate Design System</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700;800&display=swap" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "demo",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@managemate/css": "workspace:*",
"@managemate/icons": "workspace:*",
"@managemate/react": "workspace:*",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "~6.0.2",
"vite": "^8.0.10"
}
}
+1853
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@managemate/css";
import "@managemate/icons";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "../packages/react/src"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
css: { lightningcss: { errorRecovery: true } },
server: { port: 5180 },
});
+72
View File
@@ -0,0 +1,72 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
export default defineConfig({
site: "https://design.managemate.fr",
integrations: [
starlight({
title: "DSMMG",
description: "Design System ManageMate Group — référence pour designers et développeurs",
defaultLocale: "fr",
locales: { fr: { label: "Français", lang: "fr" } },
logo: { src: "./src/assets/logo.svg", replacesTitle: false },
social: {
github: "https://github.com/managemate/dsmmg",
},
customCss: ["./src/styles/custom.css", "@managemate/css", "@managemate/icons"],
sidebar: [
{
label: "Démarrer",
items: [
{ label: "Introduction", slug: "intro" },
{ label: "Installation", slug: "intro/installation" },
{ label: "Migration v0.1 → v0.2", slug: "intro/migration" },
],
},
{
label: "Fondations",
items: [
{ label: "Tokens", slug: "fondations/tokens" },
{ label: "Couleurs", slug: "fondations/colors" },
{ label: "Typographie", slug: "fondations/typography" },
{ label: "Espacement", slug: "fondations/spacing" },
{ label: "Motion", slug: "fondations/motion" },
{ label: "Densité", slug: "fondations/density" },
],
},
{
label: "Theming utilisateur",
items: [
{ label: "Architecture accent", slug: "theming/architecture" },
{ label: "Presets", slug: "theming/presets" },
{ label: "Mode sombre", slug: "theming/dark-mode" },
],
},
{
label: "Accessibilité",
items: [
{ label: "Engagement RGAA", slug: "a11y/engagement" },
{ label: "Tests automatisés", slug: "a11y/tests" },
{ label: "Patterns clavier", slug: "a11y/keyboard" },
],
},
{
label: "Composants",
autogenerate: { directory: "components" },
},
{
label: "Patterns",
autogenerate: { directory: "patterns" },
},
{
label: "Contribution",
items: [
{ label: "Comment contribuer", slug: "contrib/how" },
{ label: "RFC process", slug: "contrib/rfc" },
{ label: "Versioning", slug: "contrib/versioning" },
],
},
],
}),
],
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "docs",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "astro dev --port 4321",
"start": "astro dev --port 4321",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/starlight": "^0.30.0",
"@managemate/css": "workspace:*",
"@managemate/icons": "workspace:*",
"astro": "^5.0.0",
"sharp": "^0.33.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" aria-hidden="true">
<rect width="64" height="64" rx="14" fill="#D12B6A"/>
<path d="M14 46V18h6.4l8.8 13.5L38.1 18h6.3v28h-6.3V28.6l-7.6 11.6h-1.7l-7.5-11.5V46H14z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

+38
View File
@@ -0,0 +1,38 @@
---
title: Engagement RGAA
description: Accessibilité = priorité non-négociable du DSMMG.
---
## Pourquoi
- **12M de Français** en situation de handicap (visuel, auditif, moteur, cognitif).
- Plusieurs clients ManageMate sont des **collectivités, opérateurs publics ou entreprises >250M€ CA****soumis au RGAA par la loi**.
- Sanctions jusqu'à **50 000 €** par service en ligne non conforme (LOI n° 2005-102 + décret 2019-768 + ordonnance 2018-1102).
- Argument commercial fort sur les **appels d'offres publics**.
## Niveau cible
**RGAA 4.1 — équivalent WCAG 2.1 AA — couverture ≥ 95 %**, validé automatiquement en CI via `axe-core`.
## Couverture par composant
| Composant | axe-core | clavier | lecteur d'écran | Forced colors |
|---|---|---|---|---|
| Button | ✓ | ✓ Tab/Enter/Space | ✓ role + label | ✓ |
| Input/Field | ✓ | ✓ | ✓ label, erreurs aria-describedby | ✓ |
| Combobox | ✓ | ✓ flèches/Home/End/Enter/Esc | ✓ activedescendant | ✓ |
| Tooltip (Radix) | ✓ | ✓ Esc | ✓ role=tooltip | ✓ |
| Dialog (Radix) | ✓ | ✓ focus trap, Esc, restitution | ✓ aria-labelledby/describedby | ✓ |
| Menu (Radix) | ✓ | ✓ flèches/type-ahead/Esc | ✓ menuitem | ✓ |
| ThemePicker | ✓ | ✓ flèches/Home/End | ✓ radiogroup + radios labellés | ✓ |
## Refus absolus
- ❌ Couleur seule porteuse d'information
- ❌ Placeholder en remplacement de label (RGAA 11.1)
- ❌ Focus invisible ou supprimé sans alternative
-`<div onClick>` au lieu de `<button>`
- ❌ Animation sans `prefers-reduced-motion`
- ❌ Contraste en-dessous de AA "pour le style"
- ❌ Composant sans test axe-core
- ❌ Composant sans navigation clavier complète
+39
View File
@@ -0,0 +1,39 @@
---
title: Patterns clavier
description: Conventions clavier appliquées dans tous les composants DSMMG.
---
## Patterns standard
| Composant | Touches |
|---|---|
| Button | `Enter` / `Space` |
| Tab dans formulaire | `Tab` / `Shift+Tab` |
| Modal / Dialog | `Esc` ferme, focus trap, restitution focus |
| Tooltip | `Esc` ferme, focus trigger affiche |
| Menu | `↓ ↑` navigue, `Enter` sélectionne, `type-ahead`, `Esc` ferme |
| Combobox | `↓ ↑` navigue suggestions, `Home/End`, `Enter` sélectionne, `Esc` ferme |
| Tabs | `← →` navigue (manual activation), `Enter` active |
| ThemePicker (radiogroup) | `← → ↑ ↓ Home End` navigue + sélectionne |
| Accordion | `Enter` / `Space` toggle |
## Raccourcis globaux
Le système de raccourcis (`ShortcutProvider`) propose :
- `Cmd/Ctrl + K` : ouvre `CommandMenu` (palette d'actions)
- `?` : affiche la `ShortcutCheatsheet` contextuelle
- Séquences (vim-style) supportées : `g i` = go to inbox, `g s` = go to settings
```tsx
import { useShortcut } from "@managemate/react";
useShortcut(
{ keys: "n", description: "Nouveau ticket", scope: "synapse" },
() => createTicket(),
);
```
## Skip link
`<SkipLink />` à poser comme premier enfant de `<body>`. Sauts visuels Tab visibles. Cible : `<main id="main">` par défaut.
+54
View File
@@ -0,0 +1,54 @@
---
title: Tests automatisés
description: Comment l'accessibilité est validée en CI.
---
## Stack
- **Vitest** (runner)
- **@testing-library/react** (rendu + interactions)
- **@testing-library/user-event** (simulation clavier réaliste)
- **vitest-axe** (axe-core 4.10 en assertions)
- **jsdom** (environnement DOM)
## Exemple de test
```tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Button } from "./Button";
it("est focusable au clavier", async () => {
const user = userEvent.setup();
render(<Button>OK</Button>);
await user.tab();
expect(screen.getByRole("button")).toHaveFocus();
});
it("aucune violation axe-core", async () => {
const { container } = render(<Button>OK</Button>);
expect(await axe(container)).toHaveNoViolations();
});
```
## Politique CI
- Le job `test` du workflow CI échoue si :
- une violation axe est détectée dans un test ;
- un test clavier échoue ;
- la couverture descend sous 60 % (threshold).
## Storybook a11y
Le panneau **Accessibility** (addon-a11y) s'affiche pour chaque story. Configuré pour `wcag2a + wcag2aa + wcag21a + wcag21aa + best-practice`.
## Tests manuels conseillés
axe-core couvre ~40 % des règles WCAG. Compléter par :
- **NVDA** (Windows) ou **VoiceOver** (macOS)
- **Navigation 100 % clavier** (souris débranchée)
- **Zoom 200 %** + reflow 320px de large
- **`prefers-reduced-motion`** activé
- **Forced colors / High Contrast Mode** (Windows)
@@ -0,0 +1,71 @@
---
title: Button
description: Bouton DSMMG — variants Material 3 / Fluent, pill par défaut, tous états couverts.
---
## Anatomie
```
┌────────────────────────────────┐
│ [icon] Label [trailing icon]│
└────────────────────────────────┘
```
## Variants
| Variant | Quand l'utiliser |
|---|---|
| `primary` | Action principale d'une page/section. Un seul par bloc. |
| `tonal` | Action importante mais pas la principale. |
| `secondary` | Action neutre, outline accent. |
| `tertiary` | Action discrète mais visible. |
| `ghost` | Action très discrète, dans un toolbar. |
| `elevated` | Quand le bouton flotte sur un fond chargé (image). |
| `danger` | Suppression, action destructive. |
| `success` | Validation positive (rare, généralement preferring primary). |
## Sizes
`xs` (28px), `sm` (34px), `md` (40px, défaut), `lg` (48px), `xl` (56px).
`md` et plus respectent la cible tactile WCAG 2.5.5 (≥ 44×44 effective). Pour `xs`/`sm` en `icon-only`, une zone tactile invisible 44×44 est ajoutée via `::after`.
## API
```tsx
import { Button } from "@managemate/react";
<Button
variant="primary"
size="md"
icon="arrow-right-line"
iconPosition="right"
loading={false}
disabled={false}
block={false}
onClick={() => {}}
>
Continuer
</Button>
```
## Do / Don't
**Do**
- Un seul `primary` par section logique.
- Verbes d'action concrets : "Sauvegarder", "Continuer", "Supprimer".
- `aria-label` sur les `icon-only`.
**Don't**
- Pas de "Cliquez ici" / "Soumettre" / "OK" génériques.
- Ne pas désactiver un bouton sans aria-explication ; préférer le rendre actif et afficher l'erreur après.
- Ne pas remplacer un `<a>` (navigation) par un `<button>`. Sémantique avant tout.
## A11y
- `<button type="button">` natif. Sémantique correcte garantie.
- `disabled` empêche `pointer-events` et le focus.
- `loading` affiche un spinner et bloque les clicks ; pas d'`aria-busy` requis (le state est dans la classe).
- Ratio de contraste : tous les variants respectent AA contre `--mmg-color-bg-surface` et `--mmg-color-bg-page`.
+21
View File
@@ -0,0 +1,21 @@
---
title: Composants
description: Catalogue des composants DSMMG.
---
Les composants sont documentés en détail dans **Storybook** (live, props auto-générées). Cette section donne les guidelines (anatomie, do/don't, a11y).
→ [Storybook](https://design.managemate.fr/storybook)
## Catégories
| Catégorie | Composants |
|---|---|
| **Forms** | Button, Input, Textarea, Select, Combobox, Checkbox, Radio, Switch, Field, FileUpload, SearchBar |
| **Layout** | Container, Section, Stack, Inline, Card, Tile, Hero |
| **Feedback** | Alert, Notice, Banner, Badge, Spinner, Skeleton, Toast |
| **Overlays** | Tooltip, Popover, Menu, Dialog, ConfirmDialog, Drawer |
| **Navigation** | Header, Footer, Breadcrumb, Tabs, Pagination, AppShell, Sidebar, Topbar |
| **Data display** | DataTable, Stat, Avatar, Tag, Progress, EmptyState |
| **Article** | ArticlePage, ArticleHeader, ArticleAside, ArticleFooter, ArticleCallout, ArticleTOC |
| **Theming** | ThemePicker |
+47
View File
@@ -0,0 +1,47 @@
---
title: Comment contribuer
description: Workflow de contribution au DSMMG.
---
## Setup local
```sh
git clone <repo>
cd managemate-ds
pnpm install
pnpm build # build tous les packages dans le bon ordre
pnpm storybook # lance Storybook localement
pnpm --filter @managemate/react test:watch
```
## Workflow de PR
1. Branch depuis `main` : `feat/...`, `fix/...`, `chore/...`.
2. Modifier le code.
3. **Ajouter un changeset** : `pnpm changeset` (sélectionner les packages impactés et le bump).
4. Tests + Storybook updates obligatoires.
5. Push, ouvrir la PR.
6. CI doit passer (lint + types + tests + axe + size-limit + Storybook build).
7. 1 review minimum (CODEOWNERS).
## Conventions
- **Commits** : conventional commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`).
- **Branches** : `<type>/<short-description>`.
- **Code** : Prettier + ESLint via la config workspace (à venir).
## Ajouter un nouveau composant
1. Créer `packages/react/src/<Component>.tsx` (un fichier, exports nommés).
2. Si CSS dédié : ajouter `packages/css/src/components/<comp>.css` + l'importer dans `index.css` avec `layer(components)`.
3. Story Storybook : `storybook/stories/<Component>.stories.tsx` avec au minimum default + variants + states.
4. Tests : `packages/react/src/<Component>.test.tsx` (axe + clavier + interactions).
5. Doc MDX : `docs/src/content/docs/components/<component>.md` (anatomie, props, do/don't, a11y).
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](/contrib/rfc/).
+50
View File
@@ -0,0 +1,50 @@
---
title: RFC process
description: Quand et comment proposer une RFC.
---
Une **RFC (Request for Comments)** est requise pour :
- Nouveau composant non-trivial (DataTable, Calendar…)
- Breaking change sur l'API publique d'un composant existant
- Refonte d'un token ou d'un groupe de tokens
- Nouvelle dépendance externe (ex: ajouter Floating UI)
- Changement architectural (build, tree-shaking, distribution)
## Format
PR sur `docs/src/content/docs/rfcs/000X-<titre>.md` avec :
```md
---
title: RFC 0001 — Nom de la RFC
status: draft | accepted | rejected | superseded
author: <handle>
date: YYYY-MM-DD
---
## Contexte
Pourquoi cette RFC ? Quel problème ?
## Proposition
La solution. Code, schémas, exemples.
## Alternatives
Ce qu'on a écarté et pourquoi.
## Coût migration
Codemod possible ? Effort consommateur ?
## Risques
A11y, perf, DX, lock-in.
## Décision
À remplir après discussion.
```
## Cycle
1. Auteur ouvre la PR `docs(rfc): 000X — title`.
2. Au moins **2 reviewers** (un design, un code).
3. Discussion ouverte ≥ 7 jours.
4. Décision : `accepted` → implémentation dans une PR séparée. `rejected` → on garde la RFC en archive.
@@ -0,0 +1,44 @@
---
title: Versioning
description: SemVer strict, packages liés, deprecation policy.
---
## SemVer strict
- **major** : breaking change sur l'API publique
- rename/suppression d'export
- signature de prop modifiée
- rename de token CSS
- changement structurel du DOM rendu (peut casser des sélecteurs CSS consommateurs)
- **minor** : addition non-breaking
- nouveau composant
- nouvelle prop optionnelle
- nouveau token
- nouvelle variante
- **patch** : bugfix, doc, perf interne sans surface publique
## Fixed packages
`@managemate/css`, `@managemate/react`, `@managemate/icons`, `@managemate/tokens` sont **fixed** (même version), pour éviter les incompatibilités de tokens entre CSS et React.
## Deprecation policy
1. Ajouter `@deprecated` JSDoc sur l'API à supprimer + indiquer le remplacement.
2. Émettre un warning console en dev (`process.env.NODE_ENV !== "production"`).
3. **Au minimum un cycle minor** entre deprecation et suppression.
4. Suppression dans un major, listée explicitement dans le CHANGELOG.
## Codemods
Tout breaking change qui peut être migré automatiquement **doit** fournir un codemod.
Voir `scripts/migrate-tokens.mjs` comme référence.
## Workflow Changesets
```sh
pnpm changeset # ajouter un changeset
pnpm version-packages # consume les changesets, bump versions
pnpm release # build + publish
```
CI gère ça via GitHub Actions sur `main` (voir `.github/workflows/release.yml`).
@@ -0,0 +1,39 @@
---
title: Couleurs
description: Palette neutre + presets accent + sémantique.
---
import { Aside } from "@astrojs/starlight/components";
<Aside type="caution">
Pour la **palette interactive** avec swatches en live, voir <a href="https://design.managemate.fr/storybook/?path=/docs/tokens-colors--docs">Storybook → Tokens → Colors</a>.
</Aside>
## 3 catégories
1. **Neutres** — bg, text, border. Stables.
2. **Sémantiques** — success/warning/danger/info. **Stables, pas affectées par l'accent user.**
3. **Accent** — user-themable, 9 presets.
## Contrastes garantis
| Combinaison | Ratio | Niveau |
|---|---|---|
| `text-primary` sur `bg-page` | ≥ 12:1 | AAA |
| `text-secondary` sur `bg-page` | ≥ 7:1 | AAA |
| `text-tertiary` sur `bg-page` | ≥ 4.5:1 | AA |
| `text-quaternary` sur `bg-page` | ≥ 4:1 | (limite, pour placeholder uniquement) |
| `accent-on` sur `accent` | ≥ 4.5:1 | AA — sur tous les presets |
| `accent` sur `bg-page` | ≥ 3:1 | AA non-texte |
## Daltonisme
8 % des hommes (1/12) ont un trouble de la perception des couleurs. Aucune information ne doit reposer **uniquement** sur la couleur :
- Status badges → icône + texte ("Résolu" + check, pas juste un point vert).
- Sparkline / graphique → patterns + labels.
- Validation form → message + bordure rouge + icône `alert`.
## OKLCH
Les ramps sont conçues en OKLCH (perception uniforme). Rendu en hex pour compat IE/Safari < 15.4. Migration `oklch()` envisagée quand support ≥ 95 %.
@@ -0,0 +1,30 @@
---
title: Densité
description: 3 modes adaptables par préférence utilisateur.
---
## Modes
| Mode | Scale | Row height | Padding | Pour qui |
|---|---|---|---|---|
| `comfortable` (défaut) | 1.0 | 44 | x:16, y:12 | Espace-Client, sites publics, mobile |
| `cozy` | 0.85 | 36 | x:12, y:8 | Apps métier (Synapse, HRTime, Forge…) |
| `compact` | 0.72 | 28 | x:8, y:4 | Power users, 6h+/jour, dashboards denses |
## Application
Sur un sous-arbre (généralement `<body>` ou un container) :
```html
<body data-mmg-density="cozy">
```
Les composants qui consomment `--mmg-density-*` héritent automatiquement.
## Persistance
Recommandation : exposer un toggle dans les préférences user, persister en localStorage + DB user, appliquer en SSR pour éviter le flash.
## Touch target minimum
Quel que soit le mode, la **cible tactile reste ≥ 44×44** (WCAG SC 2.5.5). En `compact`, la cible visible peut être plus petite mais une zone tactile invisible la complète (cf. `Button.tsx` icon-only).
@@ -0,0 +1,24 @@
---
title: Motion
description: Animation = feedback fonctionnel, jamais décoratif.
---
## Tokens
| Token | Valeur | Usage |
|---|---|---|
| `--mmg-duration-fast` | 120ms | micro-interactions (hover, focus, ripple) |
| `--mmg-duration-base` | 200ms | transitions UI (panels, popovers) |
| `--mmg-duration-slow` | 320ms | navigation (modals, drawers) |
| `--mmg-ease-default` | cubic-bezier(0.4, 0, 0.2, 1) | matériel, doux |
| `--mmg-ease-emphasis` | cubic-bezier(0.2, 0.8, 0.2, 1) | rebond / overshoot léger |
## Règles
- **Animation = feedback** — toujours expliquer ce qui se passe (apparition, disparition, état).
- **Jamais décorative** — pas de "pour faire joli".
- **Entry: ease-out**, **Exit: ease-in**, **Continuous: ease-in-out**.
## prefers-reduced-motion
Toutes les animations sont désactivées (durée 0.001ms) sous `@media (prefers-reduced-motion: reduce)` (WCAG SC 2.3.3 AAA + bonne pratique AA). Cf. utilisateurs sensibles vestibulaire, migraine, attention.
@@ -0,0 +1,44 @@
---
title: Espacement
description: Grille 4pt — multiples de 4 ou 8 exclusivement.
---
## Tokens
| Token | px |
|---|---|
| `--mmg-space-0` | 0 |
| `--mmg-space-1` | 4 |
| `--mmg-space-2` | 8 |
| `--mmg-space-3` | 12 |
| `--mmg-space-4` | 16 |
| `--mmg-space-5` | 20 |
| `--mmg-space-6` | 24 |
| `--mmg-space-7` | 32 |
| `--mmg-space-8` | 40 |
| `--mmg-space-9` | 48 |
| `--mmg-space-10` | 64 |
| `--mmg-space-11` | 80 |
| `--mmg-space-12` | 120 |
## Règles
- **Toujours utiliser un token.** Aucun `padding: 17px` en dur.
- **Multiples de 4 ou 8** uniquement. Évite les valeurs intermédiaires (10, 14, 22) qui cassent l'alignement vertical.
- **Densité adaptable** : `--mmg-density-padding-{x,y}` change avec `[data-mmg-density]`. Préférer ces tokens aux `--mmg-space-*` directs dans les composants soumis à la densité.
## Utilitaires
```html
<div class="mmg-u-stack mmg-u-stack-4">
<!-- gap: 16px -->
</div>
```
| Class | Effect |
|---|---|
| `mmg-u-gap-{1..6}` | gap: var(--mmg-space-N) |
| `mmg-u-stack-{1..6}` | flex column + gap |
| `mmg-u-mt-{1..6}` | margin-top |
| `mmg-u-mb-{1..6}` | margin-bottom |
| `mmg-u-p-{0,2,3,4,6}` | padding |
@@ -0,0 +1,55 @@
---
title: Tokens
description: Architecture des tokens DSMMG.
---
Les tokens sont organisés en **3 couches**, du bas vers le haut :
```
primitives → semantic → accent (presets user)
```
## 1. Primitives
Rampes de couleur brutes (`--mmg-color-{neutral,synapse,blue,…}-{50..900}`), non utilisées directement par les composants.
## 2. Semantic
Tokens de rôle qui mappent les primitives. **Les composants consomment ceci.**
- `--mmg-color-bg-{page,surface,raised,muted,subtle,overlay}` — surfaces
- `--mmg-color-text-{primary,secondary,tertiary,quaternary,inverse}` — texte
- `--mmg-color-border{,-soft,-strong}` — bordures
- `--mmg-color-{success,warning,danger,info}{,-soft,-border,-strong,-on}` — sémantique fixe
## 3. Accent (user-themable)
`--mmg-color-accent{,-hover,-active,-soft,-border,-strong,-on}` — la SEULE couleur user-themable. Posée par `[data-mmg-accent="<preset>"]` sur `<html>`.
## Non-color
| Catégorie | Préfixe | Exemple |
|---|---|---|
| Espacement | `--mmg-space-{0..12}` | `var(--mmg-space-4)` = 16px |
| Rayons | `--mmg-radius-{sm,md,card,panel,icon,pill}` | |
| Font size | `--mmg-font-size-{xs,sm,base,lg,xl,2xl..5xl}` | |
| Font weight | `--mmg-font-weight-{regular,medium,semi,bold,extra}` | |
| Line height | `--mmg-line-height-{tight,snug,normal}` | |
| Motion | `--mmg-duration-{fast,base,slow}`, `--mmg-ease-{default,emphasis}` | |
| Z-index | `--mmg-z-{dropdown,sticky,modal,toast,tooltip}` | |
| Density | `--mmg-density-{scale,row-height,input-height,padding-x,padding-y}` | |
## Source unique : DTCG
`packages/tokens/src/tokens.json` au format **W3C Design Tokens Community Group**. Style Dictionary génère :
- `dist/tokens.css` — les CSS custom properties
- `dist/tokens.{js,cjs,d.ts}` — un objet JS/TS auto-completé pour le code
- `dist/figma-tokens.json` — pour le plugin Figma Tokens Studio
## Règles
- **Aucun hex en dur** dans les composants. Toujours via tokens.
- **Aucun `--mmg-color-synapse-*` direct** : utiliser `--mmg-color-accent-*`.
- **Pas de nouveau token sans changeset** : les tokens sont une API publique.
- **Renommage** = breaking change → bump major + codemod fourni.
@@ -0,0 +1,53 @@
---
title: Typographie
description: Figtree + échelle modulaire + line-heights.
---
## Famille
`Figtree` (Google Fonts) — sans-serif géométrique moderne. Fallback : `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`.
```css
font-family: var(--mmg-font-sans);
```
Mono : `ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace`.
## Échelle
| Token | px | Usage |
|---|---|---|
| `--mmg-font-size-xs` | 11 | annotations, métadonnées |
| `--mmg-font-size-sm` | 13 | UI dense (inputs, boutons sm), hint |
| `--mmg-font-size-base` | 15 | corps de texte |
| `--mmg-font-size-lg` | 17 | corps de texte plus aéré |
| `--mmg-font-size-xl` | 20 | h4 / sous-titres |
| `--mmg-font-size-2xl` | 24 | h3 |
| `--mmg-font-size-3xl` | 30 | h2 |
| `--mmg-font-size-4xl` | 36 | h1 |
| `--mmg-font-size-5xl` | 48 | hero / display |
## Weights
| Token | Valeur |
|---|---|
| `regular` | 400 |
| `medium` | 500 |
| `semi` | 600 |
| `bold` | 700 |
| `extra` | 800 |
## Line heights
- `tight` (1.2) — titres, boutons
- `snug` (1.4) — UI dense
- `normal` (1.6) — corps de texte
## B1 du CECRL — pour les sites publics
Espace-Client et sites publics doivent viser le **niveau B1 du CECRL** :
- Phrases courtes (< 25 mots).
- Vocabulaire courant.
- Pas de jargon ni d'acronymes non explicités.
- Voix active.
+45
View File
@@ -0,0 +1,45 @@
---
title: DSMMG — Design System ManageMate Group
description: Référence design + code pour les produits ManageMate. RGAA 4.1, headless-first, tokens W3C DTCG.
template: splash
hero:
tagline: La source de vérité visuelle, comportementale et accessible pour Synapse, HRTime, Forge, Orbit, MSLM et l'Espace-Client.
actions:
- text: Démarrer
link: /intro/
icon: right-arrow
variant: primary
- text: Storybook
link: https://design.managemate.fr/storybook
icon: external
variant: minimal
---
import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components";
## Pourquoi un DS ?
Un design system n'est pas un dossier de composants. C'est **la garantie** que toute UI sortie sous la marque ManageMate :
- respecte le **RGAA 4.1** (= WCAG 2.2 AA) — non-négociable, validé en CI ;
- est **cohérente cross-produit** — un bouton dans Synapse, HRTime, Forge ou Orbit a la même forme, le même contraste, le même comportement clavier ;
- est **theming user-aware** — chaque utilisateur peut choisir son accent parmi 9 presets validés.
## Couverture
<CardGrid>
<Card title="62 composants" icon="puzzle">React headless-first, sur primitives Radix UI + Floating UI.</Card>
<Card title="9 presets accent" icon="seti:image">Synapse (défaut), rose, blue, violet, green, amber, red, cyan, slate.</Card>
<Card title="3 densités" icon="setting">Comfortable / cozy / compact, adaptables par préférence user.</Card>
<Card title="2 thèmes" icon="moon">Light + dark, sémantique cohérente, accent qui pop sans noyer le contenu.</Card>
</CardGrid>
## Standards
| Niveau | Inspirations |
|---|---|
| Architecture & a11y | shadcn/ui, Radix UI, Ariakit, React Aria |
| Patterns SaaS dense | Linear, Notion, Vercel, Atlassian DS |
| Apps enterprise | IBM Carbon, Atlassian |
| Méthodologie a11y | DSFR, DSFR.gouv.fr |
| Mobile / mobile patterns | Apple HIG, Material Design 3 |
+29
View File
@@ -0,0 +1,29 @@
---
title: Introduction
description: Bienvenue dans le DSMMG.
---
Le **DSMMG** (Design System ManageMate Group) est la source de vérité unique pour toutes les interfaces des produits ManageMate.
## Architecture des packages
| Package | Rôle |
|---|---|
| `@managemate/tokens` | Source DTCG des tokens primitifs. Génère CSS, JS/TS, Figma. |
| `@managemate/css` | CSS vanilla — tokens, base reset, composants, utilitaires `@layer`. |
| `@managemate/icons` | 96 icônes (line + fill) Remix Icon, subset par icône possible. |
| `@managemate/react` | Composants React typés, headless-first, tree-shakable. |
## Principes
1. **Accessibilité = première classe.** RGAA 4.1, validé axe-core en CI. Refus de toute solution qui exclut une catégorie d'utilisateurs.
2. **Headless-first.** Les comportements (focus management, ARIA, keyboard nav) viennent de Radix UI. Le style vient des tokens DSMMG. Séparation stricte.
3. **Tokens partout.** Aucun hex en dur dans un composant. Couleur d'accent toujours via `--mmg-color-accent`, jamais `--mmg-color-synapse` direct.
4. **Tree-shakable.** Un fichier par composant. `sideEffects: false`. L'import d'un Button n'embarque pas le DataTable.
5. **Theming user-aware.** L'utilisateur choisit son accent. La sémantique (success/danger…) reste fixe.
6. **Pro-friendly.** Densité adaptable, raccourcis clavier first-class, bulk actions, optimistic UI, vues sauvegardées.
## Démarrer
→ [Installation](/intro/installation/)
→ [Migration v0.1 → v0.2](/intro/migration/)
@@ -0,0 +1,83 @@
---
title: Installation
description: Comment installer le DSMMG dans un projet ManageMate.
---
## Pré-requis
- Node ≥ 20
- pnpm ≥ 9 (ou npm/yarn équivalents)
- React ≥ 18
## Install
```sh
pnpm add @managemate/css @managemate/icons @managemate/react
```
## Setup minimal
### 1. Importer les CSS
```tsx
// app/main.tsx — ou _app.tsx en Next
import "@managemate/css";
import "@managemate/icons";
```
L'ordre n'a pas d'importance grâce à la cascade `@layer reset, tokens, base, components, utilities`.
### 2. (Optionnel) Mettre un thème initial
```tsx
import { applyTheme, applyAccent } from "@managemate/react";
// Dès le mount du root, idéalement avant React render
applyTheme("system"); // ou "light" / "dark"
applyAccent("synapse"); // ou un autre preset
```
Mieux : appliquer en SSR via un script inline pour éviter le flash :
```html
<script>
(function () {
try {
var t = localStorage.getItem("mmg-theme");
var a = localStorage.getItem("mmg-accent");
if (t && t !== "system") document.documentElement.setAttribute("data-mmg-theme", t);
if (a && a !== "synapse") document.documentElement.setAttribute("data-mmg-accent", a);
} catch (e) {}
})();
</script>
```
### 3. Wrapper Provider à la racine
```tsx
import { TooltipProvider, ToastProvider } from "@managemate/react";
<TooltipProvider>
<ToastProvider>
<App />
</ToastProvider>
</TooltipProvider>
```
### 4. Premier composant
```tsx
import { Button } from "@managemate/react";
<Button variant="primary" icon="arrow-right-line" iconPosition="right">
Continuer
</Button>
```
## Tree-shaking
Tous les exports sont nommés et `sideEffects: false`. L'import suivant ne tire que `Button` + `Icon` + `cx` :
```tsx
import { Button } from "@managemate/react";
```
+60
View File
@@ -0,0 +1,60 @@
---
title: Migration v0.1 → v0.2
description: Guide de migration vers la nouvelle architecture DSMMG.
---
La v0.2 est une **refonte architecturale** avec breaking changes. Elle prépare le DS à un usage réel cross-produit avec theming utilisateur, tree-shaking strict, primitives Radix.
## Codemod automatique
Un codemod est fourni pour renommer la majorité des tokens dans ton codebase consommateur :
```sh
node scripts/migrate-tokens.mjs
```
Il modifie en place les `.css` et `.tsx` selon une table de mapping idempotente.
## Token renames (mapping principal)
| Avant | Après |
|---|---|
| `--mmg-bg-page/surface/raised/muted/subtle/overlay` | `--mmg-color-bg-*` |
| `--mmg-text-1/2/3/4` | `--mmg-color-text-{primary,secondary,tertiary,quaternary}` |
| `--mmg-text-inverse` | `--mmg-color-text-inverse` |
| `--mmg-text-{xs..5xl}` | `--mmg-font-size-*` |
| `--mmg-leading-*` | `--mmg-line-height-*` |
| `--mmg-weight-*` | `--mmg-font-weight-*` |
| `--mmg-border/border-soft/border-strong` | `--mmg-color-border*` |
| `--mmg-brand-*` | `--mmg-color-accent-*` |
| `--mmg-success/warning/danger/info-*` | `--mmg-color-success/warning/danger/info-*` |
| `--mmg-shadow-brand-*` | `--mmg-shadow-accent-*` |
| `--mmg-art-*` | `--mmg-color-art-*` |
| `--mmg-state-*` | `--mmg-color-state-*` |
## API React — changements
### `Tooltip`, `Popover`, `Menu`, `Dialog`, `Combobox`
Réécrits sur **Radix UI + Floating UI**. L'API publique est globalement préservée mais :
- `Menu` : la prop `items[i].onClick` devient `onSelect` (cohérent avec Radix). Les flèches, type-ahead et focus management sont gérés natif.
- `Tooltip` : exige `<TooltipProvider>` à la racine. Les délais sont partagés entre tooltips voisins.
- `Combobox` : nouvelle prop `emptyMessage`, navigation `Home`/`End`, `aria-activedescendant`.
### Suppression du switch produit
`[data-mmg-product]` est retiré au profit de `[data-mmg-accent]`. Chaque utilisateur choisit son accent (par défaut Synapse). Voir [Architecture accent](/theming/architecture/).
### Composants splittés
`Advanced.tsx`, `Chrome.tsx`, `Article.tsx` ont été éclatés en un fichier par composant. Imports inchangés via le barrel.
## Nouveautés
- 9 presets accent + ramps -50/-900 + `-on` validé AA
- Composant `<ThemePicker />` + hooks `useAccent`, `useTheme`
- Cascade `@layer reset, tokens, base, components, utilities`
- Utilitaires CSS `mmg-u-*` (stack, flex, grid, text, bg, padding, margin)
- Build packages — distribution via `dist/` (ESM + CJS + types)
- Storybook + tests Vitest + axe-core en CI
+18
View File
@@ -0,0 +1,18 @@
---
title: Patterns
description: Patterns d'interaction et de composition récurrents dans les apps ManageMate.
---
## Power user patterns
- **Densité adaptable** — 3 modes (comfortable / cozy / compact).
- **Raccourcis clavier first-class** — registry centralisé, cmd+K, séquences vim-style.
- **Bulk actions** — toolbar contextuelle sticky sur sélection.
- **Vues sauvegardées** — filtres + colonnes + tri + group sous un nom partageable.
- **Optimistic UI** — actions appliquées instantanément, réconciliation serveur.
- **Inline editing** — clic = édition, Enter valide, Esc annule.
- **Recherche fuzzy partout** — typos tolérées, highlight des matches.
- **Drag and drop avec alternative clavier** — toujours.
- **Empty states contextuels** — pas de "no data" générique.
Détails et exemples → en cours, à compléter.
@@ -0,0 +1,102 @@
---
title: Architecture du theming user
description: Comment l'accent personnalisable est structuré dans le DSMMG.
---
## Le problème
Plusieurs apps ManageMate (Synapse, HRTime, Forge, Orbit, MSLM) doivent permettre à chaque utilisateur de **choisir sa couleur d'accent**, tout en garantissant :
- **WCAG AA** sur tous les fonds (light, dark, raised).
- **Consistance cross-produit** : le bouton primary fait la même *forme*, juste une autre teinte.
- **Pas de flash** au chargement (FOUC).
- **Sémantique préservée** : un Alert danger reste rouge même si l'accent est rouge.
## La solution
Indirection systématique :
```
[data-mmg-accent="<preset>"] ─┐
├─→ --mmg-color-accent (et dérivés) ─→ Composants
[data-mmg-theme="dark"] ─┘
```
### 1. Les composants consomment `--mmg-color-accent-*`
```css
.mmg-btn--primary {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
box-shadow: var(--mmg-shadow-accent);
}
```
**Jamais** :
```css
.mmg-btn--primary {
background: var(--mmg-color-synapse-500); /* ❌ casse le theming user */
}
```
### 2. Les presets posent les valeurs
`packages/css/src/tokens/accent.css` définit pour chaque preset :
```css
[data-mmg-accent="blue"] {
--mmg-color-accent: var(--mmg-color-blue-500);
--mmg-color-accent-hover: var(--mmg-color-blue-600);
--mmg-color-accent-active: var(--mmg-color-blue-700);
--mmg-color-accent-soft: var(--mmg-color-blue-50);
--mmg-color-accent-border: var(--mmg-color-blue-200);
--mmg-color-accent-strong: var(--mmg-color-blue-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
```
### 3. Le hook `useAccent` synchronise localStorage et `<html>`
```tsx
import { useAccent } from "@managemate/react";
const { accent, setAccent, reset } = useAccent();
```
Ou impératif :
```ts
import { applyAccent } from "@managemate/react";
applyAccent("blue", true); // persist=true par défaut
```
### 4. Pour éviter le flash en SSR
Inclure ce script dans le `<head>` :
```html
<script>
(function () {
try {
var v = localStorage.getItem("mmg-accent");
if (v && v !== "synapse") document.documentElement.setAttribute("data-mmg-accent", v);
} catch (e) {}
})();
</script>
```
## Sémantique vs accent
> **L'accent est un accent, pas une dominante.** La sémantique prime sur la personnalisation.
| Token | Affecté par l'accent ? |
|---|---|
| `--mmg-color-accent-*` | ✅ Oui — c'est son rôle |
| `--mmg-color-success-*` | ❌ Non — toujours vert |
| `--mmg-color-danger-*` | ❌ Non — toujours rouge |
| `--mmg-color-warning-*` | ❌ Non — toujours ambre |
| `--mmg-color-info-*` | ❌ Non — toujours bleu |
| `--mmg-color-bg-*`, `--mmg-color-text-*` | ❌ Non — neutres |
Cas particulier : l'utilisateur peut choisir un accent **rouge**. Il sera distinguable de `danger` car les deux ont des soft/border/strong propres.
@@ -0,0 +1,39 @@
---
title: Mode sombre
description: Activation et personnalisation du dark mode.
---
## Toggle simple
```tsx
import { useTheme } from "@managemate/react";
const { theme, setTheme, toggle } = useTheme();
// theme === "light" | "dark" | "system"
```
## Tokens dark mode
Toutes les surfaces, textes, bordures et états ont leur version dark définie dans `tokens/semantic.css`. Les ombres deviennent **noires + halo blanc subtil** plutôt que noires pures (qui s'évaporent).
```css
[data-mmg-theme="dark"] {
--mmg-color-bg-page: #0B0D14;
--mmg-color-bg-surface: #14171F;
/* ... */
--mmg-shadow-1: 0 1px 3px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.04);
}
```
## Auto-system
Sans `[data-mmg-theme]`, le DS suit `prefers-color-scheme: dark`.
## Forced colors / High Contrast Mode (Windows)
`@media (forced-colors: active)` bascule sur les system colors :
- `Highlight` pour les états sélectionnés
- `Canvas`/`CanvasText` pour les surfaces
Les composants overlays (Tooltip, Dialog, Menu) ont une bordure `1px solid CanvasText` en HCM pour rester lisibles.
+36
View File
@@ -0,0 +1,36 @@
---
title: Presets accent
description: Les 9 couleurs d'accent disponibles dans le DSMMG.
---
| Preset | Hex (light) | Usage suggéré |
|---|---|---|
| `synapse` (défaut) | `#D12B6A` | ManageMate corporate, défaut partout |
| `rose` | `#E11D48` | Variante rose plus vif |
| `blue` | `#2563EB` | Apps banking / fintech / lourd-data |
| `violet` | `#7C3AED` | Forge, créatif, AI |
| `green` | `#0E9F6E` | HRTime, environnemental |
| `amber` | `#D97706` | Orbit, attention positive |
| `red` | `#DC2626` | Sites événementiels — ne pas confondre avec sémantique danger |
| `cyan` | `#0891B2` | SIRH analytics, dashboards |
| `slate` | `#475569` | Neutre haut contraste, brutalist |
## Tests de contraste — chaque preset
Chaque preset est validé contre 6 règles avant inclusion :
1. **Accent vs fond light** ≥ 4.5:1 (texte) ou 3:1 (composant non-texte).
2. **Accent vs fond dark** ≥ 4.5:1 / 3:1 (avec la version dark-tuned).
3. **`-on` vs `accent`** ≥ 4.5:1 (texte sur le bouton).
4. Focus ring distinguable sur tous les fonds (light, dark, raised).
5. Tous les états (hover/active/disabled) restent distinguables.
6. Pas de confusion avec les couleurs sémantiques (`accent === red``danger`).
## Dark mode — adaptation
En `[data-mmg-theme="dark"]`, l'accent est **éclairci et désaturé** pour confort visuel.
Référence : Linear, Stripe, Apple HIG. L'œil supporte mal les couleurs vives sur fond sombre.
Exemple Synapse :
- Light : `#D12B6A` (HSL 335 65% 50%)
- Dark : `#E83A7E` (HSL 335 78% 57%) — plus clair, légèrement plus saturé
+8
View File
@@ -0,0 +1,8 @@
/* Customisation Starlight pour matcher le DSMMG */
:root {
--sl-color-accent: var(--mmg-color-accent);
--sl-color-accent-low: var(--mmg-color-accent-soft);
--sl-color-accent-high: var(--mmg-color-accent-strong);
--sl-font: var(--mmg-font-sans);
--sl-font-mono: var(--mmg-font-mono);
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "managemate-ds",
"private": true,
"version": "0.1.0",
"description": "ManageMate Group Design System (DSMMG) — monorepo",
"license": "UNLICENSED",
"workspaces": [
"packages/*",
"demo"
],
"scripts": {
"build": "pnpm -r build",
"build:icons": "pnpm --filter @managemate/icons build",
"build:css": "pnpm --filter @managemate/css build",
"build:react": "pnpm --filter @managemate/react build",
"demo": "pnpm --filter demo dev",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"storybook": "pnpm --filter storybook dev",
"build-storybook": "pnpm --filter storybook build",
"tokens:build": "pnpm --filter @managemate/css tokens:build",
"size": "size-limit",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "pnpm build && changeset publish",
"migrate:tokens": "node scripts/migrate-tokens.mjs",
"lint:contrast": "node scripts/lint-contrast.mjs"
},
"devDependencies": {
"@changesets/cli": "^2.27.11",
"size-limit": "^11.1.6",
"@size-limit/preset-small-lib": "^11.1.6"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"packageManager": "pnpm@10.33.2"
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@managemate/css",
"version": "0.1.0",
"description": "DSMMG vanilla CSS — tokens, base, components, utilities",
"license": "UNLICENSED",
"private": true,
"sideEffects": [
"*.css"
],
"main": "./dist/index.css",
"style": "./dist/index.css",
"exports": {
".": {
"style": "./dist/index.css",
"default": "./dist/index.css"
},
"./tokens": "./dist/tokens.css",
"./base": "./dist/base.css",
"./utilities": "./dist/utilities.css",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "pnpm run build:bundle && pnpm run build:tokens && pnpm run build:base && pnpm run build:utilities",
"build:bundle": "postcss src/index.css --output dist/index.css",
"build:tokens": "postcss src/tokens.css --output dist/tokens.css",
"build:base": "postcss src/base.css --output dist/base.css",
"build:utilities": "postcss src/utilities.css --output dist/utilities.css",
"build:min": "cross-env NODE_ENV=production pnpm run build"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"cssnano": "^7.0.7",
"postcss": "^8.5.0",
"postcss-cli": "^11.0.0",
"postcss-import": "^16.1.0"
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @managemate/css — postcss build pipeline.
* Aplatit les @import (postcss-import) et conserve les @layer correctement.
* cssnano en production pour minification.
*/
import postcssImport from "postcss-import";
import autoprefixer from "autoprefixer";
import cssnano from "cssnano";
export default {
plugins: [
postcssImport(),
autoprefixer(),
process.env.NODE_ENV === "production" &&
cssnano({
preset: ["default", { discardComments: { removeAll: false } }],
}),
].filter(Boolean),
};
+177
View File
@@ -0,0 +1,177 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Reset + base
════════════════════════════════════════════════════════════════ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-base);
line-height: var(--mmg-line-height-normal);
color: var(--mmg-color-text-secondary);
background: var(--mmg-color-bg-page);
transition: background var(--mmg-duration-base) var(--mmg-ease-default),
color var(--mmg-duration-base) var(--mmg-ease-default);
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-family: var(--mmg-font-sans);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
line-height: var(--mmg-line-height-tight);
}
h1 { font-size: var(--mmg-font-size-4xl); letter-spacing: -0.02em; }
h2 { font-size: var(--mmg-font-size-3xl); letter-spacing: -0.01em; }
h3 { font-size: var(--mmg-font-size-2xl); }
h4 { font-size: var(--mmg-font-size-xl); }
h5 { font-size: var(--mmg-font-size-lg); }
h6 { font-size: var(--mmg-font-size-base); }
p { margin: 0; }
a {
color: var(--mmg-color-accent);
text-decoration: none;
transition: color var(--mmg-duration-fast) var(--mmg-ease-default);
}
a:hover { color: var(--mmg-color-accent-hover); text-decoration: underline; }
img, svg, video {
display: block;
max-width: 100%;
height: auto;
}
button {
font: inherit;
cursor: pointer;
}
input, textarea, select, button {
font-family: inherit;
}
code, pre {
font-family: var(--mmg-font-mono);
font-size: 0.9em;
}
/* Focus visible global */
:focus-visible {
outline: 2px solid var(--mmg-color-accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ════════════════════════════════════════════════════════════════
Sélection texte — accent marqué avec teinte chaude (Vercel-style).
Le user a explicitement demandé un effet visible. AA respecté :
accent à 35% + texte primary garde un contraste ≥ 4.5:1 sur la
plupart des fonds. Décoration text-shadow pour un effet "halo".
════════════════════════════════════════════════════════════════ */
::selection {
background: color-mix(in srgb, var(--mmg-color-accent) 36%, transparent);
color: var(--mmg-color-text-primary);
text-shadow: 0 0 8px color-mix(in srgb, var(--mmg-color-accent) 24%, transparent);
}
/* Sur fond accent (CTA, hero, badges solides) on inverse pour rester lisible. */
[data-mmg-on-accent] ::selection,
.mmg-hero ::selection,
.mmg-btn--primary ::selection,
.mmg-badge--solid ::selection {
background: color-mix(in srgb, var(--mmg-color-accent-on) 32%, transparent);
color: var(--mmg-color-accent-on);
text-shadow: none;
}
/* Scrollbars (WebKit + Firefox) ——————————————————— */
* {
scrollbar-width: thin;
scrollbar-color: var(--mmg-color-border-strong) transparent;
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--mmg-color-border-strong);
border-radius: 8px;
border: 2px solid var(--mmg-color-bg-page);
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--mmg-color-text-tertiary); background-clip: padding-box; }
*::-webkit-scrollbar-corner { background: transparent; }
/* kbd ——————————————————— */
kbd {
display: inline-block;
padding: 2px 6px;
font-family: var(--mmg-font-mono);
font-size: 0.85em;
font-weight: var(--mmg-font-weight-medium);
color: var(--mmg-color-text-secondary);
background: var(--mmg-color-bg-muted);
border: 1px solid var(--mmg-color-border);
border-bottom-width: 2px;
border-radius: 4px;
line-height: 1;
}
/* — prefers-reduced-motion ————————————————————————————
WCAG SC 2.3.3 (AAA) + bonne pratique AA. Coupe quasiment
toutes les animations pour les utilisateurs sensibles
(vestibulaire, migraine, attention). Conserve transitions
d'opacité < 200ms qui sont neutres. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
/* Skip link */
.mmg-skip-link {
position: absolute;
top: -200%;
left: var(--mmg-space-2);
z-index: var(--mmg-z-tooltip);
padding: var(--mmg-space-3) var(--mmg-space-4);
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border-radius: var(--mmg-radius-md);
text-decoration: none;
font-weight: var(--mmg-font-weight-semi);
}
.mmg-skip-link:focus {
top: var(--mmg-space-2);
}
/* Helpers visuels — ne pas dépendre de Tailwind */
.mmg-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+467
View File
@@ -0,0 +1,467 @@
/* ════════════════════════════════════════════════════════════════
Advanced — Stepper, Accordion, Tooltip, Tag, Progress, Drawer,
Menu, FAB, Highlight, Quote, Callout, FileUpload,
SearchBar, Combobox
════════════════════════════════════════════════════════════════ */
/* ─────────────────────────────────────────────
Stepper (DSFR style)
───────────────────────────────────────────── */
.mmg-stepper {
margin-bottom: var(--mmg-space-6);
}
.mmg-stepper__progress {
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mmg-color-accent);
margin-bottom: var(--mmg-space-2);
}
.mmg-stepper__title {
font-size: var(--mmg-font-size-xl);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin-bottom: var(--mmg-space-2);
}
.mmg-stepper__bar {
display: flex;
gap: 4px;
margin-bottom: var(--mmg-space-3);
}
.mmg-stepper__bar-item {
flex: 1;
height: 4px;
background: var(--mmg-color-bg-muted);
border-radius: 2px;
}
.mmg-stepper__bar-item--done { background: var(--mmg-color-accent); }
.mmg-stepper__bar-item--current { background: var(--mmg-color-accent); }
.mmg-stepper__next {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
}
.mmg-stepper__next strong { color: var(--mmg-color-text-primary); }
/* ─────────────────────────────────────────────
Accordion
───────────────────────────────────────────── */
.mmg-accordion {
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-accordion__btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mmg-space-3);
padding: var(--mmg-space-4) var(--mmg-space-1);
background: transparent;
border: 0;
text-align: left;
font-family: inherit;
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
cursor: pointer;
}
.mmg-accordion__btn:hover { color: var(--mmg-color-accent); }
.mmg-accordion__chevron {
transition: transform var(--mmg-duration-base) var(--mmg-ease-emphasis);
flex-shrink: 0;
}
.mmg-accordion[data-open="true"] .mmg-accordion__chevron {
transform: rotate(180deg);
}
.mmg-accordion__panel {
display: none;
padding: 0 var(--mmg-space-1) var(--mmg-space-4);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
line-height: var(--mmg-line-height-normal);
}
.mmg-accordion[data-open="true"] .mmg-accordion__panel {
display: block;
}
/* Tooltip legacy CSS supprimé : le composant utilise désormais Radix UI
Tooltip et les styles vivent dans components/overlays.css. Les anciennes
classes .mmg-tooltip-wrap / .mmg-tooltip--top|bottom|left|right /
.mmg-tooltip--visible étaient dead code et conflictuaient (opacity 0
par défaut → tooltip Radix invisible). */
/* ─────────────────────────────────────────────
Tag (différent du Badge)
───────────────────────────────────────────── */
.mmg-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-medium);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-sm);
background: var(--mmg-color-bg-surface);
color: var(--mmg-color-text-secondary);
}
.mmg-tag--clickable {
cursor: pointer;
transition: all var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-tag--clickable:hover {
background: var(--mmg-color-bg-muted);
border-color: var(--mmg-color-border-strong);
}
.mmg-tag--selected {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border-color: var(--mmg-color-accent);
}
/* ─────────────────────────────────────────────
Progress (linéaire)
───────────────────────────────────────────── */
.mmg-progress {
height: 8px;
background: var(--mmg-color-bg-muted);
border-radius: var(--mmg-radius-pill);
overflow: hidden;
}
.mmg-progress__bar {
height: 100%;
background: var(--mmg-color-accent);
transition: width var(--mmg-duration-base) var(--mmg-ease-emphasis);
border-radius: inherit;
}
.mmg-progress--success .mmg-progress__bar { background: var(--mmg-color-success); }
.mmg-progress--warning .mmg-progress__bar { background: var(--mmg-color-warning); }
.mmg-progress--danger .mmg-progress__bar { background: var(--mmg-color-danger); }
.mmg-progress--indeterminate .mmg-progress__bar {
width: 30% !important;
animation: mmg-progress-indeterminate 1.4s ease-in-out infinite;
}
@keyframes mmg-progress-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* ─────────────────────────────────────────────
Drawer (panneau latéral)
───────────────────────────────────────────── */
.mmg-drawer-backdrop {
position: fixed;
inset: 0;
background: var(--mmg-color-bg-overlay);
z-index: var(--mmg-z-modal);
animation: mmg-fade-in var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-drawer {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 400px;
max-width: 90vw;
background: var(--mmg-color-bg-surface);
box-shadow: var(--mmg-shadow-3);
z-index: calc(var(--mmg-z-modal) + 1);
display: flex;
flex-direction: column;
animation: mmg-drawer-in var(--mmg-duration-base) var(--mmg-ease-emphasis);
}
.mmg-drawer--left {
right: auto;
left: 0;
animation-name: mmg-drawer-in-left;
}
.mmg-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mmg-space-5) var(--mmg-space-6);
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-drawer__body {
flex: 1;
overflow-y: auto;
padding: var(--mmg-space-5) var(--mmg-space-6);
}
.mmg-drawer__footer {
padding: var(--mmg-space-4) var(--mmg-space-6);
border-top: 1px solid var(--mmg-color-border-soft);
display: flex;
justify-content: flex-end;
gap: var(--mmg-space-2);
}
@keyframes mmg-drawer-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes mmg-drawer-in-left {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* ─────────────────────────────────────────────
Menu / Dropdown
───────────────────────────────────────────── */
.mmg-menu {
position: absolute;
z-index: var(--mmg-z-dropdown);
min-width: 200px;
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
box-shadow: var(--mmg-shadow-2);
padding: var(--mmg-space-1);
animation: mmg-menu-in var(--mmg-duration-fast) var(--mmg-ease-emphasis);
}
@keyframes mmg-menu-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.mmg-menu__item {
display: flex;
align-items: center;
gap: var(--mmg-space-2);
width: 100%;
padding: 8px 12px;
background: transparent;
border: 0;
border-radius: var(--mmg-radius-sm);
font: inherit;
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
text-align: left;
cursor: pointer;
text-decoration: none;
}
.mmg-menu__item:hover {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
text-decoration: none;
}
.mmg-menu__item--danger { color: var(--mmg-color-danger); }
.mmg-menu__item--danger:hover { background: var(--mmg-color-danger-soft); color: var(--mmg-color-danger-strong); }
.mmg-menu__divider {
height: 1px;
background: var(--mmg-color-border-soft);
margin: var(--mmg-space-1) 0;
border: 0;
}
.mmg-menu__label {
padding: 6px 12px 4px;
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-quaternary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ─────────────────────────────────────────────
FAB (Floating Action Button — Material style)
───────────────────────────────────────────── */
.mmg-fab {
position: fixed;
bottom: var(--mmg-space-6);
right: var(--mmg-space-6);
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border: 0;
box-shadow: var(--mmg-shadow-3);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
z-index: var(--mmg-z-sticky);
transition: transform var(--mmg-duration-fast) var(--mmg-ease-emphasis),
background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-fab:hover {
transform: scale(1.08);
background: var(--mmg-color-accent-hover);
}
.mmg-fab:active { transform: scale(0.96); }
/* ─────────────────────────────────────────────
Highlight (DSFR) — citation/encart marqué
───────────────────────────────────────────── */
.mmg-highlight {
border-left: 4px solid var(--mmg-color-accent);
padding: var(--mmg-space-4) var(--mmg-space-5);
background: var(--mmg-color-accent-soft);
border-radius: 0 var(--mmg-radius-md) var(--mmg-radius-md) 0;
font-size: var(--mmg-font-size-base);
color: var(--mmg-color-text-primary);
}
/* ─────────────────────────────────────────────
Quote (citation visuelle, plus chargée)
───────────────────────────────────────────── */
.mmg-quote {
position: relative;
padding: var(--mmg-space-6) var(--mmg-space-7);
background: var(--mmg-color-bg-surface);
border-radius: var(--mmg-radius-card);
border: 1px solid var(--mmg-color-border);
font-size: var(--mmg-font-size-lg);
line-height: var(--mmg-line-height-snug);
color: var(--mmg-color-text-primary);
font-style: italic;
}
.mmg-quote::before {
content: "“";
position: absolute;
top: -8px;
left: var(--mmg-space-4);
font-size: 80px;
line-height: 1;
color: var(--mmg-color-accent);
font-family: Georgia, serif;
font-style: normal;
}
.mmg-quote__author {
display: block;
margin-top: var(--mmg-space-3);
font-size: var(--mmg-font-size-sm);
font-style: normal;
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-tertiary);
}
/* ─────────────────────────────────────────────
Callout (mise en avant horizontale)
───────────────────────────────────────────── */
.mmg-callout {
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
padding: var(--mmg-space-6);
background: var(--mmg-color-bg-raised);
border-radius: var(--mmg-radius-card);
border: 1px solid var(--mmg-color-border);
}
.mmg-callout__title {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin: 0;
}
.mmg-callout__body {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
}
/* ─────────────────────────────────────────────
Banner (annonce hero secondaire)
───────────────────────────────────────────── */
.mmg-banner {
display: flex;
align-items: center;
gap: var(--mmg-space-4);
padding: var(--mmg-space-5) var(--mmg-space-6);
background: linear-gradient(120deg, var(--mmg-color-accent) 0%, var(--mmg-color-accent-strong) 100%);
color: var(--mmg-color-accent-on);
border-radius: var(--mmg-radius-card);
box-shadow: var(--mmg-shadow-2);
}
.mmg-banner__title {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
margin: 0;
color: inherit; /* hérite la couleur blanche du parent .mmg-banner */
}
.mmg-banner__desc {
font-size: var(--mmg-font-size-sm);
opacity: 0.92;
color: inherit;
}
.mmg-banner__action {
margin-left: auto;
}
/* ─────────────────────────────────────────────
File upload (drag & drop)
───────────────────────────────────────────── */
.mmg-upload {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mmg-space-2);
padding: var(--mmg-space-7) var(--mmg-space-5);
background: var(--mmg-color-bg-raised);
border: 2px dashed var(--mmg-color-border-strong);
border-radius: var(--mmg-radius-card);
text-align: center;
cursor: pointer;
transition: border-color var(--mmg-duration-fast) var(--mmg-ease-default),
background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-upload:hover,
.mmg-upload[data-dragging="true"] {
border-color: var(--mmg-color-accent);
background: var(--mmg-color-accent-soft);
}
.mmg-upload__icon {
font-size: 32px;
color: var(--mmg-color-accent);
}
.mmg-upload__text {
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
}
.mmg-upload__hint {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
}
.mmg-upload input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
/* ─────────────────────────────────────────────
Search bar (combinée — input + button)
───────────────────────────────────────────── */
.mmg-search {
display: flex;
align-items: stretch;
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-pill);
overflow: hidden;
transition: border-color var(--mmg-duration-fast) var(--mmg-ease-default),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-search:focus-within {
border-color: var(--mmg-color-accent);
box-shadow: var(--mmg-shadow-focus);
}
.mmg-search__input {
flex: 1;
border: 0;
padding: 10px 18px;
background: transparent;
font: inherit;
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-primary);
outline: none;
}
.mmg-search__btn {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border: 0;
padding: 0 var(--mmg-space-5);
font-weight: var(--mmg-font-weight-semi);
font-size: var(--mmg-font-size-sm);
cursor: pointer;
}
.mmg-search__btn:hover { background: var(--mmg-color-accent-hover); }
+428
View File
@@ -0,0 +1,428 @@
/* ════════════════════════════════════════════════════════════════
Article — pages de contenu type service-public.gouv.fr
════════════════════════════════════════════════════════════════ */
.mmg-article {
background: var(--mmg-color-bg-page);
}
/* ─────────────────────────────────────────────
Header de l'article
───────────────────────────────────────────── */
.mmg-article__header {
background: var(--mmg-color-bg-surface);
border-bottom: 1px solid var(--mmg-color-border-soft);
padding-block: var(--mmg-space-7) var(--mmg-space-9);
}
.mmg-article__eyebrow {
display: inline-flex;
align-items: center;
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mmg-color-accent);
margin-bottom: var(--mmg-space-3);
}
.mmg-article__title {
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.15;
letter-spacing: -0.02em;
font-weight: var(--mmg-font-weight-extra);
color: var(--mmg-color-text-primary);
margin: 0;
max-width: 24ch;
}
.mmg-article__lead {
font-size: var(--mmg-font-size-xl);
line-height: var(--mmg-line-height-snug);
color: var(--mmg-color-text-secondary);
margin-top: var(--mmg-space-5);
max-width: 60ch;
}
.mmg-article__meta {
margin-top: var(--mmg-space-5);
display: flex;
flex-wrap: wrap;
gap: var(--mmg-space-4);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
}
/* ─────────────────────────────────────────────
Layout principal : main + aside
───────────────────────────────────────────── */
.mmg-article__layout {
max-width: var(--mmg-container-max);
margin-inline: auto;
padding-inline: var(--mmg-space-5);
padding-block: var(--mmg-space-9);
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: var(--mmg-space-9);
}
@media (min-width: 768px) {
.mmg-article__layout { padding-inline: var(--mmg-space-7); }
}
@media (max-width: 1024px) {
.mmg-article__layout { grid-template-columns: 1fr; gap: var(--mmg-space-7); }
}
.mmg-article__main { min-width: 0; max-width: 720px; }
/* ─────────────────────────────────────────────
Prose — typographie pour contenu rédactionnel
───────────────────────────────────────────── */
.mmg-prose {
color: var(--mmg-color-text-secondary);
font-size: var(--mmg-font-size-base);
line-height: 1.7;
}
.mmg-prose > * + * {
margin-top: var(--mmg-space-4);
}
.mmg-prose > h2 {
margin-top: var(--mmg-space-9);
margin-bottom: var(--mmg-space-3);
font-size: var(--mmg-font-size-2xl);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.01em;
color: var(--mmg-color-text-primary);
scroll-margin-top: 100px;
position: relative;
}
/* Bar horizontal au-dessus du titre — DSFR/Apple HIG style.
Aligné gauche sur le titre, hauteur fine, jamais en marge. */
.mmg-prose > h2::before {
content: "";
display: block;
width: 40px;
height: 4px;
background: var(--mmg-color-accent);
border-radius: 2px;
margin-bottom: var(--mmg-space-3);
}
.mmg-prose > h3 {
margin-top: var(--mmg-space-7);
margin-bottom: var(--mmg-space-2);
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
scroll-margin-top: 100px;
}
.mmg-prose > p {
margin: 0;
}
.mmg-prose > ul,
.mmg-prose > ol {
padding-left: var(--mmg-space-5);
display: flex;
flex-direction: column;
gap: var(--mmg-space-2);
}
.mmg-prose > ul { list-style: disc; }
.mmg-prose > ol { list-style: decimal; }
.mmg-prose strong { color: var(--mmg-color-text-primary); font-weight: var(--mmg-font-weight-bold); }
.mmg-prose a {
color: var(--mmg-color-accent);
text-decoration: underline;
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
}
.mmg-prose a:hover {
text-decoration-thickness: 2px;
color: var(--mmg-color-accent-hover);
}
.mmg-prose code {
font-family: var(--mmg-font-mono);
font-size: 0.9em;
background: var(--mmg-color-bg-muted);
padding: 2px 6px;
border-radius: 4px;
color: var(--mmg-color-text-primary);
}
.mmg-prose pre {
background: var(--mmg-color-bg-muted);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
padding: var(--mmg-space-4);
overflow-x: auto;
font-size: var(--mmg-font-size-sm);
}
.mmg-prose blockquote {
border-left: 4px solid var(--mmg-color-accent);
padding-left: var(--mmg-space-4);
margin: 0;
color: var(--mmg-color-text-secondary);
font-style: italic;
}
.mmg-prose hr {
margin-block: var(--mmg-space-7);
border: 0;
height: 1px;
background: var(--mmg-color-border-soft);
}
/* — Callout DSFR-style dans le corps ——————————————————— */
.mmg-prose__callout {
display: flex;
gap: var(--mmg-space-3);
padding: var(--mmg-space-4) var(--mmg-space-5);
border-radius: var(--mmg-radius-md);
border-left: 4px solid var(--mmg-color-info);
background: var(--mmg-color-info-soft);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-sm);
line-height: 1.6;
margin-block: var(--mmg-space-5);
}
.mmg-prose__callout--warning {
border-left-color: var(--mmg-color-warning);
background: var(--mmg-color-warning-soft);
}
.mmg-prose__callout--important {
border-left-color: var(--mmg-color-danger);
background: var(--mmg-color-danger-soft);
}
.mmg-prose__callout-icon { flex-shrink: 0; margin-top: 2px; }
.mmg-prose__callout-icon[class*="warning"] { color: var(--mmg-color-warning); }
.mmg-prose__callout-icon[class*="important"] { color: var(--mmg-color-danger); }
.mmg-prose__callout-icon { color: var(--mmg-color-info); }
.mmg-prose__callout--warning .mmg-prose__callout-icon { color: var(--mmg-color-warning); }
.mmg-prose__callout--important .mmg-prose__callout-icon { color: var(--mmg-color-danger); }
.mmg-prose__callout-title {
display: block;
color: var(--mmg-color-text-primary);
margin-bottom: 4px;
}
/* ─────────────────────────────────────────────
Aside — Voir aussi, documents
───────────────────────────────────────────── */
.mmg-aside {
display: flex;
flex-direction: column;
gap: var(--mmg-space-7);
}
.mmg-aside--sticky {
position: sticky;
top: 100px;
align-self: start;
}
.mmg-aside__section {
border-top: 2px solid var(--mmg-color-accent);
padding-top: var(--mmg-space-4);
}
.mmg-aside__title {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mmg-color-text-primary);
margin: 0 0 var(--mmg-space-3);
}
.mmg-aside__links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.mmg-aside__links a {
display: flex;
align-items: flex-start;
gap: var(--mmg-space-2);
padding: var(--mmg-space-2) 0;
color: var(--mmg-color-text-secondary);
font-size: var(--mmg-font-size-sm);
line-height: 1.5;
text-decoration: none;
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-aside__links a:hover {
color: var(--mmg-color-accent);
text-decoration: underline;
}
.mmg-aside__links a > [class*="mmg-icon"] {
flex-shrink: 0;
margin-top: 4px;
color: var(--mmg-color-accent);
}
.mmg-aside__links li:last-child a { border-bottom: 0; }
.mmg-aside__docs {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--mmg-space-2);
}
.mmg-aside__docs a {
display: flex;
align-items: flex-start;
gap: var(--mmg-space-3);
padding: var(--mmg-space-3);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
text-decoration: none;
color: var(--mmg-color-text-secondary);
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-aside__docs a:hover {
background: var(--mmg-color-bg-raised);
border-color: var(--mmg-color-accent-border);
}
.mmg-aside__docs a > [class*="mmg-icon"] {
flex-shrink: 0;
color: var(--mmg-color-danger);
}
.mmg-aside__doc-label {
display: block;
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-sm);
}
.mmg-aside__doc-meta {
display: block;
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
margin-top: 2px;
}
/* ─────────────────────────────────────────────
TOC — table des matières
───────────────────────────────────────────── */
.mmg-toc__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.mmg-toc__link {
display: flex;
gap: var(--mmg-space-2);
padding: var(--mmg-space-2) var(--mmg-space-3);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
text-decoration: none;
line-height: 1.4;
border-left: 2px solid var(--mmg-color-border-soft);
transition: all var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-toc__link:hover {
color: var(--mmg-color-text-primary);
border-left-color: var(--mmg-color-text-tertiary);
}
.mmg-toc__link--active {
color: var(--mmg-color-accent);
border-left-color: var(--mmg-color-accent);
font-weight: var(--mmg-font-weight-semi);
background: var(--mmg-color-accent-soft);
}
.mmg-toc__num {
color: var(--mmg-color-text-quaternary);
font-family: var(--mmg-font-mono);
font-size: var(--mmg-font-size-xs);
flex-shrink: 0;
}
/* ─────────────────────────────────────────────
Article footer — last update + share + feedback
───────────────────────────────────────────── */
.mmg-article__footer {
margin-top: var(--mmg-space-9);
padding-top: var(--mmg-space-7);
border-top: 1px solid var(--mmg-color-border-soft);
display: flex;
flex-direction: column;
gap: var(--mmg-space-5);
}
.mmg-article__updated {
display: inline-flex;
align-items: center;
gap: var(--mmg-space-2);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
}
.mmg-article__updated [class*="mmg-icon"] { color: var(--mmg-color-text-tertiary); }
.mmg-article__share {
display: flex;
align-items: center;
gap: var(--mmg-space-3);
flex-wrap: wrap;
}
.mmg-article__share-label {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
}
.mmg-article__share-btn {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: 50%;
cursor: pointer;
color: var(--mmg-color-text-secondary);
text-decoration: none;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
color var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-article__share-btn:hover {
background: var(--mmg-color-accent-soft);
border-color: var(--mmg-color-accent-border);
color: var(--mmg-color-accent);
}
.mmg-article__feedback {
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
padding: var(--mmg-space-5);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--mmg-space-3);
}
.mmg-article__feedback-label {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
}
.mmg-article__feedback-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--mmg-color-bg-page);
border: 1px solid var(--mmg-color-border-strong);
border-radius: var(--mmg-radius-pill);
font: inherit;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
cursor: pointer;
min-height: 36px;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-article__feedback-btn:hover {
background: var(--mmg-color-accent-soft);
border-color: var(--mmg-color-accent);
color: var(--mmg-color-accent-strong);
}
+108
View File
@@ -0,0 +1,108 @@
/* ════════════════════════════════════════════════════════════════
Avatar — initiales, image, statut, couleurs auto-générées.
════════════════════════════════════════════════════════════════ */
.mmg-avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
font-weight: var(--mmg-font-weight-semi);
font-size: var(--mmg-font-size-sm);
line-height: 1;
letter-spacing: -0.01em;
user-select: none;
text-transform: uppercase;
}
.mmg-avatar > img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Sizes */
.mmg-avatar--xs { width: 20px; height: 20px; font-size: 9px; }
.mmg-avatar--sm { width: 28px; height: 28px; font-size: 11px; }
.mmg-avatar--md { width: 40px; height: 40px; font-size: var(--mmg-font-size-sm); }
.mmg-avatar--lg { width: 56px; height: 56px; font-size: var(--mmg-font-size-lg); }
.mmg-avatar--xl { width: 72px; height: 72px; font-size: var(--mmg-font-size-xl); }
.mmg-avatar--2xl { width: 96px; height: 96px; font-size: var(--mmg-font-size-2xl); }
/* Shape */
.mmg-avatar--square { border-radius: 12px; }
.mmg-avatar--xs.mmg-avatar--square { border-radius: 4px; }
.mmg-avatar--sm.mmg-avatar--square { border-radius: 6px; }
.mmg-avatar--lg.mmg-avatar--square { border-radius: 14px; }
.mmg-avatar--xl.mmg-avatar--square { border-radius: 16px; }
.mmg-avatar--2xl.mmg-avatar--square { border-radius: 20px; }
/* Bordure (utile pour AvatarGroup) — utilise box-shadow pour ne pas
décaler la taille interne. */
.mmg-avatar--bordered {
box-shadow: 0 0 0 2px var(--mmg-color-bg-surface);
}
/* Couleurs auto-générées des initiales — palette catégorielle stable. */
.mmg-avatar--neutral { background: var(--mmg-color-bg-muted); color: var(--mmg-color-text-secondary); }
.mmg-avatar--brand { background: var(--mmg-color-accent-soft); color: var(--mmg-color-accent-strong); }
.mmg-avatar--blue { background: var(--mmg-color-blue-100); color: var(--mmg-color-blue-800); }
.mmg-avatar--green { background: var(--mmg-color-green-100); color: var(--mmg-color-green-800); }
.mmg-avatar--amber { background: var(--mmg-color-amber-100); color: var(--mmg-color-amber-800); }
.mmg-avatar--violet { background: var(--mmg-color-violet-100); color: var(--mmg-color-violet-800); }
[data-mmg-theme="dark"] .mmg-avatar--blue { background: rgba(96, 165, 250, 0.18); color: var(--mmg-color-blue-d-300); }
[data-mmg-theme="dark"] .mmg-avatar--green { background: rgba(52, 211, 153, 0.18); color: var(--mmg-color-green-d-300); }
[data-mmg-theme="dark"] .mmg-avatar--amber { background: rgba(251, 191, 36, 0.18); color: var(--mmg-color-amber-d-300); }
[data-mmg-theme="dark"] .mmg-avatar--violet { background: rgba(167, 139, 250, 0.18); color: var(--mmg-color-violet-d-300); }
/* — Status indicator (presence) ————————————————————————— */
.mmg-avatar__status {
position: absolute;
right: 0;
bottom: 0;
width: 30%;
height: 30%;
min-width: 8px;
min-height: 8px;
max-width: 14px;
max-height: 14px;
border-radius: 50%;
box-shadow: 0 0 0 2px var(--mmg-color-bg-surface);
}
.mmg-avatar__status--online { background: var(--mmg-color-success); }
.mmg-avatar__status--away { background: var(--mmg-color-warning); }
.mmg-avatar__status--busy { background: var(--mmg-color-danger); }
.mmg-avatar__status--offline { background: var(--mmg-color-text-quaternary); }
/* AvatarGroup — empilement avec overlap négatif */
.mmg-avatar-group {
display: inline-flex;
align-items: center;
}
.mmg-avatar-group .mmg-avatar {
margin-left: -10px;
transition: transform var(--mmg-duration-fast) var(--mmg-ease-emphasis);
}
.mmg-avatar-group .mmg-avatar:first-child { margin-left: 0; }
.mmg-avatar-group .mmg-avatar:hover { transform: translateY(-2px); z-index: 1; }
.mmg-avatar-group--xs .mmg-avatar { margin-left: -6px; }
.mmg-avatar-group--sm .mmg-avatar { margin-left: -8px; }
.mmg-avatar-group--lg .mmg-avatar { margin-left: -14px; }
.mmg-avatar-group--xl .mmg-avatar { margin-left: -18px; }
.mmg-avatar-group--2xl .mmg-avatar { margin-left: -24px; }
.mmg-avatar-group__more {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-secondary);
font-size: var(--mmg-font-size-xs);
font-variant-numeric: tabular-nums;
}
+260
View File
@@ -0,0 +1,260 @@
/* ════════════════════════════════════════════════════════════════
Button — Material 3 / Fluent 2 inspired
- State layer overlay au hover (subtil, pas de flash de couleur)
- Variantes : primary, tonal, secondary, outlined, ghost, elevated, danger, success
- Radius modéré (10px) — moins agressif que pill, plus moderne que carré
════════════════════════════════════════════════════════════════ */
.mmg-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--mmg-space-2);
padding: 10px 22px;
font-family: inherit;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
line-height: 1.2;
letter-spacing: 0.005em;
border: 1px solid transparent;
border-radius: var(--mmg-radius-pill); /* Pill par défaut — Google M3 style */
background: transparent;
color: var(--mmg-color-text-primary);
cursor: pointer;
text-decoration: none;
user-select: none;
white-space: nowrap;
isolation: isolate;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default),
color var(--mmg-duration-fast) var(--mmg-ease-default),
box-shadow var(--mmg-duration-base) var(--mmg-ease-default),
transform var(--mmg-duration-fast) var(--mmg-ease-emphasis);
}
/* — State layer (overlay qui s'opacifie au hover/focus/press) — */
.mmg-btn::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--mmg-duration-fast) var(--mmg-ease-default);
z-index: -1;
}
.mmg-btn:hover:not(:disabled)::before { opacity: 0.12; }
.mmg-btn:focus-visible::before { opacity: 0.16; }
.mmg-btn:active:not(:disabled)::before { opacity: 0.22; }
.mmg-btn:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
}
.mmg-btn:active:not(:disabled) {
transform: scale(0.97);
}
/* Disabled — tokens dédiés (jamais opacity seule, pas accessible) */
.mmg-btn:disabled,
.mmg-btn[aria-disabled="true"] {
background: var(--mmg-color-state-disabled-bg) !important;
color: var(--mmg-color-state-disabled-text) !important;
border-color: var(--mmg-color-state-disabled-border) !important;
box-shadow: none !important;
cursor: not-allowed;
pointer-events: none;
}
.mmg-btn:disabled::before,
.mmg-btn[aria-disabled="true"]::before {
display: none;
}
/* — Variants ————————————————————— */
/* Primary — solid brand, ombre teintée marque pour cliquabilité */
.mmg-btn--primary {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border-color: transparent;
box-shadow: var(--mmg-shadow-accent);
}
.mmg-btn--primary::before { background: var(--mmg-color-accent-on); }
.mmg-btn--primary:hover:not(:disabled) {
color: var(--mmg-color-accent-on);
box-shadow: var(--mmg-shadow-accent-hover);
}
/* Tonal (Material 3) — fond pâle, texte foncé brand */
.mmg-btn--tonal {
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
border-color: transparent;
}
.mmg-btn--tonal::before { background: var(--mmg-color-accent); }
.mmg-btn--tonal:hover:not(:disabled) { color: var(--mmg-color-accent-strong); }
/* Secondary — outline rose */
.mmg-btn--secondary {
background: transparent;
color: var(--mmg-color-accent);
border-color: var(--mmg-color-accent);
}
.mmg-btn--secondary::before { background: var(--mmg-color-accent); }
.mmg-btn--secondary:hover:not(:disabled) { color: var(--mmg-color-accent-strong); border-color: var(--mmg-color-accent-strong); }
/* Tertiary (Fluent default) — neutre avec border */
.mmg-btn--tertiary {
background: var(--mmg-color-bg-surface);
color: var(--mmg-color-text-primary);
border-color: var(--mmg-color-border-strong);
}
.mmg-btn--tertiary::before { background: var(--mmg-color-text-primary); }
/* Dark : bg surface (#14171F) sur page (#0B0D14) = contraste 1.3:1, le
bouton "disparaît" dans le fond. On remonte sur bg-raised + on ajoute
un halo blanc pour décoller. */
[data-mmg-theme="dark"] .mmg-btn--tertiary {
background: var(--mmg-color-bg-raised);
border-color: var(--mmg-color-border-strong);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
}
/* Ghost (Fluent subtle) — transparent, hover révèle un fond */
.mmg-btn--ghost {
background: transparent;
color: var(--mmg-color-text-secondary);
border-color: transparent;
}
.mmg-btn--ghost::before { background: var(--mmg-color-text-primary); }
.mmg-btn--ghost:hover:not(:disabled) { color: var(--mmg-color-text-primary); }
/* Dark : pas de force sur le texte (sinon le hover perd sa différenciation).
text-secondary = #C9CED9 → contraste 11:1 sur bg-page #0B0D14 (AAA). */
/* Elevated (Material 3) — fond surface avec ombre, plane au hover */
.mmg-btn--elevated {
background: var(--mmg-color-bg-surface);
color: var(--mmg-color-accent);
border-color: transparent;
box-shadow: var(--mmg-shadow-elevated);
}
[data-mmg-theme="dark"] .mmg-btn--elevated {
background: var(--mmg-color-bg-raised);
}
.mmg-btn--elevated::before { background: var(--mmg-color-accent); }
.mmg-btn--elevated:hover:not(:disabled) {
box-shadow: var(--mmg-shadow-elevated-hover);
transform: translateY(-1px);
}
.mmg-btn--elevated:active:not(:disabled) { transform: translateY(0); }
/* Danger / Success */
.mmg-btn--danger {
background: var(--mmg-color-danger);
color: var(--mmg-color-accent-on);
border-color: transparent;
box-shadow: var(--mmg-shadow-flat);
}
.mmg-btn--danger::before { background: var(--mmg-color-accent-on); }
.mmg-btn--danger:hover:not(:disabled) { color: var(--mmg-color-accent-on); }
.mmg-btn--success {
background: var(--mmg-color-success);
color: var(--mmg-color-accent-on);
border-color: transparent;
box-shadow: var(--mmg-shadow-flat);
}
.mmg-btn--success::before { background: var(--mmg-color-accent-on); }
.mmg-btn--success:hover:not(:disabled) { color: var(--mmg-color-accent-on); }
/* — Sizes — radius pill par défaut, le padding gère la taille ————————— */
.mmg-btn--xs { padding: 4px 14px; font-size: var(--mmg-font-size-xs); }
.mmg-btn--sm { padding: 7px 18px; font-size: var(--mmg-font-size-xs); }
.mmg-btn--md { padding: 10px 22px; font-size: var(--mmg-font-size-sm); }
.mmg-btn--lg { padding: 13px 30px; font-size: var(--mmg-font-size-base); }
.mmg-btn--xl { padding: 17px 38px; font-size: var(--mmg-font-size-lg); }
/* — Icon-only (cercle parfait) ————————————— */
.mmg-btn--icon-only {
padding: 10px;
width: 40px;
height: 40px;
border-radius: 50%;
position: relative;
}
.mmg-btn--icon-only.mmg-btn--xs { padding: 4px; width: 28px; height: 28px; }
.mmg-btn--icon-only.mmg-btn--sm { padding: 7px; width: 34px; height: 34px; }
.mmg-btn--icon-only.mmg-btn--lg { padding: 13px; width: 48px; height: 48px; }
.mmg-btn--icon-only.mmg-btn--xl { padding: 17px; width: 56px; height: 56px; }
/* Touch target zone invisible — garantit 44×44 minimum (WCAG SC 2.5.5)
sans augmenter la taille visible. Active uniquement sous tailles 40px. */
.mmg-btn--icon-only::after {
content: "";
position: absolute;
inset: 50% 50%;
width: var(--mmg-touch-min);
height: var(--mmg-touch-min);
transform: translate(-50%, -50%);
z-index: 0;
}
.mmg-btn--icon-only.mmg-btn--lg::after,
.mmg-btn--icon-only.mmg-btn--xl::after {
/* La cible visible suffit déjà */
display: none;
}
/* — Square (alternative — radius modéré façon DSFR) ——————— */
.mmg-btn--square { border-radius: 8px; }
.mmg-btn--square.mmg-btn--lg { border-radius: 10px; }
.mmg-btn--square.mmg-btn--xl { border-radius: 12px; }
.mmg-btn--square.mmg-btn--icon-only { border-radius: 8px; }
/* Compat : alias inverse pour basculer EXPLICITEMENT en pill (déjà par défaut) */
.mmg-btn--pill { border-radius: var(--mmg-radius-pill); }
/* — Block (full width) ————————————— */
.mmg-btn--block { width: 100%; }
/* — Group ————————————————————— */
.mmg-btn-group {
display: inline-flex;
flex-wrap: wrap;
gap: var(--mmg-space-2);
}
.mmg-btn-group--vertical {
flex-direction: column;
align-items: stretch;
}
/* — Loading state ————————————————————— */
.mmg-btn--loading {
position: relative;
color: transparent !important;
pointer-events: none;
}
.mmg-btn--loading > * { visibility: hidden; }
.mmg-btn--loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: mmg-spin 0.7s linear infinite;
color: var(--mmg-color-accent-on);
visibility: visible;
z-index: 1;
}
.mmg-btn--secondary.mmg-btn--loading::after,
.mmg-btn--tertiary.mmg-btn--loading::after,
.mmg-btn--ghost.mmg-btn--loading::after,
.mmg-btn--tonal.mmg-btn--loading::after,
.mmg-btn--elevated.mmg-btn--loading::after {
color: var(--mmg-color-accent);
}
@keyframes mmg-spin {
to { transform: rotate(360deg); }
}
+926
View File
@@ -0,0 +1,926 @@
/* ════════════════════════════════════════════════════════════════
Chrome — Header, Footer, Breadcrumb, Sidebar, Topbar, Tabs, Pagination
════════════════════════════════════════════════════════════════ */
/* ─────────────────────────────────────────────
Header (landing/marketing) — DSFR multi-ligne
───────────────────────────────────────────── */
.mmg-header {
position: sticky;
top: 0;
z-index: var(--mmg-z-sticky);
background: var(--mmg-color-bg-surface);
border-bottom: 1px solid var(--mmg-color-border-soft);
backdrop-filter: blur(8px);
}
.mmg-header__inner {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
height: 64px;
gap: var(--mmg-space-5);
}
.mmg-header__brand {
display: inline-flex;
align-items: center;
gap: var(--mmg-space-2);
text-decoration: none;
color: var(--mmg-color-text-primary);
border-radius: var(--mmg-radius-md);
padding: 6px 10px;
/* Pas de margin négatif — laisse le grid faire l'alignement */
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
height: 44px;
}
.mmg-header__brand:hover,
.mmg-header__brand:focus-visible {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
text-decoration: none;
}
/* Logo carré, plus discret (pas de gradient/ombre) — Linear/Vercel-like */
.mmg-header__logo {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--mmg-color-accent);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mmg-color-accent-on);
font-weight: var(--mmg-font-weight-extra);
font-size: 12px;
letter-spacing: -0.02em;
flex-shrink: 0;
}
.mmg-header__name {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
letter-spacing: -0.005em;
}
/* Mode "institutional" — multi-ligne pour pages publiques type DSFR */
.mmg-header--institutional .mmg-header__inner {
height: 88px;
}
.mmg-header--institutional .mmg-header__brand {
height: 64px;
}
.mmg-header--institutional .mmg-header__logo {
width: 44px;
height: 44px;
border-radius: 10px;
font-size: 15px;
}
.mmg-header__brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.mmg-header__brand-top {
font-size: 10px;
font-weight: var(--mmg-font-weight-bold);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mmg-color-text-tertiary);
}
.mmg-header__service {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.01em;
color: var(--mmg-color-text-primary);
margin-top: 1px;
}
.mmg-header__service-tagline {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
margin-top: 1px;
font-weight: var(--mmg-font-weight-medium);
}
.mmg-header__nav {
display: flex;
align-items: center;
gap: var(--mmg-space-1);
height: 44px;
/* Aligné à gauche, adjacent au brand — pattern Linear/Vercel
(Le grid parent garantit l'alignement vertical exact) */
}
.mmg-header__nav a {
padding: 8px 14px;
border-radius: var(--mmg-radius-pill);
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-medium);
color: var(--mmg-color-text-secondary);
text-decoration: none;
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-header__nav a:hover {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
text-decoration: none;
}
.mmg-header__nav a[aria-current="page"] {
color: var(--mmg-color-accent);
font-weight: var(--mmg-font-weight-semi);
}
/* — Mega-menu (DSFR-style) ————————————————————————————— */
.mmg-header__nav-item {
position: relative;
}
.mmg-header__nav-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
background: transparent;
border: 0;
border-radius: var(--mmg-radius-pill);
font: inherit;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-medium);
color: var(--mmg-color-text-secondary);
cursor: pointer;
white-space: nowrap;
}
.mmg-header__nav-trigger:hover,
.mmg-header__nav-trigger[aria-expanded="true"] {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
}
.mmg-header__nav-trigger[aria-expanded="true"] .mmg-header__nav-chevron {
transform: rotate(180deg);
}
.mmg-header__nav-chevron {
transition: transform var(--mmg-duration-fast) var(--mmg-ease-emphasis);
font-size: 0.85em;
opacity: 0.7;
}
.mmg-megamenu {
position: absolute;
top: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
z-index: var(--mmg-z-dropdown);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-panel);
box-shadow: var(--mmg-shadow-3);
padding: var(--mmg-space-6);
min-width: 600px;
max-width: 880px;
animation: mmg-megamenu-in var(--mmg-duration-base) var(--mmg-ease-emphasis);
}
@keyframes mmg-megamenu-in {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.mmg-megamenu__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--mmg-space-6);
}
.mmg-megamenu__col-title {
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mmg-color-text-tertiary);
margin-bottom: var(--mmg-space-3);
}
.mmg-megamenu__list {
display: flex;
flex-direction: column;
gap: 2px;
list-style: none;
padding: 0;
margin: 0;
}
.mmg-megamenu__link {
display: flex;
align-items: flex-start;
gap: var(--mmg-space-3);
padding: var(--mmg-space-2) var(--mmg-space-3);
border-radius: var(--mmg-radius-md);
text-decoration: none;
color: var(--mmg-color-text-secondary);
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-megamenu__link:hover {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
text-decoration: none;
}
.mmg-megamenu__link-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--mmg-color-accent);
}
.mmg-megamenu__link-content { min-width: 0; }
.mmg-megamenu__link-label {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
display: block;
}
.mmg-megamenu__link-desc {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
display: block;
margin-top: 2px;
}
.mmg-megamenu__featured {
background: var(--mmg-color-accent-soft);
border-radius: var(--mmg-radius-card);
padding: var(--mmg-space-5);
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
}
.mmg-megamenu__featured-title {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-accent-strong);
}
.mmg-megamenu__featured-desc {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
line-height: var(--mmg-line-height-snug);
}
.mmg-header__actions {
display: flex;
align-items: center;
gap: var(--mmg-space-2);
height: 44px;
}
@media (max-width: 768px) {
.mmg-header__nav { display: none; }
}
/* ─────────────────────────────────────────────
Footer
───────────────────────────────────────────── */
.mmg-footer {
background: var(--mmg-color-bg-surface);
border-top: 1px solid var(--mmg-color-border);
padding-block: var(--mmg-space-9) var(--mmg-space-5);
margin-top: var(--mmg-space-12);
}
.mmg-footer__top {
display: grid;
grid-template-columns: 2fr repeat(3, 1fr);
gap: var(--mmg-space-7);
margin-bottom: var(--mmg-space-8);
}
@media (max-width: 768px) {
.mmg-footer__top { grid-template-columns: 1fr 1fr; }
}
.mmg-footer__col-title {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin-bottom: var(--mmg-space-3);
}
.mmg-footer__col-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--mmg-space-2);
}
.mmg-footer__col-list a {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
text-decoration: none;
}
.mmg-footer__col-list a:hover {
color: var(--mmg-color-accent);
text-decoration: underline;
}
.mmg-footer__bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--mmg-space-3);
padding-top: var(--mmg-space-5);
border-top: 1px solid var(--mmg-color-border-soft);
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-quaternary);
}
/* ─────────────────────────────────────────────
Breadcrumb
───────────────────────────────────────────── */
.mmg-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--mmg-space-1);
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
padding: var(--mmg-space-3) 0;
}
.mmg-breadcrumb a {
color: var(--mmg-color-text-tertiary);
text-decoration: none;
}
.mmg-breadcrumb a:hover {
color: var(--mmg-color-accent);
text-decoration: underline;
}
.mmg-breadcrumb__sep {
color: var(--mmg-color-text-quaternary);
margin: 0 4px;
}
.mmg-breadcrumb__current {
color: var(--mmg-color-text-primary);
font-weight: var(--mmg-font-weight-semi);
}
/* ─────────────────────────────────────────────
Sidebar (SaaS app shell)
───────────────────────────────────────────── */
.mmg-app-shell {
display: flex;
min-height: 100vh;
background: var(--mmg-color-bg-page);
}
.mmg-sidebar {
width: 240px;
background: var(--mmg-color-bg-surface);
border-right: 1px solid var(--mmg-color-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.mmg-sidebar__header {
padding: var(--mmg-space-4) var(--mmg-space-5);
border-bottom: 1px solid var(--mmg-color-border-soft);
display: flex;
align-items: center;
gap: var(--mmg-space-2);
}
.mmg-sidebar__body {
flex: 1;
overflow-y: auto;
padding: var(--mmg-space-3);
display: flex;
flex-direction: column;
gap: var(--mmg-space-1);
}
.mmg-sidebar__category {
font-size: 9px;
font-weight: var(--mmg-font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--mmg-color-text-quaternary);
padding: var(--mmg-space-3) var(--mmg-space-3) var(--mmg-space-1);
}
.mmg-sidebar__item {
display: flex;
align-items: center;
gap: var(--mmg-space-2);
padding: 8px var(--mmg-space-3);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
text-decoration: none;
border-radius: var(--mmg-radius-md);
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-sidebar__item:hover {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
text-decoration: none;
}
.mmg-sidebar__item[aria-current="page"] {
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
font-weight: var(--mmg-font-weight-semi);
}
.mmg-sidebar__footer {
padding: var(--mmg-space-3);
border-top: 1px solid var(--mmg-color-border-soft);
}
.mmg-app-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.mmg-topbar {
height: 56px;
background: var(--mmg-color-bg-surface);
border-bottom: 1px solid var(--mmg-color-border);
padding-inline: var(--mmg-space-5);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mmg-space-4);
flex-shrink: 0;
}
.mmg-topbar__title {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
}
.mmg-topbar__actions {
display: flex;
align-items: center;
gap: var(--mmg-space-2);
}
/* ─────────────────────────────────────────────
Tabs
───────────────────────────────────────────── */
.mmg-tabs {
display: flex;
border-bottom: 1px solid var(--mmg-color-border);
margin-bottom: var(--mmg-space-5);
gap: 0;
overflow-x: auto;
}
.mmg-tabs__btn {
background: transparent;
border: 0;
padding: var(--mmg-space-3) var(--mmg-space-5);
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-tertiary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color var(--mmg-duration-fast) var(--mmg-ease-default);
white-space: nowrap;
}
.mmg-tabs__btn:hover { color: var(--mmg-color-text-primary); }
.mmg-tabs__btn[aria-selected="true"] {
color: var(--mmg-color-accent);
border-bottom-color: var(--mmg-color-accent);
}
/* ─────────────────────────────────────────────
Pagination
───────────────────────────────────────────── */
.mmg-pagination {
display: flex;
align-items: center;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
}
.mmg-pagination li button,
.mmg-pagination li a {
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: var(--mmg-radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--mmg-color-text-secondary);
text-decoration: none;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-medium);
cursor: pointer;
}
.mmg-pagination li button:hover,
.mmg-pagination li a:hover {
background: var(--mmg-color-bg-muted);
text-decoration: none;
}
.mmg-pagination li [aria-current="page"] {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border-color: var(--mmg-color-accent);
}
/* ─────────────────────────────────────────────
Avatar
───────────────────────────────────────────── */
.mmg-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-bold);
overflow: hidden;
}
.mmg-avatar img { width: 100%; height: 100%; object-fit: cover; }
.mmg-avatar--sm { width: 28px; height: 28px; font-size: 10px; }
.mmg-avatar--lg { width: 48px; height: 48px; font-size: var(--mmg-font-size-sm); }
.mmg-avatar--xl { width: 64px; height: 64px; font-size: var(--mmg-font-size-base); }
/* ─────────────────────────────────────────────
DataTable (avancée)
───────────────────────────────────────────── */
.mmg-datatable {
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
overflow: hidden;
}
.mmg-datatable__toolbar {
display: flex;
align-items: center;
gap: var(--mmg-space-3);
padding: var(--mmg-space-3) var(--mmg-space-4);
border-bottom: 1px solid var(--mmg-color-border-soft);
background: var(--mmg-color-bg-raised);
}
.mmg-datatable__toolbar-search { flex: 1; max-width: 320px; }
.mmg-datatable__toolbar-actions {
display: flex;
gap: var(--mmg-space-2);
margin-left: auto;
}
.mmg-datatable__scroll {
overflow-x: auto;
}
.mmg-datatable table {
width: 100%;
border-collapse: collapse;
font-size: var(--mmg-font-size-sm);
}
.mmg-datatable th,
.mmg-datatable td {
text-align: left;
padding: var(--mmg-density-padding-y) var(--mmg-density-padding-x);
border-bottom: 1px solid var(--mmg-color-border-soft);
height: var(--mmg-density-row-height);
}
.mmg-datatable--dense th,
.mmg-datatable--dense td {
padding: 4px var(--mmg-space-3);
height: 32px;
}
.mmg-datatable th {
background: var(--mmg-color-bg-muted);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
user-select: none;
position: sticky;
top: 0;
z-index: 1;
}
.mmg-datatable th[data-sortable="true"] {
cursor: pointer;
}
.mmg-datatable th[data-sortable="true"]:hover {
background: var(--mmg-color-bg-subtle);
color: var(--mmg-color-accent);
}
.mmg-datatable__sort-indicator {
display: inline-flex;
margin-left: 4px;
opacity: 0.4;
transition: opacity var(--mmg-duration-fast);
}
.mmg-datatable th[aria-sort] .mmg-datatable__sort-indicator { opacity: 1; color: var(--mmg-color-accent); }
.mmg-datatable tbody tr { transition: background var(--mmg-duration-fast) var(--mmg-ease-default); }
.mmg-datatable tbody tr:hover { background: var(--mmg-color-bg-raised); }
.mmg-datatable tbody tr[data-selected="true"] { background: var(--mmg-color-accent-soft); }
.mmg-datatable tbody tr:last-child td { border-bottom: 0; }
.mmg-datatable__cell--checkbox { width: 40px; padding-right: 0; }
.mmg-datatable__footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--mmg-space-3);
padding: var(--mmg-space-3) var(--mmg-space-4);
border-top: 1px solid var(--mmg-color-border-soft);
background: var(--mmg-color-bg-raised);
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
}
.mmg-datatable__empty {
padding: var(--mmg-space-9) var(--mmg-space-5);
text-align: center;
color: var(--mmg-color-text-tertiary);
}
.mmg-datatable__empty h4 {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin: var(--mmg-space-3) 0 var(--mmg-space-1);
}
.mmg-datatable__empty p { font-size: var(--mmg-font-size-sm); margin-bottom: var(--mmg-space-3); }
.mmg-datatable__loading {
padding: var(--mmg-space-6);
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
align-items: center;
color: var(--mmg-color-text-tertiary);
}
/* ─────────────────────────────────────────────
Command Menu (Cmd+K — palette)
───────────────────────────────────────────── */
.mmg-cmdk-backdrop {
position: fixed;
inset: 0;
background: var(--mmg-color-bg-overlay);
z-index: var(--mmg-z-modal);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10vh var(--mmg-space-4) var(--mmg-space-4);
animation: mmg-fade-in var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-cmdk {
width: 100%;
max-width: 580px;
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-panel);
box-shadow: var(--mmg-shadow-3);
overflow: hidden;
animation: mmg-modal-in var(--mmg-duration-base) var(--mmg-ease-emphasis);
display: flex;
flex-direction: column;
max-height: 70vh;
}
.mmg-cmdk__input-wrap {
display: flex;
align-items: center;
gap: var(--mmg-space-3);
padding: var(--mmg-space-4) var(--mmg-space-5);
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-cmdk__input-wrap .mmg-icon { color: var(--mmg-color-text-tertiary); }
.mmg-cmdk__input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
font: inherit;
font-size: var(--mmg-font-size-base);
color: var(--mmg-color-text-primary);
}
.mmg-cmdk__input::placeholder { color: var(--mmg-color-text-quaternary); }
.mmg-cmdk__kbd {
font-size: var(--mmg-font-size-xs);
background: var(--mmg-color-bg-muted);
border: 1px solid var(--mmg-color-border);
border-radius: 4px;
padding: 2px 6px;
color: var(--mmg-color-text-tertiary);
font-family: var(--mmg-font-mono);
}
.mmg-cmdk__list {
flex: 1;
overflow-y: auto;
padding: var(--mmg-space-2);
}
.mmg-cmdk__group-label {
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-quaternary);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: var(--mmg-space-3) var(--mmg-space-3) var(--mmg-space-1);
}
.mmg-cmdk__item {
display: flex;
align-items: center;
gap: var(--mmg-space-3);
padding: 10px 12px;
border-radius: var(--mmg-radius-md);
cursor: pointer;
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
}
.mmg-cmdk__item[aria-selected="true"] {
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
}
.mmg-cmdk__item-shortcut {
margin-left: auto;
display: flex;
gap: 4px;
}
.mmg-cmdk__empty {
padding: var(--mmg-space-7) var(--mmg-space-5);
text-align: center;
color: var(--mmg-color-text-tertiary);
font-size: var(--mmg-font-size-sm);
}
.mmg-cmdk__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mmg-space-2) var(--mmg-space-4);
border-top: 1px solid var(--mmg-color-border-soft);
background: var(--mmg-color-bg-raised);
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-quaternary);
gap: var(--mmg-space-3);
}
.mmg-cmdk__footer-key {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* ─────────────────────────────────────────────
DatePicker
───────────────────────────────────────────── */
.mmg-datepicker {
position: absolute;
z-index: var(--mmg-z-dropdown);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
box-shadow: var(--mmg-shadow-2);
padding: var(--mmg-space-3);
width: 304px;
}
.mmg-datepicker__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--mmg-space-1) var(--mmg-space-2);
}
.mmg-datepicker__nav {
background: transparent;
border: 0;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mmg-color-text-secondary);
}
.mmg-datepicker__nav:hover { background: var(--mmg-color-bg-muted); color: var(--mmg-color-text-primary); }
.mmg-datepicker__title {
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-sm);
}
.mmg-datepicker__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: var(--mmg-space-1);
}
.mmg-datepicker__weekday {
font-size: 10px;
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-quaternary);
text-align: center;
text-transform: uppercase;
padding: 4px 0;
letter-spacing: 0.04em;
}
.mmg-datepicker__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.mmg-datepicker__day {
height: 36px;
background: transparent;
border: 0;
border-radius: var(--mmg-radius-md);
cursor: pointer;
font: inherit;
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.mmg-datepicker__day:hover:not(:disabled) {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
}
.mmg-datepicker__day--outside {
color: var(--mmg-color-text-quaternary);
}
.mmg-datepicker__day--today {
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-accent);
}
.mmg-datepicker__day--today::after {
content: "";
position: absolute;
bottom: 4px;
width: 4px;
height: 4px;
background: var(--mmg-color-accent);
border-radius: 50%;
}
.mmg-datepicker__day[aria-selected="true"] {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
font-weight: var(--mmg-font-weight-bold);
}
.mmg-datepicker__day[aria-selected="true"]::after { background: white; }
.mmg-datepicker__day:disabled { opacity: 0.35; cursor: not-allowed; }
/* Range picker — jours entre start et end */
.mmg-datepicker__day--between {
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
border-radius: 0;
}
.mmg-datepicker__day--endpoint {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
font-weight: var(--mmg-font-weight-bold);
}
.mmg-datepicker__footer {
display: flex;
justify-content: space-between;
padding: var(--mmg-space-2) var(--mmg-space-1) 0;
border-top: 1px solid var(--mmg-color-border-soft);
margin-top: var(--mmg-space-2);
}
/* ─────────────────────────────────────────────
Table (basique — conservée pour compat)
───────────────────────────────────────────── */
.mmg-table {
width: 100%;
border-collapse: collapse;
font-size: var(--mmg-font-size-sm);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
overflow: hidden;
}
.mmg-table th,
.mmg-table td {
text-align: left;
padding: var(--mmg-density-padding-y) var(--mmg-density-padding-x);
border-bottom: 1px solid var(--mmg-color-border-soft);
height: var(--mmg-density-row-height);
}
.mmg-table th {
background: var(--mmg-color-bg-muted);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mmg-table tbody tr:hover { background: var(--mmg-color-bg-raised); }
.mmg-table tbody tr:last-child td { border-bottom: 0; }
/* ─────────────────────────────────────────────
Stat card (dashboards)
───────────────────────────────────────────── */
.mmg-stat {
display: flex;
align-items: center;
gap: var(--mmg-space-3);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
padding: var(--mmg-space-4);
}
.mmg-stat__icon {
width: 40px;
height: 40px;
border-radius: var(--mmg-radius-pill);
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.mmg-stat__value {
font-size: var(--mmg-font-size-2xl);
font-weight: var(--mmg-font-weight-extra);
color: var(--mmg-color-text-primary);
line-height: 1;
}
.mmg-stat__label {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
margin-top: 2px;
}
+133
View File
@@ -0,0 +1,133 @@
/* ════════════════════════════════════════════════════════════════
Extras — SegmentedControl, DescriptionList, Sparkline, Kbd
════════════════════════════════════════════════════════════════ */
/* ─────────────────────────────────────────────
SegmentedControl (Apple HIG-style)
───────────────────────────────────────────── */
.mmg-segmented {
display: inline-flex;
padding: 3px;
background: var(--mmg-color-bg-muted);
border-radius: var(--mmg-radius-pill);
gap: 2px;
position: relative;
isolation: isolate;
}
.mmg-segmented--full { display: flex; width: 100%; }
.mmg-segmented__item {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
padding: 7px 16px;
background: transparent;
border: 0;
border-radius: var(--mmg-radius-pill);
font-family: inherit;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-medium);
color: var(--mmg-color-text-tertiary);
cursor: pointer;
white-space: nowrap;
/* Touch target */
min-height: 36px;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
color var(--mmg-duration-fast) var(--mmg-ease-default),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-segmented__item:hover:not(:disabled):not(.mmg-segmented__item--active) {
color: var(--mmg-color-text-primary);
}
.mmg-segmented__item:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
}
.mmg-segmented__item--active {
background: var(--mmg-color-bg-surface);
color: var(--mmg-color-text-primary);
font-weight: var(--mmg-font-weight-semi);
box-shadow: var(--mmg-shadow-1);
}
.mmg-segmented__item:disabled {
color: var(--mmg-color-state-disabled-text);
cursor: not-allowed;
}
.mmg-segmented--sm .mmg-segmented__item { padding: 5px 12px; font-size: var(--mmg-font-size-xs); min-height: 32px; }
.mmg-segmented--lg .mmg-segmented__item { padding: 10px 20px; font-size: var(--mmg-font-size-base); min-height: 44px; }
/* ─────────────────────────────────────────────
DescriptionList (DSFR-like)
───────────────────────────────────────────── */
.mmg-dlist {
margin: 0;
padding: 0;
}
.mmg-dlist__row {
padding: var(--mmg-space-3) 0;
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-dlist__row:last-child {
border-bottom: 0;
}
.mmg-dlist--horizontal .mmg-dlist__row {
display: grid;
grid-template-columns: minmax(180px, 1fr) 2fr;
gap: var(--mmg-space-4);
align-items: start;
}
.mmg-dlist--vertical .mmg-dlist__row {
display: flex;
flex-direction: column;
gap: 2px;
}
.mmg-dlist__label {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-tertiary);
margin: 0;
}
.mmg-dlist__value {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-primary);
margin: 0;
}
@media (max-width: 640px) {
.mmg-dlist--horizontal .mmg-dlist__row {
grid-template-columns: 1fr;
gap: 2px;
}
}
/* ─────────────────────────────────────────────
Sparkline
───────────────────────────────────────────── */
.mmg-sparkline {
display: block;
overflow: visible;
}
/* ─────────────────────────────────────────────
Kbd component (override de la base si besoin) —
.mmg-kbd reprend les styles de base sur kbd.
───────────────────────────────────────────── */
.mmg-kbd {
display: inline-block;
padding: 2px 6px;
font-family: var(--mmg-font-mono);
font-size: 0.85em;
font-weight: var(--mmg-font-weight-medium);
color: var(--mmg-color-text-secondary);
background: var(--mmg-color-bg-muted);
border: 1px solid var(--mmg-color-border);
border-bottom-width: 2px;
border-radius: 4px;
line-height: 1;
}
@@ -0,0 +1,123 @@
/* ════════════════════════════════════════════════════════════════
FeatureCard — icône + titre + description + lien.
Variant `glow` ajoute un border-gradient animé au hover (Vercel-style).
════════════════════════════════════════════════════════════════ */
.mmg-feature-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
padding: var(--mmg-space-6);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
isolation: isolate;
transition:
transform var(--mmg-duration-base) var(--mmg-ease-emphasis),
border-color var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-feature-card:hover {
transform: translateY(-2px);
border-color: var(--mmg-color-border-strong);
}
/* — Icon coloré ——————————————————————————————— */
.mmg-feature-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
margin-bottom: var(--mmg-space-2);
}
.mmg-feature-card__icon--brand { background: var(--mmg-color-accent-soft); color: var(--mmg-color-accent); }
.mmg-feature-card__icon--blue { background: var(--mmg-color-blue-100); color: var(--mmg-color-blue-600); }
.mmg-feature-card__icon--green { background: var(--mmg-color-green-100); color: var(--mmg-color-green-600); }
.mmg-feature-card__icon--amber { background: var(--mmg-color-amber-100); color: var(--mmg-color-amber-600); }
.mmg-feature-card__icon--violet { background: var(--mmg-color-violet-100); color: var(--mmg-color-violet-600); }
.mmg-feature-card__icon--neutral { background: var(--mmg-color-bg-muted); color: var(--mmg-color-text-secondary); }
[data-mmg-theme="dark"] .mmg-feature-card__icon--blue { background: rgba(96, 165, 250, 0.16); color: var(--mmg-color-blue-d-300); }
[data-mmg-theme="dark"] .mmg-feature-card__icon--green { background: rgba(52, 211, 153, 0.16); color: var(--mmg-color-green-d-300); }
[data-mmg-theme="dark"] .mmg-feature-card__icon--amber { background: rgba(251, 191, 36, 0.16); color: var(--mmg-color-amber-d-300); }
[data-mmg-theme="dark"] .mmg-feature-card__icon--violet { background: rgba(167, 139, 250, 0.16); color: var(--mmg-color-violet-d-300); }
.mmg-feature-card__title {
margin: 0;
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.01em;
color: var(--mmg-color-text-primary);
}
.mmg-feature-card__desc {
margin: 0;
font-size: var(--mmg-font-size-sm);
line-height: 1.55;
color: var(--mmg-color-text-tertiary);
}
.mmg-feature-card__link {
margin-top: auto;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-accent);
text-decoration: none;
transition: gap var(--mmg-duration-fast) var(--mmg-ease-emphasis);
}
.mmg-feature-card__link:hover {
gap: 8px;
text-decoration: none;
}
/* — Variant glow : hover propre (border accent + ombre teintée + lift) ——
L'ancienne version utilisait un conic-gradient masqué qui produisait
un effet fragmenté/coupé selon les browsers (mask-composite: exclude
pas toujours fiable, animation de rotation visible derrière le bord
arrondi). Cette version : bordure accent franche, ombre teintée à 3
niveaux, halo radial subtil interne, micro-lift de l'icône. Sobre,
universel, pas de transform: rotate qui peut "déborder". */
.mmg-feature-card--glow {
transition:
transform var(--mmg-duration-base) var(--mmg-ease-emphasis),
border-color var(--mmg-duration-base) var(--mmg-ease-default),
box-shadow var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-feature-card--glow:hover {
transform: translateY(-3px);
border-color: var(--mmg-color-accent);
box-shadow:
0 0 0 1px var(--mmg-color-accent),
0 12px 40px -10px color-mix(in srgb, var(--mmg-color-accent) 38%, transparent),
0 4px 12px -4px color-mix(in srgb, var(--mmg-color-accent) 22%, transparent);
}
.mmg-feature-card--glow .mmg-feature-card__icon {
transition: transform var(--mmg-duration-base) var(--mmg-ease-emphasis);
}
.mmg-feature-card--glow:hover .mmg-feature-card__icon {
transform: scale(1.06);
}
/* Halo radial interne très subtil — donne de la profondeur sans alourdir */
.mmg-feature-card--glow::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at 50% 0%,
color-mix(in srgb, var(--mmg-color-accent) 8%, transparent) 0%,
transparent 60%);
opacity: 0;
transition: opacity var(--mmg-duration-base) var(--mmg-ease-default);
pointer-events: none;
z-index: 0;
}
.mmg-feature-card--glow:hover::after { opacity: 1; }
.mmg-feature-card--glow > * { position: relative; z-index: 1; }
@media (prefers-reduced-motion: reduce) {
.mmg-feature-card--glow:hover { transform: none; }
.mmg-feature-card--glow:hover .mmg-feature-card__icon { transform: none; }
}
+529
View File
@@ -0,0 +1,529 @@
/* ════════════════════════════════════════════════════════════════
Feedback — Alert, Notice, Badge, Toast, Modal, Skeleton
════════════════════════════════════════════════════════════════ */
/* — Alert (contextuel sur la page) ————————— */
.mmg-alert {
display: flex;
align-items: flex-start;
gap: var(--mmg-space-3);
padding: var(--mmg-space-4) var(--mmg-space-5);
border: 1px solid;
border-left-width: 4px;
border-radius: var(--mmg-radius-md);
background: var(--mmg-color-info-soft);
border-color: var(--mmg-color-info-border);
border-left-color: var(--mmg-color-info);
}
.mmg-alert--success {
background: var(--mmg-color-success-soft);
border-color: var(--mmg-color-success-border);
border-left-color: var(--mmg-color-success);
}
.mmg-alert--warning {
background: var(--mmg-color-warning-soft);
border-color: var(--mmg-color-warning-border);
border-left-color: var(--mmg-color-warning);
}
.mmg-alert--danger {
background: var(--mmg-color-danger-soft);
border-color: var(--mmg-color-danger-border);
border-left-color: var(--mmg-color-danger);
}
.mmg-alert__icon {
flex-shrink: 0;
font-size: 20px;
line-height: 1;
margin-top: 2px;
}
.mmg-alert--success .mmg-alert__icon { color: var(--mmg-color-success); }
.mmg-alert--warning .mmg-alert__icon { color: var(--mmg-color-warning); }
.mmg-alert--danger .mmg-alert__icon { color: var(--mmg-color-danger); }
.mmg-alert--info .mmg-alert__icon, .mmg-alert__icon { color: var(--mmg-color-info); }
.mmg-alert__body { flex: 1; min-width: 0; }
.mmg-alert__title {
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin-bottom: 2px;
}
.mmg-alert__desc {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
}
.mmg-alert__close {
background: transparent;
border: 0;
cursor: pointer;
color: var(--mmg-color-text-tertiary);
font-size: 18px;
padding: 0;
margin-left: auto;
}
/* — Notice (bandeau pleine largeur) ————————— */
.mmg-notice {
width: 100%;
padding: var(--mmg-space-3) var(--mmg-space-5);
background: var(--mmg-color-accent-soft);
border-bottom: 1px solid var(--mmg-color-accent-border);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-accent-strong);
text-align: center;
}
.mmg-notice--info { background: var(--mmg-color-info-soft); border-color: var(--mmg-color-info-border); color: var(--mmg-color-info-strong); }
.mmg-notice--warning { background: var(--mmg-color-warning-soft); border-color: var(--mmg-color-warning-border); color: var(--mmg-color-warning-strong); }
.mmg-notice--danger { background: var(--mmg-color-danger-soft); border-color: var(--mmg-color-danger-border); color: var(--mmg-color-danger-strong); }
/* — Badge — refonte modern (Linear / Vercel / Stripe) ——————————————
Plus tight, texte légèrement uppercase tracking, dot indicator
intégré en option, hover lift discret. */
.mmg-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: var(--mmg-font-weight-semi);
letter-spacing: 0.005em;
line-height: 1.4;
border-radius: 6px;
border: 1px solid;
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-secondary);
border-color: var(--mmg-color-border);
white-space: nowrap;
vertical-align: middle;
}
/* Variantes "soft" — fond très léger + bordure teintée + texte foncé.
AA respecté sur tous les tones (text strong sur soft donne ≥ 4.5:1). */
.mmg-badge--brand { background: var(--mmg-color-accent-soft); color: var(--mmg-color-accent-strong); border-color: var(--mmg-color-accent-border); }
.mmg-badge--success { background: var(--mmg-color-success-soft); color: var(--mmg-color-success-strong); border-color: var(--mmg-color-success-border); }
.mmg-badge--warning { background: var(--mmg-color-warning-soft); color: var(--mmg-color-warning-strong); border-color: var(--mmg-color-warning-border); }
.mmg-badge--danger { background: var(--mmg-color-danger-soft); color: var(--mmg-color-danger-strong); border-color: var(--mmg-color-danger-border); }
.mmg-badge--info { background: var(--mmg-color-info-soft); color: var(--mmg-color-info-strong); border-color: var(--mmg-color-info-border); }
/* Variant solid — bouton-like, pleine couleur. À réserver pour CTA
discrets / notifications pulse / promo pricing. */
.mmg-badge--solid {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border-color: var(--mmg-color-accent);
}
/* Variant outline — pour les badges "neutres" sur fonds chargés. */
.mmg-badge--outline {
background: transparent;
color: var(--mmg-color-text-secondary);
border-color: var(--mmg-color-border-strong);
}
/* Sizes */
.mmg-badge--sm { padding: 1px 6px; font-size: 10px; gap: 4px; }
.mmg-badge--lg { padding: 4px 10px; font-size: var(--mmg-font-size-xs); gap: 6px; }
/* Dot indicator — pour status badges (online, en cours, etc.) */
.mmg-badge__dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
/* Variant "pulse" — anime le dot pour signaler du temps réel */
.mmg-badge__dot--pulse {
position: relative;
}
.mmg-badge__dot--pulse::after {
content: "";
position: absolute;
inset: -3px;
border-radius: 50%;
background: currentColor;
opacity: 0.5;
animation: mmg-badge-pulse 1.6s ease-out infinite;
}
@keyframes mmg-badge-pulse {
0% { transform: scale(0.8); opacity: 0.6; }
100% { transform: scale(1.8); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.mmg-badge__dot--pulse::after { animation: none; opacity: 0; }
}
/* ════════════════════════════════════════════════════════════════
Toasts — pile façon Sonner (architecture absolute + JS-measured)
Chaque toast est en position: absolute, ancré bottom: 0 (positions
bottom-*) ou top: 0 (positions top-*). Le composant React :
- mesure les heights via ResizeObserver
- calcule deux offsets par toast : --mmg-toast-collapsed-y (état repos,
stack-offset × index) et --mmg-toast-expanded-y (somme des heights
précédentes + gap × index)
- fixe la hauteur du region (front-most en repos, somme totale en hover)
Cette technique évite les bugs de margin: -100% (qui se résolvent sur
la largeur, pas la hauteur). Référence : github.com/emilkowalski/sonner.
════════════════════════════════════════════════════════════════ */
.mmg-toast-region {
position: fixed;
z-index: var(--mmg-z-toast);
list-style: none;
padding: 0;
margin: 0;
pointer-events: none;
width: 380px;
max-width: calc(100% - var(--mmg-space-5) * 2);
/* hauteur posée inline par le composant React, transition pour adoucir */
transition: height 320ms var(--mmg-ease-emphasis);
}
.mmg-toast-region--bottom-right { bottom: var(--mmg-space-5); right: var(--mmg-space-5); }
.mmg-toast-region--bottom-left { bottom: var(--mmg-space-5); left: var(--mmg-space-5); }
.mmg-toast-region--top-right { top: var(--mmg-space-5); right: var(--mmg-space-5); }
.mmg-toast-region--top-left { top: var(--mmg-space-5); left: var(--mmg-space-5); }
/* — Toast individuel — design moderne (Sonner / Vercel / Linear) ————————
- Pas de barre colorée à gauche (look 2018 admin panel).
- Indication de severity via la couleur de l'icône uniquement.
- Border subtile + ombre layered (deux niveaux pour la profondeur).
- Padding tight, typo dense.
- backdrop-filter blur en dark pour effet "verre dépoli". */
.mmg-toast {
position: absolute;
left: 0;
right: 0;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 14px 14px 16px;
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: 14px;
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.04),
0 6px 16px -4px rgba(15, 15, 30, 0.10),
0 2px 4px -2px rgba(15, 15, 30, 0.06);
pointer-events: auto;
animation: mmg-toast-in-bottom 360ms var(--mmg-ease-emphasis);
transition:
transform 380ms var(--mmg-ease-emphasis),
opacity 200ms var(--mmg-ease-default);
}
/* Dark : fond légèrement raised + verre dépoli + halo blanc minimal pour
décoller des fonds très sombres. */
[data-mmg-theme="dark"] .mmg-toast {
background: color-mix(in srgb, var(--mmg-color-bg-raised) 90%, transparent);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-color: var(--mmg-color-border);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 8px 24px -6px rgba(0, 0, 0, 0.55),
0 2px 6px -2px rgba(0, 0, 0, 0.40);
}
.mmg-toast-region--bottom-right .mmg-toast,
.mmg-toast-region--bottom-left .mmg-toast {
bottom: 0;
transform-origin: bottom center;
/* État repos : décalage vers le haut + scale-down par stack index */
transform: translateY(var(--mmg-toast-collapsed-y, 0))
scale(var(--mmg-toast-scale, 1));
}
.mmg-toast-region--top-right .mmg-toast,
.mmg-toast-region--top-left .mmg-toast {
top: 0;
transform-origin: top center;
transform: translateY(var(--mmg-toast-collapsed-y, 0))
scale(var(--mmg-toast-scale, 1));
animation-name: mmg-toast-in-top;
}
/* Au hover/focus : déploiement, plein scale, offset = somme des heights */
.mmg-toast-region[data-expanded] .mmg-toast {
transform: translateY(var(--mmg-toast-expanded-y, 0)) scale(1);
}
/* Au-delà de visibleCount, on masque (mais le DOM reste pour aria-live) */
.mmg-toast-region:not([data-expanded]) .mmg-toast[data-stack-index="3"],
.mmg-toast-region:not([data-expanded]) .mmg-toast[data-stack-index="4"],
.mmg-toast-region:not([data-expanded]) .mmg-toast[data-stack-index="5"] {
opacity: 0;
pointer-events: none;
}
/* — Severities — plus de border-left coloré. La severity est portée
uniquement par la couleur de l'icône (qui prime déjà aria-role=alert
pour danger). Si on veut un signal redondant : `data-severity` permet
un hint subtil sur la border. */
.mmg-toast[data-severity="success"] { border-color: color-mix(in srgb, var(--mmg-color-success) 28%, var(--mmg-color-border)); }
.mmg-toast[data-severity="warning"] { border-color: color-mix(in srgb, var(--mmg-color-warning) 28%, var(--mmg-color-border)); }
.mmg-toast[data-severity="danger"] { border-color: color-mix(in srgb, var(--mmg-color-danger) 32%, var(--mmg-color-border)); }
.mmg-toast[data-severity="info"] { border-color: color-mix(in srgb, var(--mmg-color-info) 24%, var(--mmg-color-border)); }
/* — Sous-éléments ——————————————————————————————————— */
.mmg-toast__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 1px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mmg-toast__body { flex: 1; min-width: 0; }
.mmg-toast__title {
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-sm);
line-height: 1.45;
letter-spacing: -0.005em;
}
.mmg-toast__desc {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
margin-top: 2px;
line-height: 1.45;
}
.mmg-toast__action {
margin-top: 8px;
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
border: 1px solid var(--mmg-color-border);
border-radius: 8px;
font: inherit;
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-semi);
cursor: pointer;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-toast__action:hover {
background: var(--mmg-color-bg-raised);
border-color: var(--mmg-color-border-strong);
text-decoration: none;
}
.mmg-toast__close {
background: transparent;
border: 0;
cursor: pointer;
color: var(--mmg-color-text-quaternary);
width: 22px;
height: 22px;
padding: 0;
border-radius: 8px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
/* Apparition au hover du toast (Sonner-style) */
opacity: 0;
transform: scale(0.92);
transition:
opacity var(--mmg-duration-fast) var(--mmg-ease-default),
transform var(--mmg-duration-fast) var(--mmg-ease-emphasis),
background var(--mmg-duration-fast) var(--mmg-ease-default),
color var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-toast:hover .mmg-toast__close,
.mmg-toast:focus-within .mmg-toast__close {
opacity: 1;
transform: scale(1);
}
/* Front-most en collapsed : close visible direct (pile au repos) */
.mmg-toast-region:not([data-expanded]) .mmg-toast[data-front] .mmg-toast__close {
opacity: 1;
transform: scale(1);
}
.mmg-toast__close:hover {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
}
.mmg-toast__close:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
opacity: 1;
transform: scale(1);
}
/* — Animations d'entrée ——————————————————————————————————— */
@keyframes mmg-toast-in-bottom {
from {
opacity: 0;
transform: translateY(calc(var(--mmg-toast-collapsed-y, 0) + 32px))
scale(var(--mmg-toast-scale, 1));
}
}
@keyframes mmg-toast-in-top {
from {
opacity: 0;
transform: translateY(calc(var(--mmg-toast-collapsed-y, 0) - 32px))
scale(var(--mmg-toast-scale, 1));
}
}
/* prefers-reduced-motion : on garde la pile en évitant le mouvement.
Tous les toasts sauf le front sont cachés (pas de pile visuelle pulsante). */
@media (prefers-reduced-motion: reduce) {
.mmg-toast {
animation: none;
transition: opacity 120ms;
}
.mmg-toast-region:not([data-expanded]) .mmg-toast:not([data-front]) {
opacity: 0;
pointer-events: none;
}
.mmg-toast-region[data-expanded] .mmg-toast {
/* On affiche tout en colonne via offset déjà calculé en JS */
}
}
/* Forced colors / High Contrast Mode */
@media (forced-colors: active) {
.mmg-toast { border: 1px solid CanvasText; }
}
/* — Modal ————————— */
.mmg-modal-backdrop {
position: fixed;
inset: 0;
background: var(--mmg-color-bg-overlay);
z-index: var(--mmg-z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--mmg-space-4);
animation: mmg-fade-in var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-modal {
background: var(--mmg-color-bg-surface);
border-radius: var(--mmg-radius-panel);
box-shadow: var(--mmg-shadow-3);
max-width: 560px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: mmg-modal-in var(--mmg-duration-base) var(--mmg-ease-emphasis);
}
.mmg-modal--lg { max-width: 800px; }
.mmg-modal--sm { max-width: 400px; }
.mmg-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mmg-space-5) var(--mmg-space-6);
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-modal__title {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
}
.mmg-modal__close {
background: transparent;
border: 0;
cursor: pointer;
color: var(--mmg-color-text-tertiary);
padding: var(--mmg-space-1);
border-radius: 50%;
}
.mmg-modal__close:hover {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-primary);
}
.mmg-modal__body {
padding: var(--mmg-space-6);
overflow-y: auto;
flex: 1;
}
.mmg-modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--mmg-space-2);
padding: var(--mmg-space-4) var(--mmg-space-6);
border-top: 1px solid var(--mmg-color-border-soft);
background: var(--mmg-color-bg-raised);
}
@keyframes mmg-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes mmg-modal-in {
from { opacity: 0; transform: scale(0.96) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* — Empty state ————————— */
.mmg-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mmg-space-9) var(--mmg-space-5);
gap: var(--mmg-space-3);
}
.mmg-empty--compact {
padding: var(--mmg-space-5) var(--mmg-space-4);
gap: var(--mmg-space-2);
}
.mmg-empty__title {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin: var(--mmg-space-2) 0 0;
}
.mmg-empty--compact .mmg-empty__title {
font-size: var(--mmg-font-size-base);
margin-top: var(--mmg-space-1);
}
.mmg-empty__desc {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
max-width: 480px;
line-height: var(--mmg-line-height-snug);
}
.mmg-empty__actions {
display: flex;
gap: var(--mmg-space-2);
margin-top: var(--mmg-space-2);
flex-wrap: wrap;
justify-content: center;
}
/* — Skeleton (loading) ————————— */
.mmg-skeleton {
background: linear-gradient(90deg, var(--mmg-color-bg-muted) 25%, var(--mmg-color-bg-subtle) 50%, var(--mmg-color-bg-muted) 75%);
background-size: 200% 100%;
border-radius: var(--mmg-radius-sm);
animation: mmg-shimmer 1.4s ease-in-out infinite;
display: block;
}
.mmg-skeleton--text { height: 14px; margin-bottom: 6px; }
.mmg-skeleton--title { height: 24px; margin-bottom: 10px; }
.mmg-skeleton--circle { border-radius: 50%; aspect-ratio: 1; }
@keyframes mmg-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* — Spinner ————————— */
.mmg-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--mmg-color-accent-border);
border-top-color: var(--mmg-color-accent);
border-radius: 50%;
animation: mmg-spin 0.7s linear infinite;
}
.mmg-spinner--sm { width: 14px; height: 14px; border-width: 1.5px; }
.mmg-spinner--lg { width: 32px; height: 32px; border-width: 3px; }
+257
View File
@@ -0,0 +1,257 @@
/* ════════════════════════════════════════════════════════════════
Form — Field, Input, Select, Textarea, Checkbox, Radio, Switch
════════════════════════════════════════════════════════════════ */
/* — Field group (label + control + hint/error) ————————— */
.mmg-field {
display: flex;
flex-direction: column;
gap: var(--mmg-space-1);
margin-bottom: var(--mmg-space-4);
}
.mmg-field__label {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
}
.mmg-field__label--required::after {
content: " *";
color: var(--mmg-color-danger);
}
.mmg-field__hint {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
margin-top: 2px;
}
.mmg-field__error {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-danger);
margin-top: 2px;
}
.mmg-field__success {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-success);
margin-top: 2px;
}
/* — Input + Textarea + Select shared ————————— */
.mmg-input,
.mmg-textarea,
.mmg-select {
display: block;
width: 100%;
padding: 10px 14px;
font-family: inherit;
font-size: var(--mmg-font-size-sm);
line-height: 1.4;
color: var(--mmg-color-text-primary);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
transition: border-color var(--mmg-duration-fast) var(--mmg-ease-default),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-input::placeholder,
.mmg-textarea::placeholder {
color: var(--mmg-color-text-quaternary);
}
.mmg-input:hover:not(:disabled),
.mmg-textarea:hover:not(:disabled),
.mmg-select:hover:not(:disabled) {
border-color: var(--mmg-color-border-strong);
}
.mmg-input:focus,
.mmg-textarea:focus,
.mmg-select:focus {
outline: 0;
border-color: var(--mmg-color-accent);
box-shadow: var(--mmg-shadow-focus);
}
.mmg-input:disabled,
.mmg-textarea:disabled,
.mmg-select:disabled {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-tertiary);
cursor: not-allowed;
}
.mmg-field--error .mmg-input,
.mmg-field--error .mmg-textarea,
.mmg-field--error .mmg-select {
border-color: var(--mmg-color-danger);
}
.mmg-field--error .mmg-input:focus,
.mmg-field--error .mmg-textarea:focus,
.mmg-field--error .mmg-select:focus {
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18);
}
.mmg-field--success .mmg-input,
.mmg-field--success .mmg-textarea {
border-color: var(--mmg-color-success);
}
.mmg-textarea {
min-height: 96px;
resize: vertical;
}
/* — Input avec icône préfixe/suffixe ————————— */
.mmg-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.mmg-input-wrap .mmg-input {
padding-left: 38px;
}
.mmg-input-wrap--with-suffix .mmg-input {
padding-right: 38px;
}
.mmg-input-wrap__icon {
position: absolute;
left: 12px;
pointer-events: none;
color: var(--mmg-color-text-tertiary);
}
.mmg-input-wrap__icon--suffix {
left: auto;
right: 12px;
pointer-events: auto;
}
/* — Sizes ————————————————————— */
.mmg-input--sm,
.mmg-select--sm,
.mmg-textarea--sm {
padding: 6px 10px;
font-size: var(--mmg-font-size-xs);
}
.mmg-input--lg,
.mmg-select--lg,
.mmg-textarea--lg {
padding: 14px 18px;
font-size: var(--mmg-font-size-base);
}
/* — Select natif arrow ————————— */
.mmg-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237875A1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 36px;
}
/* — Checkbox + Radio ————————————————————— */
.mmg-check {
display: inline-flex;
align-items: flex-start;
gap: var(--mmg-space-2);
cursor: pointer;
user-select: none;
}
.mmg-check__input {
appearance: none;
flex-shrink: 0;
width: 18px;
height: 18px;
margin: 0;
background: var(--mmg-color-bg-surface);
border: 1.5px solid var(--mmg-color-border-strong);
border-radius: 4px;
transition: background var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default);
cursor: pointer;
position: relative;
}
.mmg-check__input[type="radio"] {
border-radius: 50%;
}
.mmg-check__input:hover { border-color: var(--mmg-color-accent); }
.mmg-check__input:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
}
.mmg-check__input:checked {
background: var(--mmg-color-accent);
border-color: var(--mmg-color-accent);
}
.mmg-check__input[type="checkbox"]:checked::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center/12px no-repeat;
}
.mmg-check__input[type="radio"]:checked::after {
content: "";
position: absolute;
inset: 4px;
background: white;
border-radius: 50%;
}
.mmg-check__input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.mmg-check__label {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
line-height: 1.4;
}
/* — Switch ————————————————————— */
.mmg-switch {
display: inline-flex;
align-items: center;
gap: var(--mmg-space-2);
cursor: pointer;
user-select: none;
}
.mmg-switch__input {
appearance: none;
width: 36px;
height: 20px;
background: var(--mmg-color-border-strong);
border-radius: 9999px;
position: relative;
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
cursor: pointer;
}
.mmg-switch__input::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform var(--mmg-duration-fast) var(--mmg-ease-emphasis);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.mmg-switch__input:checked {
background: var(--mmg-color-accent);
}
.mmg-switch__input:checked::after {
transform: translateX(16px);
}
.mmg-switch__input:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
}
.mmg-switch__input:disabled { cursor: not-allowed; opacity: 0.5; }
/* — Fieldset ————————————————————— */
.mmg-fieldset {
border: 0;
padding: 0;
margin: 0 0 var(--mmg-space-6);
}
.mmg-fieldset__legend {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
margin-bottom: var(--mmg-space-4);
}
@@ -0,0 +1,51 @@
/* ════════════════════════════════════════════════════════════════
HoverCard — preview riche au survol (Radix UI HoverCard).
Plus large que Tooltip, contenu structuré, animation douce.
════════════════════════════════════════════════════════════════ */
.mmg-hover-card {
z-index: var(--mmg-z-tooltip);
width: 320px;
max-width: calc(100vw - 32px);
padding: var(--mmg-space-4);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: 14px;
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.04),
0 12px 32px -6px rgba(15, 15, 30, 0.16),
0 4px 8px -2px rgba(15, 15, 30, 0.08);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-primary);
animation: mmg-hover-card-in 220ms var(--mmg-ease-emphasis);
}
.mmg-hover-card[data-state="closed"] {
animation: mmg-hover-card-out 160ms var(--mmg-ease-default);
}
[data-mmg-theme="dark"] .mmg-hover-card {
background: color-mix(in srgb, var(--mmg-color-bg-raised) 92%, transparent);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 12px 32px -6px rgba(0, 0, 0, 0.55),
0 4px 8px -2px rgba(0, 0, 0, 0.40);
}
.mmg-hover-card__arrow {
fill: var(--mmg-color-bg-surface);
filter: drop-shadow(0 -1px 0 var(--mmg-color-border));
}
[data-mmg-theme="dark"] .mmg-hover-card__arrow {
fill: var(--mmg-color-bg-raised);
}
@keyframes mmg-hover-card-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
}
@keyframes mmg-hover-card-out {
to { opacity: 0; transform: translateY(-4px) scale(0.98); }
}
@media (prefers-reduced-motion: reduce) {
.mmg-hover-card { animation: none !important; }
}
+453
View File
@@ -0,0 +1,453 @@
/* ════════════════════════════════════════════════════════════════
Layout — Container, Grid, Stack, Card, Tile
════════════════════════════════════════════════════════════════ */
.mmg-container {
width: 100%;
max-width: var(--mmg-container-max);
margin-inline: auto;
padding-inline: var(--mmg-space-5);
}
.mmg-container--narrow { max-width: var(--mmg-container-narrow); }
.mmg-container--wide { max-width: var(--mmg-container-wide); }
.mmg-container--fluid { max-width: none; }
@media (min-width: 768px) {
.mmg-container { padding-inline: var(--mmg-space-7); }
}
/* — Grid responsive ————————————————————————— */
.mmg-grid {
display: grid;
gap: var(--mmg-space-5);
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.mmg-grid--gap-sm { gap: var(--mmg-space-3); }
.mmg-grid--gap-lg { gap: var(--mmg-space-7); }
.mmg-col-1 { grid-column: span 1; }
.mmg-col-2 { grid-column: span 2; }
.mmg-col-3 { grid-column: span 3; }
.mmg-col-4 { grid-column: span 4; }
.mmg-col-5 { grid-column: span 5; }
.mmg-col-6 { grid-column: span 6; }
.mmg-col-7 { grid-column: span 7; }
.mmg-col-8 { grid-column: span 8; }
.mmg-col-9 { grid-column: span 9; }
.mmg-col-10 { grid-column: span 10; }
.mmg-col-11 { grid-column: span 11; }
.mmg-col-12 { grid-column: span 12; }
@media (max-width: 767px) {
.mmg-grid { grid-template-columns: 1fr; }
[class*="mmg-col-"] { grid-column: 1 / -1; }
}
/* — Stack vertical ————————————————————————— */
.mmg-stack {
display: flex;
flex-direction: column;
gap: var(--mmg-space-4);
}
.mmg-stack--xs { gap: var(--mmg-space-1); }
.mmg-stack--sm { gap: var(--mmg-space-2); }
.mmg-stack--md { gap: var(--mmg-space-4); }
.mmg-stack--lg { gap: var(--mmg-space-6); }
.mmg-stack--xl { gap: var(--mmg-space-8); }
/* — Inline horizontal ————————————————————————— */
.mmg-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--mmg-space-3);
}
.mmg-inline--end { justify-content: flex-end; }
.mmg-inline--between { justify-content: space-between; }
.mmg-inline--center { justify-content: center; }
/* — Section ————————————————————————— */
.mmg-section {
padding-block: var(--mmg-space-10);
}
.mmg-section--sm { padding-block: var(--mmg-space-7); }
.mmg-section--lg { padding-block: var(--mmg-space-12); }
.mmg-section--surface { background: var(--mmg-color-bg-surface); }
.mmg-section--muted { background: var(--mmg-color-bg-muted); }
.mmg-section--brand-soft { background: var(--mmg-color-accent-soft); }
/* — Card ————————————————————————— */
.mmg-card {
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
padding: var(--mmg-space-6);
box-shadow: var(--mmg-shadow-1);
transition: box-shadow var(--mmg-duration-base) var(--mmg-ease-default),
transform var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-card--raised { box-shadow: var(--mmg-shadow-2); }
.mmg-card--flat { box-shadow: none; }
.mmg-card--no-padding { padding: 0; }
.mmg-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--mmg-space-4);
gap: var(--mmg-space-3);
}
.mmg-card__title {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
}
.mmg-card__desc {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
}
.mmg-card__footer {
margin-top: var(--mmg-space-5);
padding-top: var(--mmg-space-4);
border-top: 1px solid var(--mmg-color-border-soft);
}
/* — Tile (carte cliquable, style DSFR + hover soigné) ——————————— */
.mmg-tile {
position: relative;
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
padding: var(--mmg-space-6);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
box-shadow: var(--mmg-shadow-1);
text-decoration: none;
color: inherit;
overflow: hidden;
isolation: isolate;
transition:
transform var(--mmg-duration-base) var(--mmg-ease-emphasis),
box-shadow var(--mmg-duration-base) var(--mmg-ease-default),
border-color var(--mmg-duration-base) var(--mmg-ease-default);
}
/* Liseré rose qui se révèle en haut au hover */
.mmg-tile::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--mmg-color-accent) 0%, var(--mmg-color-accent-strong) 100%);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--mmg-duration-base) var(--mmg-ease-emphasis);
z-index: 1;
}
.mmg-tile:hover {
transform: translateY(-4px);
box-shadow: var(--mmg-shadow-3);
border-color: var(--mmg-color-accent-border);
text-decoration: none;
color: inherit;
}
.mmg-tile:hover::before {
transform: scaleX(1);
}
.mmg-tile:hover .mmg-tile__title {
color: var(--mmg-color-accent);
}
.mmg-tile:hover .mmg-tile__arrow {
transform: translateX(4px);
opacity: 1;
}
.mmg-tile:active { transform: translateY(-1px); }
.mmg-tile__icon {
width: 48px;
height: 48px;
border-radius: var(--mmg-radius-icon);
display: flex;
align-items: center;
justify-content: center;
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent);
font-size: 22px;
flex-shrink: 0;
}
.mmg-tile__pictogram {
width: 80px;
height: 80px;
margin-bottom: var(--mmg-space-2);
flex-shrink: 0;
}
/* — IconBlock (carré coloré avec icône, alternative aux pictos) ——— */
.mmg-iconblock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent);
font-size: 26px;
flex-shrink: 0;
position: relative;
transition:
background var(--mmg-duration-base) var(--mmg-ease-default),
transform var(--mmg-duration-base) var(--mmg-ease-emphasis);
}
.mmg-iconblock--xs { width: 32px; height: 32px; border-radius: 10px; font-size: 16px; }
.mmg-iconblock--sm { width: 40px; height: 40px; border-radius: 12px; font-size: 20px; }
.mmg-iconblock--md { width: 56px; height: 56px; border-radius: 16px; font-size: 26px; }
.mmg-iconblock--lg { width: 72px; height: 72px; border-radius: 20px; font-size: 32px; }
.mmg-iconblock--xl { width: 96px; height: 96px; border-radius: 24px; font-size: 44px; }
.mmg-iconblock--brand { background: var(--mmg-color-accent-soft); color: var(--mmg-color-accent); }
.mmg-iconblock--success { background: var(--mmg-color-success-soft); color: var(--mmg-color-success); }
.mmg-iconblock--warning { background: var(--mmg-color-warning-soft); color: var(--mmg-color-warning); }
.mmg-iconblock--danger { background: var(--mmg-color-danger-soft); color: var(--mmg-color-danger); }
.mmg-iconblock--info { background: var(--mmg-color-info-soft); color: var(--mmg-color-info); }
.mmg-iconblock--neutral { background: var(--mmg-color-bg-muted); color: var(--mmg-color-text-secondary); }
/* Variant "filled" — fond plein, icône inverse. Ombre teintée via
color-mix sur l'accent courant pour s'adapter au preset user et au dark. */
.mmg-iconblock--filled {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
box-shadow:
0 2px 6px color-mix(in srgb, var(--mmg-color-accent) 25%, transparent),
0 4px 12px color-mix(in srgb, var(--mmg-color-accent) 18%, transparent);
}
.mmg-iconblock--filled.mmg-iconblock--success {
background: var(--mmg-color-success); color: #fff;
box-shadow: 0 2px 6px color-mix(in srgb, var(--mmg-color-success) 25%, transparent);
}
.mmg-iconblock--filled.mmg-iconblock--warning {
background: var(--mmg-color-warning); color: #fff;
box-shadow: 0 2px 6px color-mix(in srgb, var(--mmg-color-warning) 25%, transparent);
}
.mmg-iconblock--filled.mmg-iconblock--danger {
background: var(--mmg-color-danger); color: #fff;
box-shadow: 0 2px 6px color-mix(in srgb, var(--mmg-color-danger) 25%, transparent);
}
.mmg-iconblock--filled.mmg-iconblock--info {
background: var(--mmg-color-info); color: #fff;
box-shadow: 0 2px 6px color-mix(in srgb, var(--mmg-color-info) 25%, transparent);
}
/* Variant "outline" — bordure colorée */
.mmg-iconblock--outline {
background: transparent;
border: 2px solid currentColor;
}
/* Variant "gradient" — dégradé accent. Ombre teintée via color-mix. */
.mmg-iconblock--gradient {
background: linear-gradient(135deg, var(--mmg-color-accent) 0%, var(--mmg-color-accent-strong) 100%);
color: var(--mmg-color-accent-on);
box-shadow:
0 4px 12px color-mix(in srgb, var(--mmg-color-accent) 25%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
/* Animation au hover du parent .mmg-tile */
.mmg-tile:hover .mmg-iconblock { transform: scale(1.05); }
.mmg-tile__title {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
transition: color var(--mmg-duration-fast) var(--mmg-ease-default);
display: flex;
align-items: center;
gap: var(--mmg-space-2);
}
.mmg-tile__arrow {
display: inline-flex;
opacity: 0;
transform: translateX(-4px);
transition:
transform var(--mmg-duration-base) var(--mmg-ease-emphasis),
opacity var(--mmg-duration-base) var(--mmg-ease-default);
color: var(--mmg-color-accent);
}
.mmg-tile__desc {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
line-height: var(--mmg-line-height-snug);
}
.mmg-tile--horizontal {
flex-direction: row;
align-items: flex-start;
}
.mmg-tile--horizontal .mmg-tile__pictogram {
margin-bottom: 0;
}
/* — Variantes de taille ————————————————————— */
.mmg-tile--sm {
padding: var(--mmg-space-4);
gap: var(--mmg-space-2);
}
.mmg-tile--sm .mmg-tile__title {
font-size: var(--mmg-font-size-base);
}
.mmg-tile--sm .mmg-tile__desc {
font-size: var(--mmg-font-size-xs);
}
.mmg-tile--sm .mmg-tile__icon {
width: 36px;
height: 36px;
font-size: 18px;
border-radius: 10px;
}
.mmg-tile--sm .mmg-tile__pictogram {
width: 56px;
height: 56px;
}
.mmg-tile--lg {
padding: var(--mmg-space-7);
gap: var(--mmg-space-4);
}
.mmg-tile--lg .mmg-tile__title {
font-size: var(--mmg-font-size-xl);
}
.mmg-tile--lg .mmg-tile__desc {
font-size: var(--mmg-font-size-base);
}
.mmg-tile--lg .mmg-tile__icon {
width: 64px;
height: 64px;
font-size: 28px;
border-radius: 16px;
}
.mmg-tile--lg .mmg-tile__pictogram {
width: 96px;
height: 96px;
}
/* ════════════════════════════════════════════════════════════════
Hero — landing
Light : gradient saturé accent → accent-strong, texte accent-on (blanc).
Dark : SURFACE NEUTRE + halos accent diffus aux coins. Le pink-on-pink
en dark était inutilisable (texte washed-out, boutons tonal invisibles).
On garde la marque via les glows, pas en saturant tout l'espace.
════════════════════════════════════════════════════════════════ */
.mmg-hero {
position: relative;
padding-block: var(--mmg-space-12);
background: linear-gradient(135deg, var(--mmg-color-accent) 0%, var(--mmg-color-accent-strong) 100%);
color: var(--mmg-color-accent-on);
overflow: hidden;
}
.mmg-hero__title {
font-size: var(--mmg-font-size-5xl);
font-weight: var(--mmg-font-weight-extra);
letter-spacing: -0.02em;
color: var(--mmg-color-accent-on);
margin-bottom: var(--mmg-space-4);
}
.mmg-hero__lead {
font-size: var(--mmg-font-size-xl);
color: var(--mmg-color-accent-on);
opacity: 0.92;
max-width: 640px;
margin-bottom: var(--mmg-space-6);
}
@media (max-width: 767px) {
.mmg-hero__title { font-size: var(--mmg-font-size-4xl); }
.mmg-hero__lead { font-size: var(--mmg-font-size-lg); }
}
/* — Dark : surface neutre + glow accent en radial gradients ———————— */
[data-mmg-theme="dark"] .mmg-hero {
background:
radial-gradient(circle at 18% 8%,
color-mix(in srgb, var(--mmg-color-accent) 28%, transparent) 0%,
transparent 45%),
radial-gradient(circle at 88% 92%,
color-mix(in srgb, var(--mmg-color-accent) 18%, transparent) 0%,
transparent 55%),
var(--mmg-color-bg-surface);
color: var(--mmg-color-text-primary);
}
[data-mmg-theme="dark"] .mmg-hero__title {
color: var(--mmg-color-text-primary);
}
[data-mmg-theme="dark"] .mmg-hero__lead {
color: var(--mmg-color-text-secondary);
opacity: 1;
}
/* Liseré subtil en bas du Hero dark pour séparer du contenu suivant */
[data-mmg-theme="dark"] .mmg-hero::after {
content: "";
position: absolute;
inset: auto 0 0 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--mmg-color-border), transparent);
}
/* — Boutons dans le Hero — restylés selon le contexte —————————————
Light Hero (fond accent saturé) : tonal et ghost étaient illisibles
parce qu'ils héritaient des accent-soft / accent-strong → rose sur rose.
On les bascule sur fond accent-on (blanc) translucide. */
.mmg-hero .mmg-btn--tonal {
background: color-mix(in srgb, var(--mmg-color-accent-on) 22%, transparent);
color: var(--mmg-color-accent-on);
border-color: color-mix(in srgb, var(--mmg-color-accent-on) 18%, transparent);
}
.mmg-hero .mmg-btn--tonal:hover:not(:disabled) {
background: color-mix(in srgb, var(--mmg-color-accent-on) 32%, transparent);
color: var(--mmg-color-accent-on);
}
.mmg-hero .mmg-btn--tonal::before {
background: var(--mmg-color-accent-on);
}
.mmg-hero .mmg-btn--ghost {
color: var(--mmg-color-accent-on);
border: 1px solid color-mix(in srgb, var(--mmg-color-accent-on) 35%, transparent);
}
.mmg-hero .mmg-btn--ghost:hover:not(:disabled) {
color: var(--mmg-color-accent-on);
background: color-mix(in srgb, var(--mmg-color-accent-on) 12%, transparent);
}
.mmg-hero .mmg-btn--ghost::before {
background: var(--mmg-color-accent-on);
}
/* Dark Hero (fond surface neutre) : on revient aux variants standards.
Le fond n'est plus saturé donc tonal et ghost reprennent leur look
normal et restent lisibles. */
[data-mmg-theme="dark"] .mmg-hero .mmg-btn--tonal {
background: var(--mmg-color-accent-soft);
color: var(--mmg-color-accent-strong);
border-color: var(--mmg-color-accent-border);
}
[data-mmg-theme="dark"] .mmg-hero .mmg-btn--tonal:hover:not(:disabled) {
background: color-mix(in srgb, var(--mmg-color-accent) 24%, transparent);
color: var(--mmg-color-accent-strong);
}
[data-mmg-theme="dark"] .mmg-hero .mmg-btn--ghost {
color: var(--mmg-color-text-secondary);
border: 1px solid transparent;
}
[data-mmg-theme="dark"] .mmg-hero .mmg-btn--ghost:hover:not(:disabled) {
color: var(--mmg-color-text-primary);
background: var(--mmg-color-bg-muted);
border-color: var(--mmg-color-border);
}
/* — Divider ————————————————————————— */
.mmg-divider {
height: 1px;
background: var(--mmg-color-border-soft);
border: 0;
margin: var(--mmg-space-5) 0;
}
+109
View File
@@ -0,0 +1,109 @@
/* ════════════════════════════════════════════════════════════════
MetricCard — KPI avec valeur, delta coloré, sparkline.
Pattern Linear / Vercel / Stripe Dashboard.
════════════════════════════════════════════════════════════════ */
.mmg-metric-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--mmg-space-2);
padding: var(--mmg-space-5);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
text-decoration: none;
color: inherit;
font: inherit;
text-align: left;
overflow: hidden;
transition:
border-color var(--mmg-duration-base) var(--mmg-ease-default),
transform var(--mmg-duration-base) var(--mmg-ease-emphasis),
box-shadow var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-metric-card--interactive {
cursor: pointer;
}
.mmg-metric-card--interactive:hover {
border-color: var(--mmg-color-border-strong);
transform: translateY(-2px);
box-shadow: var(--mmg-shadow-2);
}
.mmg-metric-card--interactive:focus-visible {
outline: 0;
border-color: var(--mmg-color-accent);
box-shadow: var(--mmg-shadow-focus);
}
.mmg-metric-card__head {
display: inline-flex;
align-items: center;
gap: var(--mmg-space-2);
color: var(--mmg-color-text-tertiary);
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-medium);
}
.mmg-metric-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 8px;
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-secondary);
}
.mmg-metric-card__value {
font-size: var(--mmg-font-size-3xl);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
letter-spacing: -0.02em;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.mmg-metric-card__footer {
display: inline-flex;
align-items: center;
gap: var(--mmg-space-2);
flex-wrap: wrap;
font-size: var(--mmg-font-size-xs);
}
.mmg-metric-card__delta {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--mmg-radius-pill);
font-weight: var(--mmg-font-weight-semi);
font-variant-numeric: tabular-nums;
}
.mmg-metric-card__delta--success {
background: var(--mmg-color-success-soft);
color: var(--mmg-color-success-strong);
}
.mmg-metric-card__delta--danger {
background: var(--mmg-color-danger-soft);
color: var(--mmg-color-danger-strong);
}
.mmg-metric-card__delta--neutral {
background: var(--mmg-color-bg-muted);
color: var(--mmg-color-text-secondary);
}
.mmg-metric-card__delta--rotated svg,
.mmg-metric-card__delta--rotated .mmg-icon {
transform: rotate(90deg);
}
.mmg-metric-card__period {
color: var(--mmg-color-text-quaternary);
}
.mmg-metric-card__sparkline {
margin-top: var(--mmg-space-2);
height: 48px;
display: flex;
align-items: stretch;
}
.mmg-metric-card__sparkline > * { width: 100%; }
+289
View File
@@ -0,0 +1,289 @@
/* ════════════════════════════════════════════════════════════════
Overlays — Tooltip, Popover, Dialog, Menu (Radix-backed)
Les composants Radix posent leur propre positionnement via inline-styles
et data-attributes. Nos sélecteurs ciblent les classes mmg-* sans
override de position/transform.
data-state attributes Radix : "open"/"closed"/"delayed-open"
data-side : "top"/"right"/"bottom"/"left"
data-align : "start"/"center"/"end"
════════════════════════════════════════════════════════════════ */
/* — Tooltip ——————————————————————————————————— */
.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);
border-radius: var(--mmg-radius-sm);
box-shadow: var(--mmg-shadow-2);
user-select: none;
pointer-events: none;
max-width: 280px;
animation: mmg-tooltip-in 120ms var(--mmg-ease-emphasis);
}
.mmg-tooltip[data-state="closed"] {
animation: mmg-tooltip-out 100ms var(--mmg-ease-default);
}
.mmg-tooltip__arrow {
fill: var(--mmg-color-text-primary);
}
@keyframes mmg-tooltip-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@keyframes mmg-tooltip-out {
to { opacity: 0; transform: scale(0.96); }
}
/* — Popover ——————————————————————————————————— */
.mmg-popover {
z-index: var(--mmg-z-dropdown);
min-width: 220px;
padding: var(--mmg-space-3);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
box-shadow: var(--mmg-shadow-3);
animation: mmg-popover-in 160ms var(--mmg-ease-emphasis);
}
.mmg-popover[data-state="closed"] {
animation: mmg-popover-out 120ms var(--mmg-ease-default);
}
.mmg-popover:focus-visible {
outline: 0;
}
.mmg-popover__arrow {
fill: var(--mmg-color-bg-surface);
/* Bordure de l'arrow : Radix permet via stroke */
filter: drop-shadow(0 -1px 0 var(--mmg-color-border));
}
@keyframes mmg-popover-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes mmg-popover-out {
to { opacity: 0; transform: translateY(-4px) scale(0.98); }
}
/* — Dialog (Radix Modal) ——————————————————————————————————— */
.mmg-dialog__overlay {
position: fixed;
inset: 0;
z-index: var(--mmg-z-modal);
background: var(--mmg-color-bg-overlay);
backdrop-filter: blur(2px);
animation: mmg-dialog-overlay-in 160ms var(--mmg-ease-default);
}
.mmg-dialog__overlay[data-state="closed"] {
animation: mmg-dialog-overlay-out 120ms var(--mmg-ease-default);
}
@keyframes mmg-dialog-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes mmg-dialog-overlay-out {
to { opacity: 0; }
}
.mmg-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: calc(var(--mmg-z-modal) + 1);
display: flex;
flex-direction: column;
width: calc(100% - var(--mmg-space-6));
max-height: calc(100dvh - var(--mmg-space-9));
background: var(--mmg-color-bg-surface);
border-radius: var(--mmg-radius-card);
box-shadow: var(--mmg-shadow-3);
animation: mmg-dialog-in 200ms var(--mmg-ease-emphasis);
}
.mmg-dialog[data-state="closed"] {
animation: mmg-dialog-out 150ms var(--mmg-ease-default);
}
.mmg-dialog:focus-visible {
outline: 0;
}
.mmg-dialog--sm { max-width: 420px; }
.mmg-dialog--md { max-width: 560px; }
.mmg-dialog--lg { max-width: 720px; }
.mmg-dialog--xl { max-width: 960px; }
.mmg-dialog--full {
max-width: none;
width: calc(100% - var(--mmg-space-6));
height: calc(100dvh - var(--mmg-space-9));
}
.mmg-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mmg-space-3);
padding: var(--mmg-space-5) var(--mmg-space-6) 0;
}
.mmg-dialog__title {
margin: 0;
font-size: var(--mmg-font-size-xl);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
}
.mmg-dialog__description {
padding: var(--mmg-space-2) var(--mmg-space-6) 0;
margin: 0;
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
}
.mmg-dialog__body {
flex: 1 1 auto;
padding: var(--mmg-space-4) var(--mmg-space-6);
overflow-y: auto;
}
.mmg-dialog__body:empty { padding: 0; }
.mmg-dialog__footer {
padding: var(--mmg-space-4) var(--mmg-space-6) var(--mmg-space-5);
border-top: 1px solid var(--mmg-color-border-soft);
}
.mmg-dialog__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
color: var(--mmg-color-text-tertiary);
border: 0;
border-radius: 50%;
cursor: pointer;
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-dialog__close:hover { background: var(--mmg-color-bg-muted); color: var(--mmg-color-text-primary); }
.mmg-dialog__close:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
}
@keyframes mmg-dialog-in {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes mmg-dialog-out {
to { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
}
/* — Menu (DropdownMenu Radix) ——————————————————————————————————— */
/* Note : .mmg-menu existait déjà dans chrome.css (legacy) — on enrichit
pour Radix sans casser les anciens usages. */
.mmg-menu {
z-index: var(--mmg-z-dropdown);
min-width: 220px;
padding: var(--mmg-space-1);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
box-shadow: var(--mmg-shadow-2);
animation: mmg-popover-in 140ms var(--mmg-ease-emphasis);
}
.mmg-menu[data-state="closed"] {
animation: mmg-popover-out 100ms var(--mmg-ease-default);
}
.mmg-menu__item {
display: flex;
align-items: center;
gap: var(--mmg-space-2);
width: 100%;
padding: var(--mmg-space-2) var(--mmg-space-3);
background: transparent;
color: var(--mmg-color-text-primary);
border: 0;
border-radius: var(--mmg-radius-sm);
font-size: var(--mmg-font-size-sm);
text-align: left;
text-decoration: none;
cursor: pointer;
user-select: none;
outline: 0;
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-menu__item[data-highlighted],
.mmg-menu__item.mmg-menu__item--active,
.mmg-menu__item:hover:not([data-disabled]) {
background: var(--mmg-color-bg-muted);
}
.mmg-menu__item[data-disabled],
.mmg-menu__item[aria-disabled="true"] {
color: var(--mmg-color-text-quaternary);
cursor: not-allowed;
pointer-events: none;
}
.mmg-menu__item--danger {
color: var(--mmg-color-danger);
}
.mmg-menu__item--danger[data-highlighted],
.mmg-menu__item--danger:hover {
background: var(--mmg-color-danger-soft);
}
.mmg-menu__divider {
height: 1px;
margin: var(--mmg-space-1) 0;
background: var(--mmg-color-border-soft);
border: 0;
}
.mmg-menu__label {
padding: var(--mmg-space-2) var(--mmg-space-3) var(--mmg-space-1);
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-quaternary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mmg-menu__shortcut {
margin-left: auto;
font-family: var(--mmg-font-mono);
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
}
.mmg-menu__empty {
padding: var(--mmg-space-3) var(--mmg-space-3);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
text-align: center;
}
/* — Combobox (étend mmg-menu) ———————————————————— */
.mmg-combobox__list {
overflow-y: auto;
}
/* — Forced colors / High Contrast (Windows) ——————————— */
@media (forced-colors: active) {
.mmg-tooltip,
.mmg-popover,
.mmg-menu,
.mmg-dialog {
border: 1px solid CanvasText;
}
.mmg-menu__item[data-highlighted] {
background: Highlight;
color: HighlightText;
forced-color-adjust: none;
}
}
/* prefers-reduced-motion : Radix respecte data-state mais on coupe nos
keyframes par sécurité. */
@media (prefers-reduced-motion: reduce) {
.mmg-tooltip,
.mmg-popover,
.mmg-menu,
.mmg-dialog,
.mmg-dialog__overlay {
animation: none !important;
}
}
@@ -0,0 +1,130 @@
/* ════════════════════════════════════════════════════════════════
PricingCard — carte de tarification.
════════════════════════════════════════════════════════════════ */
.mmg-pricing-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--mmg-space-5);
padding: var(--mmg-space-7);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
transition:
transform var(--mmg-duration-base) var(--mmg-ease-emphasis),
border-color var(--mmg-duration-base) var(--mmg-ease-default);
}
.mmg-pricing-card:hover {
transform: translateY(-2px);
border-color: var(--mmg-color-border-strong);
}
.mmg-pricing-card--highlighted {
border-width: 2px;
border-color: var(--mmg-color-accent);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--mmg-color-accent) 4%, var(--mmg-color-bg-surface)) 0%,
var(--mmg-color-bg-surface) 100%);
box-shadow: 0 8px 32px -12px color-mix(in srgb, var(--mmg-color-accent) 30%, transparent);
}
[data-mmg-theme="dark"] .mmg-pricing-card--highlighted {
background:
linear-gradient(180deg,
color-mix(in srgb, var(--mmg-color-accent) 8%, var(--mmg-color-bg-surface)) 0%,
var(--mmg-color-bg-surface) 100%);
}
.mmg-pricing-card__badge {
position: absolute;
top: var(--mmg-space-4);
right: var(--mmg-space-4);
padding: 4px 10px;
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
border-radius: var(--mmg-radius-pill);
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-semi);
letter-spacing: 0.01em;
box-shadow: var(--mmg-shadow-accent-soft);
}
.mmg-pricing-card__head {
display: flex;
flex-direction: column;
gap: var(--mmg-space-2);
}
.mmg-pricing-card__name {
margin: 0;
font-size: var(--mmg-font-size-xl);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
letter-spacing: -0.015em;
}
.mmg-pricing-card__desc {
margin: 0;
color: var(--mmg-color-text-tertiary);
font-size: var(--mmg-font-size-sm);
line-height: 1.5;
}
.mmg-pricing-card__price {
display: flex;
align-items: baseline;
gap: var(--mmg-space-2);
flex-wrap: wrap;
}
.mmg-pricing-card__price-value {
font-size: var(--mmg-font-size-4xl);
font-weight: var(--mmg-font-weight-extra);
letter-spacing: -0.025em;
color: var(--mmg-color-text-primary);
line-height: 1;
font-variant-numeric: tabular-nums;
}
.mmg-pricing-card__price-period {
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
}
.mmg-pricing-card__features {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--mmg-space-2);
border-top: 1px solid var(--mmg-color-border-soft);
padding-top: var(--mmg-space-4);
}
.mmg-pricing-card__feature {
display: flex;
align-items: flex-start;
gap: var(--mmg-space-2);
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-secondary);
line-height: 1.5;
}
.mmg-pricing-card__feature-icon {
flex-shrink: 0;
margin-top: 3px;
color: var(--mmg-color-success);
}
.mmg-pricing-card__feature--excluded {
color: var(--mmg-color-text-quaternary);
}
.mmg-pricing-card__feature--excluded .mmg-pricing-card__feature-icon {
color: var(--mmg-color-text-quaternary);
}
.mmg-pricing-card__feature--excluded span {
text-decoration: line-through;
text-decoration-color: var(--mmg-color-text-quaternary);
}
.mmg-pricing-card__cta {
margin-top: auto;
}
.mmg-pricing-card__cta > * {
width: 100%;
}
@@ -0,0 +1,112 @@
/* ════════════════════════════════════════════════════════════════
ProfileHeader — cover image + avatar en débord (LinkedIn / GitHub).
════════════════════════════════════════════════════════════════ */
.mmg-profile-header {
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-card);
overflow: hidden;
}
.mmg-profile-header__cover {
position: relative;
height: 160px;
background: var(--mmg-color-bg-muted);
}
.mmg-profile-header__cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.mmg-profile-header__cover-gradient {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 18% 20%,
color-mix(in srgb, var(--mmg-color-accent) 32%, transparent), transparent 55%),
radial-gradient(circle at 82% 80%,
color-mix(in srgb, var(--mmg-color-accent) 20%, transparent), transparent 60%),
linear-gradient(135deg, var(--mmg-color-accent-strong) 0%, var(--mmg-color-accent) 100%);
}
.mmg-profile-header__body {
position: relative;
padding: 0 var(--mmg-space-6) var(--mmg-space-5);
}
.mmg-profile-header__avatar-wrap {
position: relative;
margin-top: -48px;
margin-bottom: var(--mmg-space-3);
display: inline-block;
}
.mmg-profile-header__avatar-wrap .mmg-avatar {
/* Anneau plus marqué pour décoller de la cover */
box-shadow: 0 0 0 4px var(--mmg-color-bg-surface);
}
.mmg-profile-header__main {
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
}
.mmg-profile-header__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--mmg-space-4);
flex-wrap: wrap;
}
.mmg-profile-header__name {
margin: 0;
font-size: var(--mmg-font-size-2xl);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.015em;
color: var(--mmg-color-text-primary);
}
.mmg-profile-header__subtitle {
margin-top: 4px;
color: var(--mmg-color-text-tertiary);
font-size: var(--mmg-font-size-sm);
}
.mmg-profile-header__badges {
margin-top: var(--mmg-space-2);
display: flex;
gap: var(--mmg-space-1);
flex-wrap: wrap;
}
.mmg-profile-header__actions {
display: inline-flex;
align-items: center;
gap: var(--mmg-space-2);
}
.mmg-profile-header__bio {
margin: 0;
color: var(--mmg-color-text-secondary);
font-size: var(--mmg-font-size-sm);
line-height: 1.55;
max-width: 60ch;
}
.mmg-profile-header__stats {
margin: 0;
padding: var(--mmg-space-3) 0 0;
display: flex;
flex-wrap: wrap;
gap: var(--mmg-space-6);
border-top: 1px solid var(--mmg-color-border-soft);
}
.mmg-profile-header__stat { display: block; }
.mmg-profile-header__stat-label {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mmg-profile-header__stat-value {
margin: 2px 0 0;
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
font-variant-numeric: tabular-nums;
}
+164
View File
@@ -0,0 +1,164 @@
/* ════════════════════════════════════════════════════════════════
Sheet — panneau latéral / drawer (Radix Dialog avec slide-in).
Side : right (défaut), left, top, bottom.
Size : sm (320), md (480), lg (640), xl (820), full.
Focus trap, scroll lock, escape — tout natif Radix.
════════════════════════════════════════════════════════════════ */
.mmg-sheet__overlay {
position: fixed;
inset: 0;
z-index: var(--mmg-z-modal);
background: var(--mmg-color-bg-overlay);
backdrop-filter: blur(2px);
animation: mmg-sheet-overlay-in 200ms var(--mmg-ease-default);
}
.mmg-sheet__overlay[data-state="closed"] {
animation: mmg-sheet-overlay-out 160ms var(--mmg-ease-default);
}
@keyframes mmg-sheet-overlay-in { from { opacity: 0; } }
@keyframes mmg-sheet-overlay-out { to { opacity: 0; } }
.mmg-sheet {
position: fixed;
z-index: calc(var(--mmg-z-modal) + 1);
display: flex;
flex-direction: column;
background: var(--mmg-color-bg-surface);
box-shadow: 0 0 0 1px var(--mmg-color-border), -16px 0 48px rgba(15, 15, 30, 0.16);
outline: 0;
}
[data-mmg-theme="dark"] .mmg-sheet {
box-shadow: 0 0 0 1px var(--mmg-color-border), -16px 0 48px rgba(0, 0, 0, 0.55);
}
/* — Côtés ——————————————————————————— */
.mmg-sheet--right {
top: 0; right: 0; bottom: 0;
width: 100%;
border-left: 1px solid var(--mmg-color-border);
animation: mmg-sheet-in-right 320ms var(--mmg-ease-emphasis);
}
.mmg-sheet--left {
top: 0; left: 0; bottom: 0;
width: 100%;
border-right: 1px solid var(--mmg-color-border);
animation: mmg-sheet-in-left 320ms var(--mmg-ease-emphasis);
}
.mmg-sheet--top {
top: 0; left: 0; right: 0;
border-bottom: 1px solid var(--mmg-color-border);
border-bottom-left-radius: var(--mmg-radius-card);
border-bottom-right-radius: var(--mmg-radius-card);
animation: mmg-sheet-in-top 320ms var(--mmg-ease-emphasis);
}
.mmg-sheet--bottom {
bottom: 0; left: 0; right: 0;
border-top: 1px solid var(--mmg-color-border);
border-top-left-radius: var(--mmg-radius-card);
border-top-right-radius: var(--mmg-radius-card);
animation: mmg-sheet-in-bottom 320ms var(--mmg-ease-emphasis);
}
.mmg-sheet--right[data-state="closed"] { animation: mmg-sheet-out-right 220ms var(--mmg-ease-default); }
.mmg-sheet--left[data-state="closed"] { animation: mmg-sheet-out-left 220ms var(--mmg-ease-default); }
.mmg-sheet--top[data-state="closed"] { animation: mmg-sheet-out-top 220ms var(--mmg-ease-default); }
.mmg-sheet--bottom[data-state="closed"] { animation: mmg-sheet-out-bottom 220ms var(--mmg-ease-default); }
/* — Sizes ——————————————————————————— */
.mmg-sheet--right.mmg-sheet--sm,
.mmg-sheet--left.mmg-sheet--sm { max-width: 320px; }
.mmg-sheet--right.mmg-sheet--md,
.mmg-sheet--left.mmg-sheet--md { max-width: 480px; }
.mmg-sheet--right.mmg-sheet--lg,
.mmg-sheet--left.mmg-sheet--lg { max-width: 640px; }
.mmg-sheet--right.mmg-sheet--xl,
.mmg-sheet--left.mmg-sheet--xl { max-width: 820px; }
.mmg-sheet--right.mmg-sheet--full,
.mmg-sheet--left.mmg-sheet--full { max-width: 100%; }
.mmg-sheet--top.mmg-sheet--sm,
.mmg-sheet--bottom.mmg-sheet--sm { max-height: 30dvh; }
.mmg-sheet--top.mmg-sheet--md,
.mmg-sheet--bottom.mmg-sheet--md { max-height: 50dvh; }
.mmg-sheet--top.mmg-sheet--lg,
.mmg-sheet--bottom.mmg-sheet--lg { max-height: 70dvh; }
.mmg-sheet--top.mmg-sheet--xl,
.mmg-sheet--bottom.mmg-sheet--xl { max-height: 85dvh; }
.mmg-sheet--top.mmg-sheet--full,
.mmg-sheet--bottom.mmg-sheet--full { height: 100dvh; max-height: 100dvh; }
/* — Sous-éléments ——————————————————————————— */
.mmg-sheet__header {
display: flex;
align-items: flex-start;
gap: var(--mmg-space-3);
padding: var(--mmg-space-5) var(--mmg-space-6) var(--mmg-space-3);
border-bottom: 1px solid var(--mmg-color-border-soft);
}
.mmg-sheet__header-text { flex: 1; min-width: 0; }
.mmg-sheet__title {
margin: 0;
font-size: var(--mmg-font-size-xl);
font-weight: var(--mmg-font-weight-bold);
color: var(--mmg-color-text-primary);
letter-spacing: -0.01em;
}
.mmg-sheet__description {
margin: 4px 0 0;
font-size: var(--mmg-font-size-sm);
color: var(--mmg-color-text-tertiary);
line-height: 1.5;
}
.mmg-sheet__close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
color: var(--mmg-color-text-tertiary);
border: 0;
border-radius: 8px;
cursor: pointer;
transition: background var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-sheet__close:hover { background: var(--mmg-color-bg-muted); color: var(--mmg-color-text-primary); }
.mmg-sheet__close:focus-visible {
outline: 0;
box-shadow: var(--mmg-shadow-focus);
}
.mmg-sheet__body {
flex: 1 1 auto;
padding: var(--mmg-space-5) var(--mmg-space-6);
overflow-y: auto;
}
.mmg-sheet__footer {
display: flex;
justify-content: flex-end;
gap: var(--mmg-space-2);
padding: var(--mmg-space-4) var(--mmg-space-6);
border-top: 1px solid var(--mmg-color-border-soft);
background: var(--mmg-color-bg-raised);
}
/* — Animations ——————————————————————————— */
@keyframes mmg-sheet-in-right { from { transform: translateX(100%); } }
@keyframes mmg-sheet-out-right { to { transform: translateX(100%); } }
@keyframes mmg-sheet-in-left { from { transform: translateX(-100%); } }
@keyframes mmg-sheet-out-left { to { transform: translateX(-100%); } }
@keyframes mmg-sheet-in-top { from { transform: translateY(-100%); } }
@keyframes mmg-sheet-out-top { to { transform: translateY(-100%); } }
@keyframes mmg-sheet-in-bottom { from { transform: translateY(100%); } }
@keyframes mmg-sheet-out-bottom { to { transform: translateY(100%); } }
@media (prefers-reduced-motion: reduce) {
.mmg-sheet, .mmg-sheet__overlay { animation: none !important; }
}
/* Forced colors */
@media (forced-colors: active) {
.mmg-sheet { border: 1px solid CanvasText; }
}
+102
View File
@@ -0,0 +1,102 @@
/* ════════════════════════════════════════════════════════════════
Slider — sélecteur de valeur (Radix UI Slider)
Track fin, range accent, thumb pill avec halo focus, scale au hover.
════════════════════════════════════════════════════════════════ */
.mmg-slider {
position: relative;
display: flex;
align-items: center;
user-select: none;
touch-action: none;
width: 100%;
height: 24px;
}
.mmg-slider[data-orientation="vertical"] {
flex-direction: column;
width: 24px;
height: 100%;
min-height: 120px;
}
.mmg-slider__track {
position: relative;
flex-grow: 1;
background: var(--mmg-color-bg-muted);
border-radius: 9999px;
height: 6px;
overflow: hidden;
}
.mmg-slider[data-orientation="vertical"] .mmg-slider__track {
width: 6px;
height: 100%;
}
.mmg-slider__range {
position: absolute;
background: var(--mmg-color-accent);
border-radius: 9999px;
height: 100%;
}
.mmg-slider[data-orientation="vertical"] .mmg-slider__range {
width: 100%;
height: auto;
}
.mmg-slider__thumb {
position: relative;
display: block;
width: 18px;
height: 18px;
background: var(--mmg-color-bg-surface);
border: 2px solid var(--mmg-color-accent);
border-radius: 50%;
cursor: grab;
outline: 0;
transition:
transform var(--mmg-duration-fast) var(--mmg-ease-emphasis),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-slider__thumb:hover {
transform: scale(1.12);
}
.mmg-slider__thumb:active {
cursor: grabbing;
transform: scale(1.18);
}
.mmg-slider__thumb:focus-visible {
box-shadow: var(--mmg-shadow-focus);
}
.mmg-slider--disabled {
opacity: 0.55;
pointer-events: none;
}
.mmg-slider__value {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
background: var(--mmg-color-text-primary);
color: var(--mmg-color-bg-surface);
border-radius: 6px;
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-semi);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-slider__thumb:hover .mmg-slider__value,
.mmg-slider__thumb:focus .mmg-slider__value,
.mmg-slider__thumb[data-disabled] .mmg-slider__value {
opacity: 1;
}
@media (forced-colors: active) {
.mmg-slider__thumb { border: 2px solid CanvasText; }
.mmg-slider__range { background: Highlight; }
}
@@ -0,0 +1,107 @@
/* ════════════════════════════════════════════════════════════════
Theme picker — sélecteur de couleur d'accent
Pattern radiogroup, pastilles cliquables, focus-visible, état checked.
════════════════════════════════════════════════════════════════ */
.mmg-theme-picker {
display: flex;
flex-direction: column;
gap: var(--mmg-space-3);
}
.mmg-theme-picker__legend {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
}
.mmg-theme-picker__group {
display: flex;
flex-wrap: wrap;
gap: var(--mmg-space-2);
}
.mmg-theme-picker__swatch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition:
border-color var(--mmg-duration-fast) var(--mmg-ease-default),
transform var(--mmg-duration-fast) var(--mmg-ease-emphasis),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-theme-picker__swatch-color {
display: block;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--mmg-swatch-color);
/* Bordure subtile pour la pastille slate sur fond clair (contraste 3:1 mini) */
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.10);
transition: transform var(--mmg-duration-fast) var(--mmg-ease-emphasis);
}
.mmg-theme-picker__swatch:hover .mmg-theme-picker__swatch-color {
transform: scale(1.08);
}
.mmg-theme-picker__swatch--selected {
border-color: var(--mmg-color-text-primary);
}
.mmg-theme-picker__swatch:focus-visible {
outline: 0;
border-color: var(--mmg-color-accent);
box-shadow: var(--mmg-shadow-focus);
}
.mmg-theme-picker__swatch-check {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
/* Mix-blend pour rester lisible sur n'importe quelle teinte d'accent */
mix-blend-mode: difference;
/* RGAA 9 : couleur jamais seule. Le check est un signal redondant. */
}
.mmg-theme-picker__reset {
align-self: flex-start;
padding: 4px 12px;
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
background: transparent;
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-pill);
cursor: pointer;
transition:
color var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-theme-picker__reset:hover {
color: var(--mmg-color-text-primary);
border-color: var(--mmg-color-border-strong);
}
.mmg-theme-picker__reset:focus-visible {
outline: 0;
border-color: var(--mmg-color-accent);
box-shadow: var(--mmg-shadow-focus);
}
/* Forced colors / High Contrast Mode (Windows) */
@media (forced-colors: active) {
.mmg-theme-picker__swatch--selected {
border-color: Highlight;
}
}
@@ -0,0 +1,75 @@
/* ════════════════════════════════════════════════════════════════
ToggleGroup — groupe de boutons toggle (Radix UI).
Variants : outline (défaut, bordure) / solid (fond accent).
Sizes : sm / md / lg.
════════════════════════════════════════════════════════════════ */
.mmg-toggle-group {
display: inline-flex;
align-items: stretch;
border-radius: var(--mmg-radius-md);
background: var(--mmg-color-bg-muted);
padding: 3px;
gap: 2px;
border: 1px solid var(--mmg-color-border);
}
.mmg-toggle-group__item {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
color: var(--mmg-color-text-secondary);
border: 0;
border-radius: calc(var(--mmg-radius-md) - 4px);
font-family: inherit;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-medium);
cursor: pointer;
user-select: none;
outline: 0;
white-space: nowrap;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
color var(--mmg-duration-fast) var(--mmg-ease-default),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-toggle-group__item:hover:not([data-disabled]) {
color: var(--mmg-color-text-primary);
}
.mmg-toggle-group__item[data-state="on"] {
background: var(--mmg-color-bg-surface);
color: var(--mmg-color-text-primary);
box-shadow:
0 0 0 1px var(--mmg-color-border),
0 1px 2px rgba(0, 0, 0, 0.06);
}
.mmg-toggle-group__item[data-disabled] {
color: var(--mmg-color-text-quaternary);
cursor: not-allowed;
}
.mmg-toggle-group__item:focus-visible {
box-shadow: var(--mmg-shadow-focus);
}
/* Sizes */
.mmg-toggle-group--sm .mmg-toggle-group__item { padding: 4px 10px; font-size: var(--mmg-font-size-xs); }
.mmg-toggle-group--lg .mmg-toggle-group__item { padding: 9px 16px; font-size: var(--mmg-font-size-base); }
/* Variant solid : item actif passe en accent plein */
.mmg-toggle-group--solid .mmg-toggle-group__item[data-state="on"] {
background: var(--mmg-color-accent);
color: var(--mmg-color-accent-on);
box-shadow: none;
}
/* Forced colors */
@media (forced-colors: active) {
.mmg-toggle-group__item[data-state="on"] {
background: Highlight;
color: HighlightText;
forced-color-adjust: none;
}
}
+218
View File
@@ -0,0 +1,218 @@
/* ════════════════════════════════════════════════════════════════
Typography — système typographique DSMMG
3 familles d'échelles, à utiliser selon le contexte :
═══ DISPLAY ═══════════════════════════════════════════════
Pour les pages marketing / landing produits (HRTime, Synapse,
Forge, Orbit). Tailles XXL, extra-bold, tracking serré pour
un look éditorial moderne (Vercel / Linear / Stripe).
mmg-text-display-2xl 72px → Hero "événement" (premier écran d'un produit)
mmg-text-display-xl 60px → Hero standard de page produit
mmg-text-display-lg 48px → Hero secondaire / section feature
mmg-text-display-md 36px → Sous-section marketing
═══ HEADLINES ═══════════════════════════════════════════════
Pour les apps métier (Synapse, HRTime app, Forge, Orbit) et
l'Espace-Client. Hiérarchie h1 → h6 sémantique.
mmg-text-h1 36px → titre de page
mmg-text-h2 30px → section
mmg-text-h3 24px → sous-section
mmg-text-h4 20px → groupe / card title
mmg-text-h5 17px → sub-card
mmg-text-h6 15px → label fort
═══ BODY ═══════════════════════════════════════════════════
mmg-text-body-lg 17px → paragraphes marketing aérés
mmg-text-body 15px → texte courant (défaut <p>)
mmg-text-body-sm 13px → UI dense, hint
mmg-text-body-xs 11px → métadonnées, footnotes
═══ AUXILIAIRES ═════════════════════════════════════════════
mmg-text-eyebrow 13px uppercase tracking → label au-dessus d'un titre
mmg-text-lead 20px regular → chapô sous Hero
mmg-text-overline 11px uppercase tracking → labels de groupe
mmg-text-caption 11px regular → légendes d'image, annotations
mmg-text-kbd mono → raccourcis clavier (déjà dans base.css)
════════════════════════════════════════════════════════════════ */
/* — Display ——————————————————————————————————— */
.mmg-text-display-2xl,
.mmg-text-display-xl,
.mmg-text-display-lg,
.mmg-text-display-md {
font-family: var(--mmg-font-sans);
font-weight: var(--mmg-font-weight-extra);
letter-spacing: -0.025em;
line-height: 1.05;
color: var(--mmg-color-text-primary);
/* Légère optimisation rendering pour grandes tailles */
text-rendering: optimizeLegibility;
font-feature-settings: "ss01", "ss02";
}
.mmg-text-display-2xl {
font-size: clamp(48px, 6vw + 1rem, 72px);
letter-spacing: -0.035em;
line-height: 1;
}
.mmg-text-display-xl {
font-size: clamp(40px, 5vw + 1rem, 60px);
letter-spacing: -0.03em;
}
.mmg-text-display-lg {
font-size: clamp(32px, 4vw + 0.5rem, 48px);
letter-spacing: -0.025em;
line-height: 1.1;
}
.mmg-text-display-md {
font-size: clamp(28px, 3vw + 0.5rem, 36px);
letter-spacing: -0.02em;
line-height: 1.15;
}
/* — Headlines (sémantiques h1-h6) ——————————————————— */
.mmg-text-h1 {
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-4xl);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--mmg-color-text-primary);
}
.mmg-text-h2 {
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-3xl);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.015em;
line-height: 1.25;
color: var(--mmg-color-text-primary);
}
.mmg-text-h3 {
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-2xl);
font-weight: var(--mmg-font-weight-bold);
letter-spacing: -0.01em;
line-height: 1.3;
color: var(--mmg-color-text-primary);
}
.mmg-text-h4 {
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-xl);
font-weight: var(--mmg-font-weight-semi);
letter-spacing: -0.005em;
line-height: 1.35;
color: var(--mmg-color-text-primary);
}
.mmg-text-h5 {
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-semi);
line-height: 1.4;
color: var(--mmg-color-text-primary);
}
.mmg-text-h6 {
font-family: var(--mmg-font-sans);
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-semi);
line-height: 1.45;
color: var(--mmg-color-text-primary);
}
/* — Body ——————————————————————————————————— */
.mmg-text-body-lg {
font-size: var(--mmg-font-size-lg);
font-weight: var(--mmg-font-weight-regular);
line-height: 1.6;
color: var(--mmg-color-text-secondary);
}
.mmg-text-body {
font-size: var(--mmg-font-size-base);
font-weight: var(--mmg-font-weight-regular);
line-height: 1.55;
color: var(--mmg-color-text-secondary);
}
.mmg-text-body-sm {
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-regular);
line-height: 1.5;
color: var(--mmg-color-text-secondary);
}
.mmg-text-body-xs {
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-regular);
line-height: 1.45;
color: var(--mmg-color-text-tertiary);
}
/* — Auxiliaires ——————————————————————————————— */
.mmg-text-eyebrow {
display: inline-block;
font-size: var(--mmg-font-size-sm);
font-weight: var(--mmg-font-weight-semi);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mmg-color-accent);
line-height: 1.2;
}
.mmg-text-lead {
font-size: var(--mmg-font-size-xl);
font-weight: var(--mmg-font-weight-regular);
line-height: 1.5;
letter-spacing: -0.005em;
color: var(--mmg-color-text-secondary);
max-width: 60ch;
}
.mmg-text-overline {
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-semi);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mmg-color-text-tertiary);
}
.mmg-text-caption {
font-size: var(--mmg-font-size-xs);
font-weight: var(--mmg-font-weight-regular);
line-height: 1.4;
color: var(--mmg-color-text-tertiary);
}
/* — Modificateurs alignement / poids ————————————————————— */
.mmg-text--mono { font-family: var(--mmg-font-mono); letter-spacing: 0; }
.mmg-text--tabular { font-variant-numeric: tabular-nums; }
.mmg-text--balance { text-wrap: balance; } /* Pour titres : équilibre les retours à la ligne */
.mmg-text--pretty { text-wrap: pretty; } /* Pour body : évite les veuves/orphelines */
/* — Texte gradient (Vercel signature) ————————————————————
Applicable à n'importe quel titre via .mmg-text--gradient.
Utilise l'accent + accent-strong pour rester theming-aware. */
.mmg-text--gradient {
background: linear-gradient(135deg,
var(--mmg-color-accent) 0%,
var(--mmg-color-accent-strong) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* — Texte gradient "rainbow" (effet hero exceptionnel) ——————
À utiliser AVEC PARCIMONIE — un seul par page max. */
.mmg-text--rainbow {
background: linear-gradient(90deg,
var(--mmg-color-accent),
var(--mmg-color-violet-500),
var(--mmg-color-blue-500),
var(--mmg-color-accent));
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: mmg-text-rainbow 8s linear infinite;
}
@keyframes mmg-text-rainbow {
to { background-position: 300% 0; }
}
@media (prefers-reduced-motion: reduce) {
.mmg-text--rainbow { animation: none; background-position: 0 0; }
}
+85
View File
@@ -0,0 +1,85 @@
/* ════════════════════════════════════════════════════════════════
UserCard — carte utilisateur compacte.
════════════════════════════════════════════════════════════════ */
.mmg-user-card {
display: flex;
align-items: center;
gap: var(--mmg-space-3);
padding: var(--mmg-space-3) var(--mmg-space-4);
background: var(--mmg-color-bg-surface);
border: 1px solid var(--mmg-color-border);
border-radius: var(--mmg-radius-md);
text-decoration: none;
color: inherit;
font: inherit;
text-align: left;
width: 100%;
transition:
background var(--mmg-duration-fast) var(--mmg-ease-default),
border-color var(--mmg-duration-fast) var(--mmg-ease-default),
transform var(--mmg-duration-fast) var(--mmg-ease-emphasis),
box-shadow var(--mmg-duration-fast) var(--mmg-ease-default);
}
.mmg-user-card--sm { padding: var(--mmg-space-2) var(--mmg-space-3); gap: var(--mmg-space-2); }
.mmg-user-card--lg { padding: var(--mmg-space-4) var(--mmg-space-5); gap: var(--mmg-space-4); }
.mmg-user-card--interactive {
cursor: pointer;
}
.mmg-user-card--interactive:hover {
border-color: var(--mmg-color-border-strong);
background: var(--mmg-color-bg-raised);
}
.mmg-user-card--interactive:active {
transform: translateY(1px);
}
.mmg-user-card--interactive:focus-visible {
outline: 0;
border-color: var(--mmg-color-accent);
box-shadow: var(--mmg-shadow-focus);
}
.mmg-user-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mmg-user-card__name {
font-weight: var(--mmg-font-weight-semi);
color: var(--mmg-color-text-primary);
font-size: var(--mmg-font-size-sm);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mmg-user-card__role {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-tertiary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mmg-user-card__meta {
font-size: var(--mmg-font-size-xs);
color: var(--mmg-color-text-quaternary);
line-height: 1.4;
margin-top: 2px;
}
.mmg-user-card__actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: var(--mmg-space-1);
/* Cliquer sur un bouton dans actions ne déclenche PAS la card */
}
.mmg-user-card__actions :where(button, a) {
z-index: 1;
}
.mmg-user-card--lg .mmg-user-card__name { font-size: var(--mmg-font-size-base); }
.mmg-user-card--lg .mmg-user-card__role { font-size: var(--mmg-font-size-sm); }
+35
View File
@@ -0,0 +1,35 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Bundle complet
Importez ce fichier pour obtenir tout le DS.
Cascade @layer ordonnée pour permettre aux consommateurs de
surcharger proprement sans !important :
reset → tokens → base → components → utilities (consumer override)
════════════════════════════════════════════════════════════════ */
@layer reset, tokens, base, components, utilities;
@import "./tokens.css" layer(tokens);
@import "./base.css" layer(base);
@import "./components/layout.css" layer(components);
@import "./components/button.css" layer(components);
@import "./components/form.css" layer(components);
@import "./components/feedback.css" layer(components);
@import "./components/chrome.css" layer(components);
@import "./components/advanced.css" layer(components);
@import "./components/extras.css" layer(components);
@import "./components/article.css" layer(components);
@import "./components/overlays.css" layer(components);
@import "./components/sheet.css" layer(components);
@import "./components/hover-card.css" layer(components);
@import "./components/slider.css" layer(components);
@import "./components/toggle-group.css" layer(components);
@import "./components/avatar.css" layer(components);
@import "./components/user-card.css" layer(components);
@import "./components/profile-header.css" layer(components);
@import "./components/metric-card.css" layer(components);
@import "./components/pricing-card.css" layer(components);
@import "./components/feature-card.css" layer(components);
@import "./components/typography.css" layer(components);
@import "./components/theme-picker.css" layer(components);
@import "./utilities.css" layer(utilities);
+10
View File
@@ -0,0 +1,10 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Tokens entry
Préfixe : --mmg-color-* pour tout ce qui est couleur,
--mmg-space/font/radius/duration/... pour les autres.
Architecture : primitives → semantic → accent (presets), system non-color.
════════════════════════════════════════════════════════════════ */
@import "./tokens/primitives.css";
@import "./tokens/semantic.css";
@import "./tokens/accent.css";
@import "./tokens/system.css";
+209
View File
@@ -0,0 +1,209 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Accent tokens (user-themable)
--mmg-color-accent-* est le SEUL ensemble de tokens accent que les
composants doivent consommer. La couleur est ré-aliasée selon
[data-mmg-accent="..."] sur <html>, persisté côté user.
Défaut = synapse (rose ManageMate). Aucun composant ne réfère
--mmg-color-synapse-* directement.
Presets fournis : synapse, rose, blue, violet, green, amber, red,
cyan, slate. 9 presets, tous validés WCAG AA contre fonds light/dark.
════════════════════════════════════════════════════════════════ */
/* — Default (synapse) — light theme ——————— */
:root,
[data-mmg-theme="light"],
[data-mmg-accent="synapse"] {
--mmg-color-accent: var(--mmg-color-synapse-500);
--mmg-color-accent-hover: var(--mmg-color-synapse-600);
--mmg-color-accent-active: var(--mmg-color-synapse-700);
--mmg-color-accent-soft: var(--mmg-color-synapse-50);
--mmg-color-accent-border: var(--mmg-color-synapse-200);
--mmg-color-accent-strong: var(--mmg-color-synapse-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
/* — Presets — light theme ——————— */
[data-mmg-accent="rose"] {
--mmg-color-accent: var(--mmg-color-rose-500);
--mmg-color-accent-hover: var(--mmg-color-rose-600);
--mmg-color-accent-active: var(--mmg-color-rose-700);
--mmg-color-accent-soft: var(--mmg-color-rose-50);
--mmg-color-accent-border: var(--mmg-color-rose-200);
--mmg-color-accent-strong: var(--mmg-color-rose-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-accent="blue"] {
--mmg-color-accent: var(--mmg-color-blue-500);
--mmg-color-accent-hover: var(--mmg-color-blue-600);
--mmg-color-accent-active: var(--mmg-color-blue-700);
--mmg-color-accent-soft: var(--mmg-color-blue-50);
--mmg-color-accent-border: var(--mmg-color-blue-200);
--mmg-color-accent-strong: var(--mmg-color-blue-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-accent="violet"] {
--mmg-color-accent: var(--mmg-color-violet-500);
--mmg-color-accent-hover: var(--mmg-color-violet-600);
--mmg-color-accent-active: var(--mmg-color-violet-700);
--mmg-color-accent-soft: var(--mmg-color-violet-50);
--mmg-color-accent-border: var(--mmg-color-violet-200);
--mmg-color-accent-strong: var(--mmg-color-violet-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-accent="green"] {
/* Green-500 (#0E9F6E) sur blanc = 3.39:1 → fail AA texte. Bump vers
green-700 pour passer 5.5:1 AA confortablement. */
--mmg-color-accent: var(--mmg-color-green-700);
--mmg-color-accent-hover: var(--mmg-color-green-800);
--mmg-color-accent-active: var(--mmg-color-green-900);
--mmg-color-accent-soft: var(--mmg-color-green-50);
--mmg-color-accent-border: var(--mmg-color-green-200);
--mmg-color-accent-strong: var(--mmg-color-green-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-accent="amber"] {
/* Amber-500 (#D97706) sur bg-page = 2.96:1 → fail AA composant (3:1).
Bump à amber-600 (#B45309) pour 3.5:1. accent-on reste text-primary
car amber-600 est jaune-orange foncé (texte foncé est mieux qu'un
blanc qui rendrait un contraste insuffisant aussi). */
--mmg-color-accent: var(--mmg-color-amber-600);
--mmg-color-accent-hover: var(--mmg-color-amber-700);
--mmg-color-accent-active: var(--mmg-color-amber-800);
--mmg-color-accent-soft: var(--mmg-color-amber-50);
--mmg-color-accent-border: var(--mmg-color-amber-200);
--mmg-color-accent-strong: var(--mmg-color-amber-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-accent="red"] {
--mmg-color-accent: var(--mmg-color-red-500);
--mmg-color-accent-hover: var(--mmg-color-red-600);
--mmg-color-accent-active: var(--mmg-color-red-700);
--mmg-color-accent-soft: var(--mmg-color-red-50);
--mmg-color-accent-border: var(--mmg-color-red-200);
--mmg-color-accent-strong: var(--mmg-color-red-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
/* Note : preset accent rouge ≠ couleur sémantique danger.
Le composant Alert/danger reste --mmg-color-danger-*. */
}
[data-mmg-accent="cyan"] {
/* Cyan-500 (#0891B2) sur blanc = 3.68:1 → fail AA texte. Bump vers
cyan-700 pour passer 6.5:1 AA confortablement. */
--mmg-color-accent: var(--mmg-color-cyan-700);
--mmg-color-accent-hover: var(--mmg-color-cyan-800);
--mmg-color-accent-active: var(--mmg-color-cyan-900);
--mmg-color-accent-soft: var(--mmg-color-cyan-50);
--mmg-color-accent-border: var(--mmg-color-cyan-200);
--mmg-color-accent-strong: var(--mmg-color-cyan-800);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-accent="slate"] {
--mmg-color-accent: var(--mmg-color-slate-700);
--mmg-color-accent-hover: var(--mmg-color-slate-800);
--mmg-color-accent-active: var(--mmg-color-slate-900);
--mmg-color-accent-soft: var(--mmg-color-slate-100);
--mmg-color-accent-border: var(--mmg-color-slate-300);
--mmg-color-accent-strong: var(--mmg-color-slate-900);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
/* ════════════════════════════════════════════════════════════════
Dark theme — accent tuned (saturation réduite, luminosité montée)
Override global de l'accent quand [data-mmg-theme="dark"] est posé.
Combine avec [data-mmg-accent="..."] pour preset user en dark.
════════════════════════════════════════════════════════════════ */
[data-mmg-theme="dark"],
[data-mmg-theme="dark"][data-mmg-accent="synapse"] {
--mmg-color-accent: var(--mmg-color-synapse-d-500);
--mmg-color-accent-hover: var(--mmg-color-synapse-d-400);
--mmg-color-accent-active: var(--mmg-color-synapse-d-600);
--mmg-color-accent-soft: var(--mmg-color-synapse-d-soft);
--mmg-color-accent-border: var(--mmg-color-synapse-d-border);
--mmg-color-accent-strong: var(--mmg-color-synapse-d-300);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-theme="dark"][data-mmg-accent="rose"] {
--mmg-color-accent: var(--mmg-color-rose-d-500);
--mmg-color-accent-hover: var(--mmg-color-rose-d-400);
--mmg-color-accent-active: var(--mmg-color-rose-d-600);
--mmg-color-accent-soft: var(--mmg-color-rose-d-soft);
--mmg-color-accent-border: var(--mmg-color-rose-d-border);
--mmg-color-accent-strong: var(--mmg-color-rose-d-300);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-theme="dark"][data-mmg-accent="blue"] {
--mmg-color-accent: var(--mmg-color-blue-d-500);
--mmg-color-accent-hover: var(--mmg-color-blue-d-400);
--mmg-color-accent-active: var(--mmg-color-blue-d-600);
--mmg-color-accent-soft: var(--mmg-color-blue-d-soft);
--mmg-color-accent-border: var(--mmg-color-blue-d-border);
--mmg-color-accent-strong: var(--mmg-color-blue-d-300);
--mmg-color-accent-on: var(--mmg-color-neutral-900);
}
[data-mmg-theme="dark"][data-mmg-accent="violet"] {
--mmg-color-accent: var(--mmg-color-violet-d-500);
--mmg-color-accent-hover: var(--mmg-color-violet-d-400);
--mmg-color-accent-active: var(--mmg-color-violet-d-600);
--mmg-color-accent-soft: var(--mmg-color-violet-d-soft);
--mmg-color-accent-border: var(--mmg-color-violet-d-border);
--mmg-color-accent-strong: var(--mmg-color-violet-d-300);
--mmg-color-accent-on: var(--mmg-color-violet-900);
}
[data-mmg-theme="dark"][data-mmg-accent="green"] {
--mmg-color-accent: var(--mmg-color-green-d-500);
--mmg-color-accent-hover: var(--mmg-color-green-d-400);
--mmg-color-accent-active: var(--mmg-color-green-d-600);
--mmg-color-accent-soft: var(--mmg-color-green-d-soft);
--mmg-color-accent-border: var(--mmg-color-green-d-border);
--mmg-color-accent-strong: var(--mmg-color-green-d-300);
--mmg-color-accent-on: var(--mmg-color-green-900);
}
[data-mmg-theme="dark"][data-mmg-accent="amber"] {
--mmg-color-accent: var(--mmg-color-amber-d-500);
--mmg-color-accent-hover: var(--mmg-color-amber-d-400);
--mmg-color-accent-active: var(--mmg-color-amber-d-600);
--mmg-color-accent-soft: var(--mmg-color-amber-d-soft);
--mmg-color-accent-border: var(--mmg-color-amber-d-border);
--mmg-color-accent-strong: var(--mmg-color-amber-d-300);
--mmg-color-accent-on: var(--mmg-color-amber-900);
}
[data-mmg-theme="dark"][data-mmg-accent="red"] {
--mmg-color-accent: var(--mmg-color-red-d-500);
--mmg-color-accent-hover: var(--mmg-color-red-d-400);
--mmg-color-accent-active: var(--mmg-color-red-d-600);
--mmg-color-accent-soft: var(--mmg-color-red-d-soft);
--mmg-color-accent-border: var(--mmg-color-red-d-border);
--mmg-color-accent-strong: var(--mmg-color-red-d-300);
--mmg-color-accent-on: var(--mmg-color-neutral-0);
}
[data-mmg-theme="dark"][data-mmg-accent="cyan"] {
--mmg-color-accent: var(--mmg-color-cyan-d-500);
--mmg-color-accent-hover: var(--mmg-color-cyan-d-400);
--mmg-color-accent-active: var(--mmg-color-cyan-d-600);
--mmg-color-accent-soft: var(--mmg-color-cyan-d-soft);
--mmg-color-accent-border: var(--mmg-color-cyan-d-border);
--mmg-color-accent-strong: var(--mmg-color-cyan-d-300);
--mmg-color-accent-on: var(--mmg-color-cyan-900);
}
[data-mmg-theme="dark"][data-mmg-accent="slate"] {
--mmg-color-accent: var(--mmg-color-slate-d-500);
--mmg-color-accent-hover: var(--mmg-color-slate-d-400);
--mmg-color-accent-active: var(--mmg-color-slate-d-600);
--mmg-color-accent-soft: var(--mmg-color-slate-d-soft);
--mmg-color-accent-border: var(--mmg-color-slate-d-border);
--mmg-color-accent-strong: var(--mmg-color-slate-d-300);
--mmg-color-accent-on: var(--mmg-color-slate-900);
}
@media (prefers-color-scheme: dark) {
:root:not([data-mmg-theme="light"]):not([data-mmg-accent]) {
--mmg-color-accent: var(--mmg-color-synapse-d-500);
--mmg-color-accent-hover: var(--mmg-color-synapse-d-400);
--mmg-color-accent-active: var(--mmg-color-synapse-d-600);
--mmg-color-accent-soft: var(--mmg-color-synapse-d-soft);
--mmg-color-accent-border: var(--mmg-color-synapse-d-border);
--mmg-color-accent-strong: var(--mmg-color-synapse-d-300);
}
}
+205
View File
@@ -0,0 +1,205 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Primitive color palettes
Raw color ramps. JAMAIS consommées directement par les composants.
Toujours référencer via les tokens sémantiques ou l'accent.
Chaque rampe : 50, 100, 200, 300, 400, 500, 600, 700, 800, 900.
════════════════════════════════════════════════════════════════ */
:root {
/* — Neutral grayscale (texte, bg, border) ——————— */
--mmg-color-neutral-0: #FFFFFF;
--mmg-color-neutral-50: #F7F6FB;
--mmg-color-neutral-100: #F0EFF9;
--mmg-color-neutral-150: #EDEDFA;
--mmg-color-neutral-200: #E4E3F4;
--mmg-color-neutral-300: #C9C7E0;
--mmg-color-neutral-400: #AAA8C9;
--mmg-color-neutral-500: #7875A1;
--mmg-color-neutral-600: #56557A;
--mmg-color-neutral-700: #3B3A56;
--mmg-color-neutral-800: #1F1E32;
--mmg-color-neutral-900: #111120;
/* Dark grays (utilisés en thème sombre, indépendants de neutral pour ne
pas écraser les valeurs light en cascade) */
--mmg-color-gray-d-bg: #0B0D14;
--mmg-color-gray-d-surface: #14171F;
--mmg-color-gray-d-raised: #1C2029;
--mmg-color-gray-d-muted: #252A35;
--mmg-color-gray-d-subtle: #181B23;
--mmg-color-gray-d-border: #2D323D;
--mmg-color-gray-d-border-soft: #1F242E;
--mmg-color-gray-d-border-strong: #424857;
--mmg-color-gray-d-text-1: #F5F7FA;
--mmg-color-gray-d-text-2: #C9CED9;
--mmg-color-gray-d-text-3: #8D93A4;
--mmg-color-gray-d-text-4: #6B7185;
/* — Synapse (rose corporate ManageMate, défaut accent) ——————— */
--mmg-color-synapse-50: #FEF0F4;
--mmg-color-synapse-100: #FCE0EA;
--mmg-color-synapse-200: #FAD0DF;
--mmg-color-synapse-300: #F4A0BD;
--mmg-color-synapse-400: #ED608E;
--mmg-color-synapse-500: #D12B6A;
--mmg-color-synapse-600: #BA245F;
--mmg-color-synapse-700: #A82257;
--mmg-color-synapse-800: #831B45;
--mmg-color-synapse-900: #5A132F;
/* Dark-mode tuned — équilibre vibrance / confort visuel.
Itération 1 : #E83A7E (HSL 335 78% 57%) — neon, piquait les yeux.
Itération 2 : #E66B97 (HSL 338 68% 66%) — trop pastel, perdait la marque.
Final : #E94B91 (HSL 335 76% 60%) — saturation 76% (-2pts), luminosité
60% (+3pts) → garde la vibrance rose Synapse, sans neon, sans pastel.
Référence Linear pink dark : ~#E54187, Stripe pink dark : ~#EB5097. */
--mmg-color-synapse-d-300: #F0A5C1;
--mmg-color-synapse-d-400: #ED75A6;
--mmg-color-synapse-d-500: #E94B91;
--mmg-color-synapse-d-600: #D8307C;
--mmg-color-synapse-d-soft: rgba(233, 75, 145, 0.14);
--mmg-color-synapse-d-border: rgba(233, 75, 145, 0.38);
/* — Rose (preset alternatif plus pastel) ——————— */
--mmg-color-rose-50: #FFF1F2;
--mmg-color-rose-100: #FFE4E6;
--mmg-color-rose-200: #FECDD3;
--mmg-color-rose-300: #FDA4AF;
--mmg-color-rose-400: #FB7185;
--mmg-color-rose-500: #E11D48;
--mmg-color-rose-600: #BE123C;
--mmg-color-rose-700: #9F1239;
--mmg-color-rose-800: #881337;
--mmg-color-rose-900: #4C0519;
--mmg-color-rose-d-300: #FB7185;
--mmg-color-rose-d-400: #F43F5E;
--mmg-color-rose-d-500: #FB7185;
--mmg-color-rose-d-600: #E11D48;
--mmg-color-rose-d-soft: rgba(251, 113, 133, 0.16);
--mmg-color-rose-d-border: rgba(251, 113, 133, 0.40);
/* — Blue ——————— */
--mmg-color-blue-50: #EFF6FF;
--mmg-color-blue-100: #DBEAFE;
--mmg-color-blue-200: #BFDBFE;
--mmg-color-blue-300: #93C5FD;
--mmg-color-blue-400: #60A5FA;
--mmg-color-blue-500: #2563EB;
--mmg-color-blue-600: #1D4ED8;
--mmg-color-blue-700: #1E40AF;
--mmg-color-blue-800: #1E3A8A;
--mmg-color-blue-900: #172554;
--mmg-color-blue-d-300: #93C5FD;
--mmg-color-blue-d-400: #60A5FA;
--mmg-color-blue-d-500: #60A5FA;
--mmg-color-blue-d-600: #3B82F6;
--mmg-color-blue-d-soft: rgba(96, 165, 250, 0.16);
--mmg-color-blue-d-border: rgba(96, 165, 250, 0.40);
/* — Violet ——————— */
--mmg-color-violet-50: #F5F3FF;
--mmg-color-violet-100: #EDE9FE;
--mmg-color-violet-200: #DDD6FE;
--mmg-color-violet-300: #C4B5FD;
--mmg-color-violet-400: #A78BFA;
--mmg-color-violet-500: #7C3AED;
--mmg-color-violet-600: #6D28D9;
--mmg-color-violet-700: #5B21B6;
--mmg-color-violet-800: #4C1D95;
--mmg-color-violet-900: #2E1065;
--mmg-color-violet-d-300: #C4B5FD;
--mmg-color-violet-d-400: #A78BFA;
--mmg-color-violet-d-500: #A78BFA;
--mmg-color-violet-d-600: #8B5CF6;
--mmg-color-violet-d-soft: rgba(167, 139, 250, 0.16);
--mmg-color-violet-d-border: rgba(167, 139, 250, 0.40);
/* — Green (HRTime) ——————— */
--mmg-color-green-50: #ECFDF5;
--mmg-color-green-100: #D1FAE5;
--mmg-color-green-200: #BAEFD3;
--mmg-color-green-300: #6EE7B7;
--mmg-color-green-400: #34D399;
--mmg-color-green-500: #0E9F6E;
--mmg-color-green-600: #0B8861;
--mmg-color-green-700: #086B4D;
--mmg-color-green-800: #064E3B;
--mmg-color-green-900: #022C22;
--mmg-color-green-d-300: #A7F3D0;
--mmg-color-green-d-400: #6EE7B7;
--mmg-color-green-d-500: #34D399;
--mmg-color-green-d-600: #10B981;
--mmg-color-green-d-soft: rgba(52, 211, 153, 0.16);
--mmg-color-green-d-border: rgba(52, 211, 153, 0.40);
/* — Amber (Orbit) ——————— */
--mmg-color-amber-50: #FFFBEB;
--mmg-color-amber-100: #FEF3C7;
--mmg-color-amber-200: #FDE68A;
--mmg-color-amber-300: #FCD34D;
--mmg-color-amber-400: #FBBF24;
--mmg-color-amber-500: #D97706;
--mmg-color-amber-600: #B45309;
--mmg-color-amber-700: #92400E;
--mmg-color-amber-800: #78350F;
--mmg-color-amber-900: #451A03;
--mmg-color-amber-d-300: #FDE68A;
--mmg-color-amber-d-400: #FCD34D;
--mmg-color-amber-d-500: #FBBF24;
--mmg-color-amber-d-600: #F59E0B;
--mmg-color-amber-d-soft: rgba(251, 191, 36, 0.16);
--mmg-color-amber-d-border: rgba(251, 191, 36, 0.40);
/* — Red (warnings forts, palette accent rouge) ——————— */
--mmg-color-red-50: #FEF2F2;
--mmg-color-red-100: #FEE2E2;
--mmg-color-red-200: #FECACA;
--mmg-color-red-300: #FCA5A5;
--mmg-color-red-400: #F87171;
--mmg-color-red-500: #DC2626;
--mmg-color-red-600: #B91C1C;
--mmg-color-red-700: #991B1B;
--mmg-color-red-800: #7F1D1D;
--mmg-color-red-900: #450A0A;
--mmg-color-red-d-300: #FCA5A5;
--mmg-color-red-d-400: #F87171;
--mmg-color-red-d-500: #F87171;
--mmg-color-red-d-600: #EF4444;
--mmg-color-red-d-soft: rgba(248, 113, 113, 0.16);
--mmg-color-red-d-border: rgba(248, 113, 113, 0.40);
/* — Cyan ——————— */
--mmg-color-cyan-50: #ECFEFF;
--mmg-color-cyan-100: #CFFAFE;
--mmg-color-cyan-200: #A5F3FC;
--mmg-color-cyan-300: #67E8F9;
--mmg-color-cyan-400: #22D3EE;
--mmg-color-cyan-500: #0891B2;
--mmg-color-cyan-600: #0E7490;
--mmg-color-cyan-700: #155E75;
--mmg-color-cyan-800: #164E63;
--mmg-color-cyan-900: #083344;
--mmg-color-cyan-d-300: #67E8F9;
--mmg-color-cyan-d-400: #22D3EE;
--mmg-color-cyan-d-500: #22D3EE;
--mmg-color-cyan-d-600: #06B6D4;
--mmg-color-cyan-d-soft: rgba(34, 211, 238, 0.16);
--mmg-color-cyan-d-border: rgba(34, 211, 238, 0.40);
/* — Slate (preset accent neutre élégant pour ceux qui veulent moins de couleur) ——————— */
--mmg-color-slate-50: #F8FAFC;
--mmg-color-slate-100: #F1F5F9;
--mmg-color-slate-200: #E2E8F0;
--mmg-color-slate-300: #CBD5E1;
--mmg-color-slate-400: #94A3B8;
--mmg-color-slate-500: #475569;
--mmg-color-slate-600: #334155;
--mmg-color-slate-700: #1E293B;
--mmg-color-slate-800: #0F172A;
--mmg-color-slate-900: #020617;
--mmg-color-slate-d-300: #CBD5E1;
--mmg-color-slate-d-400: #94A3B8;
--mmg-color-slate-d-500: #94A3B8;
--mmg-color-slate-d-600: #64748B;
--mmg-color-slate-d-soft: rgba(148, 163, 184, 0.16);
--mmg-color-slate-d-border: rgba(148, 163, 184, 0.40);
}
+181
View File
@@ -0,0 +1,181 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Semantic tokens
Mappent les primitives à des rôles UI. CONSOMMÉS par les composants.
Light + dark via [data-mmg-theme] / prefers-color-scheme.
════════════════════════════════════════════════════════════════ */
:root,
[data-mmg-theme="light"] {
/* — Surfaces ————————————————————————————————— */
--mmg-color-bg-page: var(--mmg-color-neutral-50);
--mmg-color-bg-surface: var(--mmg-color-neutral-0);
--mmg-color-bg-raised: #FAFAFE;
--mmg-color-bg-muted: var(--mmg-color-neutral-150);
--mmg-color-bg-subtle: var(--mmg-color-neutral-100);
--mmg-color-bg-overlay: rgba(13, 12, 24, 0.55);
/* — Borders ————————————————————————————————— */
--mmg-color-border: var(--mmg-color-neutral-200);
--mmg-color-border-soft: #EEECFD;
--mmg-color-border-strong: var(--mmg-color-neutral-300);
/* — Texte ————————————————————————————————————————————————
text-tertiary bumpé de neutral-500 (4.02:1, fail AA) à neutral-600
(7.85:1, pass AA) pour garantir le contraste sur hint/placeholder/
caption qui sont du texte SMALL. text-quaternary descend d'un cran. */
--mmg-color-text-primary: var(--mmg-color-neutral-900);
--mmg-color-text-secondary: var(--mmg-color-neutral-700);
--mmg-color-text-tertiary: var(--mmg-color-neutral-600);
--mmg-color-text-quaternary: var(--mmg-color-neutral-500);
--mmg-color-text-inverse: var(--mmg-color-neutral-0);
/* — Sémantique (FIXE — ne change jamais avec l'accent user) —————— */
--mmg-color-success: #059669;
--mmg-color-success-soft: var(--mmg-color-green-50);
--mmg-color-success-border: #A7F3D0;
--mmg-color-success-strong: var(--mmg-color-green-800);
--mmg-color-success-on: var(--mmg-color-neutral-0);
/* Amber-500 (#D97706) sur bg-page = 2.96:1 → fail AA composant. Bump
vers amber-600 pour passer 3.5:1 AA confortablement. */
--mmg-color-warning: var(--mmg-color-amber-600);
--mmg-color-warning-soft: var(--mmg-color-amber-50);
--mmg-color-warning-border: var(--mmg-color-amber-200);
--mmg-color-warning-strong: var(--mmg-color-amber-700);
--mmg-color-warning-on: var(--mmg-color-neutral-0);
--mmg-color-danger: var(--mmg-color-red-500);
--mmg-color-danger-soft: var(--mmg-color-red-50);
--mmg-color-danger-border: var(--mmg-color-red-200);
--mmg-color-danger-strong: var(--mmg-color-red-700);
--mmg-color-danger-on: var(--mmg-color-neutral-0);
--mmg-color-info: var(--mmg-color-blue-500);
--mmg-color-info-soft: var(--mmg-color-blue-50);
--mmg-color-info-border: var(--mmg-color-blue-200);
--mmg-color-info-strong: var(--mmg-color-blue-700);
--mmg-color-info-on: var(--mmg-color-neutral-0);
/* — Ombres ————————————————————————————————— */
--mmg-shadow-1: 0 1px 4px rgba(80, 60, 180, 0.07), 0 0 0 1px rgba(80, 60, 180, 0.06);
--mmg-shadow-2: 0 4px 16px rgba(80, 60, 180, 0.09), 0 0 0 1px rgba(80, 60, 180, 0.04);
--mmg-shadow-3: 0 12px 32px rgba(80, 60, 180, 0.12);
--mmg-shadow-elevated: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--mmg-shadow-elevated-hover: 0 4px 8px rgba(0, 0, 0, 0.10), 0 2px 4px rgba(0, 0, 0, 0.06);
--mmg-shadow-flat: 0 1px 2px rgba(0, 0, 0, 0.04);
/* Ombres teintées accent — calculées à partir de --mmg-color-accent. */
--mmg-shadow-accent: 0 1px 2px color-mix(in srgb, var(--mmg-color-accent) 25%, transparent),
0 2px 6px color-mix(in srgb, var(--mmg-color-accent) 20%, transparent);
--mmg-shadow-accent-hover: 0 2px 4px color-mix(in srgb, var(--mmg-color-accent) 30%, transparent),
0 6px 14px color-mix(in srgb, var(--mmg-color-accent) 25%, transparent);
--mmg-shadow-accent-soft: 0 2px 6px color-mix(in srgb, var(--mmg-color-accent) 22%, transparent);
--mmg-shadow-focus: 0 0 0 3px color-mix(in srgb, var(--mmg-color-accent) 25%, transparent);
/* — Artwork (pictogrammes) ——————————— */
--mmg-color-art-major: var(--mmg-color-accent);
--mmg-color-art-minor: var(--mmg-color-accent-strong);
--mmg-color-art-light: var(--mmg-color-accent-soft);
--mmg-color-art-dark: #1F2347;
--mmg-color-art-accent: var(--mmg-color-blue-500);
/* — États système ————————————————————— */
--mmg-color-state-disabled-bg: var(--mmg-color-neutral-100);
--mmg-color-state-disabled-text: var(--mmg-color-neutral-400);
--mmg-color-state-disabled-border: var(--mmg-color-neutral-200);
--mmg-color-state-selection-bg: color-mix(in srgb, var(--mmg-color-accent) 22%, transparent);
--mmg-color-state-selection-text: var(--mmg-color-text-primary);
}
/* ════════════════════════════════════════════════════════════════
Dark theme — surfaces neutres, accent pop
════════════════════════════════════════════════════════════════ */
[data-mmg-theme="dark"] {
--mmg-color-bg-page: var(--mmg-color-gray-d-bg);
--mmg-color-bg-surface: var(--mmg-color-gray-d-surface);
--mmg-color-bg-raised: var(--mmg-color-gray-d-raised);
--mmg-color-bg-muted: var(--mmg-color-gray-d-muted);
--mmg-color-bg-subtle: var(--mmg-color-gray-d-subtle);
--mmg-color-bg-overlay: rgba(0, 0, 0, 0.78);
--mmg-color-border: var(--mmg-color-gray-d-border);
--mmg-color-border-soft: var(--mmg-color-gray-d-border-soft);
--mmg-color-border-strong: var(--mmg-color-gray-d-border-strong);
--mmg-color-text-primary: var(--mmg-color-gray-d-text-1);
--mmg-color-text-secondary: var(--mmg-color-gray-d-text-2);
--mmg-color-text-tertiary: var(--mmg-color-gray-d-text-3);
--mmg-color-text-quaternary: var(--mmg-color-gray-d-text-4);
/* Sémantique dark */
--mmg-color-success: var(--mmg-color-green-d-500);
--mmg-color-success-soft: var(--mmg-color-green-d-soft);
--mmg-color-success-border: var(--mmg-color-green-d-border);
--mmg-color-success-strong: var(--mmg-color-green-d-300);
--mmg-color-warning: var(--mmg-color-amber-d-500);
--mmg-color-warning-soft: var(--mmg-color-amber-d-soft);
--mmg-color-warning-border: var(--mmg-color-amber-d-border);
--mmg-color-warning-strong: var(--mmg-color-amber-d-300);
--mmg-color-warning-on: var(--mmg-color-amber-900);
--mmg-color-danger: var(--mmg-color-red-d-500);
--mmg-color-danger-soft: var(--mmg-color-red-d-soft);
--mmg-color-danger-border: var(--mmg-color-red-d-border);
--mmg-color-danger-strong: var(--mmg-color-red-d-300);
--mmg-color-info: var(--mmg-color-blue-d-500);
--mmg-color-info-soft: var(--mmg-color-blue-d-soft);
--mmg-color-info-border: var(--mmg-color-blue-d-border);
--mmg-color-info-strong: var(--mmg-color-blue-d-300);
/* Ombres : noir + halo blanc subtil */
--mmg-shadow-1: 0 1px 3px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04);
--mmg-shadow-2: 0 6px 18px rgba(0, 0, 0, 0.60), 0 0 0 1px rgba(255, 255, 255, 0.05);
--mmg-shadow-3: 0 16px 40px rgba(0, 0, 0, 0.70), 0 0 0 1px rgba(255, 255, 255, 0.06);
--mmg-shadow-elevated: 0 1px 3px rgba(0, 0, 0, 0.40), 0 0 0 1px rgba(255, 255, 255, 0.05);
--mmg-shadow-elevated-hover: 0 4px 12px rgba(0, 0, 0, 0.50), 0 0 0 1px rgba(255, 255, 255, 0.06);
--mmg-shadow-flat: 0 0 0 1px rgba(255, 255, 255, 0.04);
--mmg-shadow-focus: 0 0 0 3px color-mix(in srgb, var(--mmg-color-accent) 55%, transparent);
/* États dark */
--mmg-color-state-disabled-bg: #1E222C;
--mmg-color-state-disabled-text: #5A6072;
--mmg-color-state-disabled-border: var(--mmg-color-gray-d-border);
/* Artwork dark */
--mmg-color-art-light: #1E222C;
--mmg-color-art-dark: #E5E8F0;
}
/* prefers-color-scheme: dark — applique les overrides si l'user n'a pas
forcé [data-mmg-theme]. */
@media (prefers-color-scheme: dark) {
:root:not([data-mmg-theme="light"]) {
--mmg-color-bg-page: var(--mmg-color-gray-d-bg);
--mmg-color-bg-surface: var(--mmg-color-gray-d-surface);
--mmg-color-bg-raised: var(--mmg-color-gray-d-raised);
--mmg-color-bg-muted: var(--mmg-color-gray-d-muted);
--mmg-color-bg-subtle: var(--mmg-color-gray-d-subtle);
--mmg-color-bg-overlay: rgba(0, 0, 0, 0.78);
--mmg-color-border: var(--mmg-color-gray-d-border);
--mmg-color-border-soft: var(--mmg-color-gray-d-border-soft);
--mmg-color-border-strong: var(--mmg-color-gray-d-border-strong);
--mmg-color-text-primary: var(--mmg-color-gray-d-text-1);
--mmg-color-text-secondary: var(--mmg-color-gray-d-text-2);
--mmg-color-text-tertiary: var(--mmg-color-gray-d-text-3);
--mmg-color-text-quaternary: var(--mmg-color-gray-d-text-4);
--mmg-color-success: var(--mmg-color-green-d-500);
--mmg-color-success-soft: var(--mmg-color-green-d-soft);
--mmg-color-success-border: var(--mmg-color-green-d-border);
--mmg-color-warning: var(--mmg-color-amber-d-500);
--mmg-color-warning-soft: var(--mmg-color-amber-d-soft);
--mmg-color-warning-border: var(--mmg-color-amber-d-border);
--mmg-color-danger: var(--mmg-color-red-d-500);
--mmg-color-danger-soft: var(--mmg-color-red-d-soft);
--mmg-color-danger-border: var(--mmg-color-red-d-border);
--mmg-color-info: var(--mmg-color-blue-d-500);
--mmg-color-info-soft: var(--mmg-color-blue-d-soft);
--mmg-color-info-border: var(--mmg-color-blue-d-border);
}
}
+98
View File
@@ -0,0 +1,98 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — System tokens (non-color)
Espacements, rayons, typographie, motion, density, z-index, layout.
════════════════════════════════════════════════════════════════ */
:root {
/* — Rayons ————————————————————————————————— */
--mmg-radius-pill: 9999px;
--mmg-radius-sm: 8px;
--mmg-radius-md: 12px;
--mmg-radius-card: 20px;
--mmg-radius-panel: 24px;
--mmg-radius-icon: 12px;
/* — Espacement (4pt grid) ————————————————————— */
--mmg-space-0: 0;
--mmg-space-1: 4px;
--mmg-space-2: 8px;
--mmg-space-3: 12px;
--mmg-space-4: 16px;
--mmg-space-5: 20px;
--mmg-space-6: 24px;
--mmg-space-7: 32px;
--mmg-space-8: 40px;
--mmg-space-9: 48px;
--mmg-space-10: 64px;
--mmg-space-11: 80px;
--mmg-space-12: 120px;
/* — Typographie ————————————————————————————————— */
--mmg-font-sans: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mmg-font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--mmg-font-size-xs: 11px;
--mmg-font-size-sm: 13px;
--mmg-font-size-base: 15px;
--mmg-font-size-lg: 17px;
--mmg-font-size-xl: 20px;
--mmg-font-size-2xl: 24px;
--mmg-font-size-3xl: 30px;
--mmg-font-size-4xl: 36px;
--mmg-font-size-5xl: 48px;
--mmg-line-height-tight: 1.2;
--mmg-line-height-snug: 1.4;
--mmg-line-height-normal: 1.6;
--mmg-font-weight-regular: 400;
--mmg-font-weight-medium: 500;
--mmg-font-weight-semi: 600;
--mmg-font-weight-bold: 700;
--mmg-font-weight-extra: 800;
/* — Motion ————————————————————————————————— */
--mmg-ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--mmg-ease-emphasis: cubic-bezier(0.2, 0.8, 0.2, 1);
--mmg-duration-fast: 120ms;
--mmg-duration-base: 200ms;
--mmg-duration-slow: 320ms;
/* — Layout ————————————————————————————————— */
--mmg-container-max: 1200px;
--mmg-container-narrow: 800px;
--mmg-container-wide: 1440px;
--mmg-z-dropdown: 100;
--mmg-z-sticky: 200;
--mmg-z-modal: 1000;
--mmg-z-toast: 1100;
--mmg-z-tooltip: 1200;
/* — Touch targets (Apple HIG / WCAG SC 2.5.5) ————— */
--mmg-touch-min: 44px;
/* — Densité (multiplicateurs) ————————————————————————
comfortable = défaut, cozy = pro, compact = power user.
Appliqué via [data-mmg-density] sur sous-arbre. */
--mmg-density-scale: 1;
--mmg-density-row-height: 44px;
--mmg-density-input-height: 40px;
--mmg-density-padding-x: var(--mmg-space-4);
--mmg-density-padding-y: var(--mmg-space-3);
}
[data-mmg-density="cozy"] {
--mmg-density-scale: 0.85;
--mmg-density-row-height: 36px;
--mmg-density-input-height: 36px;
--mmg-density-padding-x: var(--mmg-space-3);
--mmg-density-padding-y: var(--mmg-space-2);
}
[data-mmg-density="compact"] {
--mmg-density-scale: 0.72;
--mmg-density-row-height: 28px;
--mmg-density-input-height: 30px;
--mmg-density-padding-x: var(--mmg-space-2);
--mmg-density-padding-y: 4px;
}
+141
View File
@@ -0,0 +1,141 @@
/* ════════════════════════════════════════════════════════════════
DSMMG — Utility classes
Composables, opt-in, sans charger un composant. Préfixe mmg-u-.
════════════════════════════════════════════════════════════════ */
/* — Layout primitives ————————————————————————— */
.mmg-u-stack { display: flex; flex-direction: column; }
.mmg-u-stack-1 { gap: var(--mmg-space-1); }
.mmg-u-stack-2 { gap: var(--mmg-space-2); }
.mmg-u-stack-3 { gap: var(--mmg-space-3); }
.mmg-u-stack-4 { gap: var(--mmg-space-4); }
.mmg-u-stack-6 { gap: var(--mmg-space-6); }
.mmg-u-stack-8 { gap: var(--mmg-space-7); }
.mmg-u-flex { display: flex; }
.mmg-u-flex-row { display: flex; flex-direction: row; }
.mmg-u-flex-col { display: flex; flex-direction: column; }
.mmg-u-grid { display: grid; }
.mmg-u-inline { display: inline-flex; }
.mmg-u-items-start { align-items: flex-start; }
.mmg-u-items-center { align-items: center; }
.mmg-u-items-end { align-items: flex-end; }
.mmg-u-items-stretch { align-items: stretch; }
.mmg-u-justify-start { justify-content: flex-start; }
.mmg-u-justify-center { justify-content: center; }
.mmg-u-justify-end { justify-content: flex-end; }
.mmg-u-justify-between { justify-content: space-between; }
.mmg-u-justify-around { justify-content: space-around; }
.mmg-u-gap-1 { gap: var(--mmg-space-1); }
.mmg-u-gap-2 { gap: var(--mmg-space-2); }
.mmg-u-gap-3 { gap: var(--mmg-space-3); }
.mmg-u-gap-4 { gap: var(--mmg-space-4); }
.mmg-u-gap-6 { gap: var(--mmg-space-6); }
.mmg-u-cluster { display: flex; flex-wrap: wrap; gap: var(--mmg-space-3); align-items: center; }
.mmg-u-center { display: grid; place-items: center; }
.mmg-u-spacer { flex: 1 1 auto; }
.mmg-u-w-full { width: 100%; }
.mmg-u-h-full { height: 100%; }
/* — Visibility ————————————————————————— */
.mmg-u-hidden { display: none !important; }
.mmg-u-invisible { visibility: hidden; }
/* — Text ————————————————————————— */
.mmg-u-text-xs { font-size: var(--mmg-font-size-xs); }
.mmg-u-text-sm { font-size: var(--mmg-font-size-sm); }
.mmg-u-text-base { font-size: var(--mmg-font-size-base); }
.mmg-u-text-lg { font-size: var(--mmg-font-size-lg); }
.mmg-u-text-xl { font-size: var(--mmg-font-size-xl); }
.mmg-u-text-2xl { font-size: var(--mmg-font-size-2xl); }
.mmg-u-text-primary { color: var(--mmg-color-text-primary); }
.mmg-u-text-secondary { color: var(--mmg-color-text-secondary); }
.mmg-u-text-tertiary { color: var(--mmg-color-text-tertiary); }
.mmg-u-text-accent { color: var(--mmg-color-accent); }
.mmg-u-text-success { color: var(--mmg-color-success); }
.mmg-u-text-danger { color: var(--mmg-color-danger); }
.mmg-u-text-warning { color: var(--mmg-color-warning); }
.mmg-u-text-left { text-align: left; }
.mmg-u-text-center { text-align: center; }
.mmg-u-text-right { text-align: right; }
.mmg-u-font-regular { font-weight: var(--mmg-font-weight-regular); }
.mmg-u-font-medium { font-weight: var(--mmg-font-weight-medium); }
.mmg-u-font-semi { font-weight: var(--mmg-font-weight-semi); }
.mmg-u-font-bold { font-weight: var(--mmg-font-weight-bold); }
.mmg-u-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* — Background / surfaces ————————————————————————— */
.mmg-u-bg-page { background: var(--mmg-color-bg-page); }
.mmg-u-bg-surface { background: var(--mmg-color-bg-surface); }
.mmg-u-bg-raised { background: var(--mmg-color-bg-raised); }
.mmg-u-bg-muted { background: var(--mmg-color-bg-muted); }
.mmg-u-bg-accent { background: var(--mmg-color-accent); color: var(--mmg-color-accent-on); }
/* — Margins / paddings ————————————————————————— */
.mmg-u-m-0 { margin: 0; }
.mmg-u-mt-1 { margin-top: var(--mmg-space-1); }
.mmg-u-mt-2 { margin-top: var(--mmg-space-2); }
.mmg-u-mt-3 { margin-top: var(--mmg-space-3); }
.mmg-u-mt-4 { margin-top: var(--mmg-space-4); }
.mmg-u-mt-6 { margin-top: var(--mmg-space-6); }
.mmg-u-mb-1 { margin-bottom: var(--mmg-space-1); }
.mmg-u-mb-2 { margin-bottom: var(--mmg-space-2); }
.mmg-u-mb-3 { margin-bottom: var(--mmg-space-3); }
.mmg-u-mb-4 { margin-bottom: var(--mmg-space-4); }
.mmg-u-mb-6 { margin-bottom: var(--mmg-space-6); }
.mmg-u-p-0 { padding: 0; }
.mmg-u-p-2 { padding: var(--mmg-space-2); }
.mmg-u-p-3 { padding: var(--mmg-space-3); }
.mmg-u-p-4 { padding: var(--mmg-space-4); }
.mmg-u-p-6 { padding: var(--mmg-space-6); }
/* — Radius / shadow ————————————————————————— */
.mmg-u-rounded-sm { border-radius: var(--mmg-radius-sm); }
.mmg-u-rounded-md { border-radius: var(--mmg-radius-md); }
.mmg-u-rounded-pill { border-radius: var(--mmg-radius-pill); }
.mmg-u-rounded-card { border-radius: var(--mmg-radius-card); }
.mmg-u-shadow-1 { box-shadow: var(--mmg-shadow-1); }
.mmg-u-shadow-2 { box-shadow: var(--mmg-shadow-2); }
.mmg-u-shadow-3 { box-shadow: var(--mmg-shadow-3); }
.mmg-u-shadow-flat { box-shadow: var(--mmg-shadow-flat); }
/* — A11y ————————————————————————— */
.mmg-u-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.mmg-u-focus-visible {
outline: 2px solid var(--mmg-color-accent);
outline-offset: 2px;
}
/* — Forced colors (Windows High Contrast) ————————————————————————— */
@media (forced-colors: active) {
.mmg-u-bg-accent,
.mmg-u-text-accent {
forced-color-adjust: none;
}
}
+191
View File
@@ -0,0 +1,191 @@
// DSMMG icon builder
// - Émet un bundle complet (icons.css) avec base + toutes les classes
// - Émet aussi des modules par icône (each/<name>.css) pour permettre le
// tree-shaking côté consommateur (import "@managemate/icons/each/check-line").
// - Émet le module base.css (la classe .mmg-icon + sizes) en stand-alone,
// utile quand on n'importe que des icônes individuelles.
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REMIX_DIR = join(__dirname, "node_modules", "remixicon", "icons");
const DIST_DIR = join(__dirname, "dist");
const EACH_DIR = join(DIST_DIR, "each");
if (existsSync(DIST_DIR)) await rm(DIST_DIR, { recursive: true });
await mkdir(EACH_DIR, { recursive: true });
const ICONS = [
["Arrows", "arrow-right"],
["Arrows", "arrow-left"],
["Arrows", "arrow-up"],
["Arrows", "arrow-down"],
["Arrows", "arrow-right-up"],
["Arrows", "arrow-go-back"],
["Arrows", "expand-up-down"],
["System", "external-link"],
["System", "check"],
["System", "close"],
["System", "add"],
["System", "subtract"],
["System", "search-2"],
["System", "filter-3"],
["System", "more"],
["System", "more-2"],
["System", "menu"],
["System", "settings-3"],
["System", "refresh"],
["System", "delete-bin"],
["Design", "edit"],
["System", "share"],
["System", "download"],
["System", "upload"],
["System", "information"],
["System", "alert"],
["System", "error-warning"],
["System", "checkbox-circle"],
["System", "close-circle"],
["System", "question"],
["System", "spam"],
["System", "shield-check"],
["User & Faces", "user"],
["User & Faces", "user-add"],
["User & Faces", "team"],
["User & Faces", "account-circle"],
["System", "logout-box-r"],
["System", "login-box"],
["System", "lock"],
["System", "lock-unlock"],
["System", "eye"],
["System", "eye-off"],
["Business", "mail"],
["Business", "mail-send"],
["Communication", "message-2"],
["Device", "phone"],
["Media", "notification-3"],
["Communication", "chat-3"],
["Business", "global"],
["Business", "calendar"],
["Business", "calendar-check"],
["System", "time"],
["System", "history"],
["Document", "file-text"],
["Document", "file"],
["Document", "folder"],
["Document", "folder-open"],
["Document", "file-pdf"],
["Document", "file-copy"],
["Editor", "attachment"],
["Document", "draft"],
["Business", "inbox"],
["Map", "rocket-2"],
["Buildings", "home-4"],
["Buildings", "building"],
["Buildings", "store-2"],
["Buildings", "community"],
["Map", "map-pin-2"],
["Map", "map-2"],
["Finance", "money-euro-circle"],
["Finance", "bank-card"],
["Finance", "shopping-cart-2"],
["Finance", "shopping-bag-3"],
["Finance", "price-tag-3"],
["Finance", "coins"],
["Business", "briefcase-4"],
["Business", "line-chart"],
["Business", "bar-chart"],
["Business", "pie-chart"],
["Business", "stack"],
["System", "dashboard"],
["System", "apps-2"],
["System", "function"],
["Business", "presentation"],
["Document", "newspaper"],
["Document", "article"],
["Document", "book-open"],
["Business", "bookmark-3"],
["Health & Medical", "heart-3"],
["System", "star"],
["System", "thumb-up"],
["Design", "palette"],
["Design", "magic"],
["Weather", "flashlight"],
["Weather", "sun"],
["Weather", "moon"],
];
const BASE_CSS = `/* DSMMG icons — base (auto-generated). */
.mmg-icon {
display: inline-block;
width: 1.25em;
height: 1.25em;
vertical-align: -0.225em;
background: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
flex-shrink: 0;
}
.mmg-icon--xs { width: 12px; height: 12px; }
.mmg-icon--sm { width: 14px; height: 14px; }
.mmg-icon--md { width: 18px; height: 18px; }
.mmg-icon--lg { width: 24px; height: 24px; }
.mmg-icon--xl { width: 32px; height: 32px; }
.mmg-icon--2xl { width: 48px; height: 48px; }
`;
const rules = [];
const names = [];
const missing = [];
for (const [category, base, alias] of ICONS) {
for (const variant of ["line", "fill"]) {
const file = join(REMIX_DIR, category, `${base}-${variant}.svg`);
if (!existsSync(file)) {
missing.push(`${category}/${base}-${variant}`);
continue;
}
const svg = (await readFile(file, "utf8"))
.replace(/[\r\n]+/g, "")
.replace(/"/g, "'")
.replace(/\s+/g, " ")
.trim();
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
const className = `${alias ?? base}-${variant}`;
const rule = `.mmg-icon-${className} { mask-image: url("${url}"); -webkit-mask-image: url("${url}"); }`;
rules.push(rule);
names.push(className);
// Module subset par icône — n'inclut PAS la base, à charger séparément ou
// implicitement via @managemate/icons/base.
await writeFile(join(EACH_DIR, `${className}.css`), rule + "\n");
}
}
// Bundle complet — base + toutes les classes
await writeFile(join(DIST_DIR, "icons.css"), BASE_CSS + "\n" + rules.join("\n") + "\n");
// Base seule — utile pour ceux qui font du subset
await writeFile(join(DIST_DIR, "base.css"), BASE_CSS);
await writeFile(join(DIST_DIR, "icons.json"), JSON.stringify(names, null, 2));
await writeFile(
join(DIST_DIR, "names.js"),
`export default ${JSON.stringify(names, null, 2)};\n`,
);
await writeFile(
join(DIST_DIR, "names.d.ts"),
`declare const names: string[];\nexport default names;\n`,
);
console.log(`Built ${names.length} icon classes (${ICONS.length} icons × 2 variants)`);
console.log(` - dist/icons.css (bundle, ${BASE_CSS.length + rules.join("\n").length} bytes)`);
console.log(` - dist/base.css (base only)`);
console.log(` - dist/each/<name>.css (${names.length} per-icon modules)`);
if (missing.length) {
console.warn(`Missing ${missing.length} files:`, missing.slice(0, 5));
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@managemate/icons",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@managemate/icons",
"version": "0.1.0",
"dependencies": {
"remixicon": "^4.9.1"
}
},
"node_modules/remixicon": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.9.1.tgz",
"integrity": "sha512-36gLSoujkabnCFZFDyP17VNh9piuBA/rsXUb4auSJWLGsHVXtmxLj/EM5FjaEAGnk8oIAj1Azob/DZ2N+90lAQ==",
"license": "Apache-2.0"
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@managemate/icons",
"version": "0.1.0",
"description": "DSMMG icon system — CSS classes mmg-icon-* + per-icon subset modules",
"license": "UNLICENSED",
"private": true,
"sideEffects": [
"*.css"
],
"main": "./dist/icons.css",
"style": "./dist/icons.css",
"exports": {
".": {
"style": "./dist/icons.css",
"default": "./dist/icons.css"
},
"./base": "./dist/base.css",
"./each/*": "./dist/each/*.css",
"./names": {
"types": "./dist/names.d.ts",
"default": "./dist/names.js"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "node build.mjs"
},
"files": [
"dist",
"build.mjs"
],
"dependencies": {
"remixicon": "^4.9.1"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@managemate/react",
"version": "0.1.0",
"description": "DSMMG React components — headless-first, typed variants, tree-shakable",
"type": "module",
"license": "UNLICENSED",
"private": true,
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@floating-ui/react": "^0.27.0",
"@managemate/css": "workspace:*",
"@managemate/icons": "workspace:*",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"axe-core": "^4.10.2",
"jsdom": "^25.0.1",
"tsup": "^8.5.0",
"typescript": "~5.7.0",
"vitest": "^2.1.9",
"vitest-axe": "^0.1.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { useId, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type AccordionProps = {
label: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
className?: string;
};
export function Accordion({ label, defaultOpen = false, children, className }: AccordionProps) {
const [open, setOpen] = useState(defaultOpen);
const id = useId();
return (
<div className={cx("mmg-accordion", className)} data-open={open}>
<button
type="button"
className="mmg-accordion__btn"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
>
<span>{label}</span>
<Icon name="arrow-down-line" className="mmg-accordion__chevron" />
</button>
<div id={id} role="region" className="mmg-accordion__panel">
{children}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { Icon, type IconName } from "./Icon";
export type SidebarItem = {
id?: string;
label: ReactNode;
href: string;
icon?: IconName;
current?: boolean;
};
export type SidebarSection = {
category?: ReactNode;
items: SidebarItem[];
};
export function AppShell({ children }: { children: ReactNode }) {
return <div className="mmg-app-shell">{children}</div>;
}
export type SidebarProps = {
brand?: ReactNode;
sections: SidebarSection[];
footer?: ReactNode;
};
export function Sidebar({ brand, sections, footer }: SidebarProps) {
return (
<aside className="mmg-sidebar">
{brand && <div className="mmg-sidebar__header">{brand}</div>}
<nav className="mmg-sidebar__body" aria-label="Navigation latérale">
{sections.map((sec, i) => (
<div key={i}>
{sec.category && <div className="mmg-sidebar__category">{sec.category}</div>}
{sec.items.map((item, j) => (
<a
key={item.id ?? j}
href={item.href}
aria-current={item.current ? "page" : undefined}
className="mmg-sidebar__item"
>
{item.icon && <Icon name={item.icon} />}
{item.label}
</a>
))}
</div>
))}
</nav>
{footer && <div className="mmg-sidebar__footer">{footer}</div>}
</aside>
);
}
export function AppMain({ children }: { children: ReactNode }) {
return <div className="mmg-app-main">{children}</div>;
}
export type TopbarProps = {
title?: ReactNode;
actions?: ReactNode;
};
export function Topbar({ title, actions }: TopbarProps) {
return (
<header className="mmg-topbar">
<h1 className="mmg-topbar__title">{title}</h1>
{actions && <div className="mmg-topbar__actions">{actions}</div>}
</header>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type ArticleAsideProps = {
children: ReactNode;
sticky?: boolean;
className?: string;
};
export function ArticleAside({ children, sticky = true, className }: ArticleAsideProps) {
return (
<div className={cx("mmg-aside", sticky && "mmg-aside--sticky", className)}>
{children}
</div>
);
}
ArticleAside.Section = function ArticleAsideSection({
title,
children,
}: {
title: ReactNode;
children: ReactNode;
}) {
return (
<section className="mmg-aside__section">
<h3 className="mmg-aside__title">{title}</h3>
{children}
</section>
);
};
ArticleAside.Links = function ArticleAsideLinks({
items,
}: {
items: { label: ReactNode; href: string; external?: boolean }[];
}) {
return (
<ul className="mmg-aside__links">
{items.map((item, i) => (
<li key={i}>
<a
href={item.href}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
>
<Icon name={item.external ? "external-link-line" : "arrow-right-line"} size="sm" />
{item.label}
</a>
</li>
))}
</ul>
);
};
ArticleAside.Documents = function ArticleAsideDocuments({
items,
}: {
items: { label: ReactNode; href: string; size?: string; format?: string }[];
}) {
return (
<ul className="mmg-aside__docs">
{items.map((item, i) => (
<li key={i}>
<a href={item.href} download>
<Icon name="file-pdf-line" size="md" />
<span>
<span className="mmg-aside__doc-label">{item.label}</span>
{(item.size || item.format) && (
<span className="mmg-aside__doc-meta">
{[item.format, item.size].filter(Boolean).join(" · ")}
</span>
)}
</span>
</a>
</li>
))}
</ul>
);
};
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type ArticleCalloutProps = {
type?: "info" | "warning" | "important";
title?: ReactNode;
children: ReactNode;
};
export function ArticleCallout({ type = "info", title, children }: ArticleCalloutProps) {
const icon: IconName =
type === "warning"
? "alert-fill"
: type === "important"
? "error-warning-fill"
: "information-fill";
return (
<aside className={cx("mmg-prose__callout", `mmg-prose__callout--${type}`)}>
<Icon name={icon} size="md" className="mmg-prose__callout-icon" />
<div>
{title && <strong className="mmg-prose__callout-title">{title}</strong>}
<div>{children}</div>
</div>
</aside>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
export type ArticleFooterProps = {
/** Date de dernière mise à jour (ISO ou Date) */
updatedAt?: string | Date;
/** Locale pour le formatage de date — défaut fr-FR */
locale?: string;
/** URL canonique pour le partage */
shareUrl?: string;
/** Callback feedback */
onFeedback?: (helpful: boolean) => void;
className?: string;
};
export function ArticleFooter({
updatedAt,
locale = "fr-FR",
shareUrl,
onFeedback,
className,
}: ArticleFooterProps) {
const [helpful, setHelpful] = useState<"yes" | "no" | null>(null);
const date = updatedAt
? typeof updatedAt === "string"
? new Date(updatedAt)
: updatedAt
: null;
const formatted = date
? date.toLocaleDateString(locale, { day: "numeric", month: "long", year: "numeric" })
: null;
const submit = (h: boolean) => {
setHelpful(h ? "yes" : "no");
onFeedback?.(h);
};
return (
<footer className={cx("mmg-article__footer", className)}>
{formatted && (
<div className="mmg-article__updated">
<Icon name="time-line" size="sm" />
Page mise à jour le <strong>{formatted}</strong>
</div>
)}
{shareUrl && (
<div className="mmg-article__share">
<span className="mmg-article__share-label">Partager cette page&nbsp;:</span>
<a
href={`mailto:?subject=${encodeURIComponent("À voir")}&body=${encodeURIComponent(shareUrl)}`}
aria-label="Partager par e-mail"
className="mmg-article__share-btn"
>
<Icon name="mail-line" size="md" />
</a>
<button
type="button"
aria-label="Copier le lien"
className="mmg-article__share-btn"
onClick={() => navigator.clipboard?.writeText(shareUrl)}
>
<Icon name="file-copy-line" size="md" />
</button>
<button
type="button"
aria-label="Imprimer"
className="mmg-article__share-btn"
onClick={() => window.print()}
>
<Icon name="external-link-line" size="md" />
</button>
</div>
)}
<div className="mmg-article__feedback">
{helpful === null ? (
<>
<span className="mmg-article__feedback-label">
Cette page vous a-t-elle é utile&nbsp;?
</span>
<div style={{ display: "flex", gap: "var(--mmg-space-2)" }}>
<button
type="button"
className="mmg-article__feedback-btn"
onClick={() => submit(true)}
>
<Icon name="thumb-up-line" size="sm" />
Oui
</button>
<button
type="button"
className="mmg-article__feedback-btn"
onClick={() => submit(false)}
>
<Icon name="thumb-up-line" size="sm" style={{ transform: "rotate(180deg)" }} />
Non
</button>
</div>
</>
) : (
<span className="mmg-article__feedback-label">
Merci pour votre retour. Vos remarques nous aident à améliorer cette page.
</span>
)}
</div>
</footer>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type ArticleHeaderProps = {
breadcrumb?: { items: { label: ReactNode; href: string }[]; current: ReactNode };
/** Catégorie/type ("Démarche", "Information") affiché en eyebrow */
eyebrow?: ReactNode;
title: ReactNode;
lead?: ReactNode;
/** Métadonnées : auteur, dates, durée de lecture */
meta?: ReactNode;
className?: string;
};
export function ArticleHeader({ breadcrumb, eyebrow, title, lead, meta, className }: ArticleHeaderProps) {
return (
<header className={cx("mmg-article__header", className)}>
<div className="mmg-container mmg-container--narrow">
{breadcrumb && (
<nav className="mmg-breadcrumb" aria-label="Fil d'Ariane">
{breadcrumb.items.map((item, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<a href={item.href}>{item.label}</a>
<span className="mmg-breadcrumb__sep" aria-hidden>/</span>
</span>
))}
<span className="mmg-breadcrumb__current">{breadcrumb.current}</span>
</nav>
)}
{eyebrow && <div className="mmg-article__eyebrow">{eyebrow}</div>}
<h1 className="mmg-article__title">{title}</h1>
{lead && <p className="mmg-article__lead">{lead}</p>}
{meta && <div className="mmg-article__meta">{meta}</div>}
</div>
</header>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type ArticlePageProps = {
/** En-tête de page (ArticleHeader) */
header: ReactNode;
/** Aside (ArticleAside) — colonne droite, sticky en desktop */
aside?: ReactNode;
/** Footer (ArticleFooter) */
footer?: ReactNode;
/** Le contenu principal */
children: ReactNode;
className?: string;
};
export function ArticlePage({ header, aside, footer, children, className }: ArticlePageProps) {
return (
<article className={cx("mmg-article", className)}>
{header}
<div className="mmg-article__layout">
<div className="mmg-article__main">
<div className="mmg-prose">{children}</div>
{footer}
</div>
{aside && <aside className="mmg-article__aside">{aside}</aside>}
</div>
</article>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState, type ReactNode } from "react";
import { cx } from "./utils";
export type ArticleTOCProps = {
items: { id: string; label: ReactNode }[];
title?: ReactNode;
};
export function ArticleTOC({ items, title = "Sommaire" }: ArticleTOCProps) {
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible) setActive(visible.target.id);
},
{ rootMargin: "-20% 0px -70% 0px" },
);
items.forEach((item) => {
const el = document.getElementById(item.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [items]);
return (
<nav className="mmg-toc" aria-label="Sommaire">
<h3 className="mmg-aside__title">{title}</h3>
<ol className="mmg-toc__list">
{items.map((item, i) => (
<li key={item.id}>
<a
href={`#${item.id}`}
className={cx("mmg-toc__link", active === item.id && "mmg-toc__link--active")}
>
<span className="mmg-toc__num">{i + 1}.</span>
{item.label}
</a>
</li>
))}
</ol>
</nav>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { type ReactNode } from "react";
import { cx } from "./utils";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
export type AvatarStatus = "online" | "away" | "busy" | "offline";
export type AvatarShape = "circle" | "square";
export type AvatarProps = {
/** Initiales si pas de src (auto-shortened à 2 chars). */
initials?: string;
/** URL de l'image. */
src?: string;
/** Alt text de l'image, ou aria-label de l'avatar si initiales. */
alt?: string;
size?: AvatarSize;
shape?: AvatarShape;
/** Indicateur de présence en bas-droite. */
status?: AvatarStatus;
/** Couleur de fond pour les initiales (auto-générée à partir du nom si non fournie). */
color?: "auto" | "neutral" | "brand" | "blue" | "green" | "amber" | "violet";
/** Bordure visible (utile pour AvatarGroup). */
bordered?: boolean;
className?: string;
children?: ReactNode;
};
/** Hash stable d'une string vers un index 0..N-1 pour générer une couleur. */
function hashIndex(str: string, n: number) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0;
return Math.abs(h) % n;
}
const AUTO_COLORS = ["brand", "blue", "green", "amber", "violet"] as const;
export function Avatar({
initials,
src,
alt,
size = "md",
shape = "circle",
status,
color = "auto",
bordered,
className,
children,
}: AvatarProps) {
const finalInitials = initials?.slice(0, 2).toUpperCase();
const autoColor =
color === "auto" && finalInitials
? AUTO_COLORS[hashIndex(finalInitials, AUTO_COLORS.length)]
: color === "auto"
? "neutral"
: color;
return (
<span
className={cx(
"mmg-avatar",
size !== "md" && `mmg-avatar--${size}`,
shape === "square" && "mmg-avatar--square",
!src && `mmg-avatar--${autoColor}`,
bordered && "mmg-avatar--bordered",
className,
)}
role={src ? undefined : "img"}
aria-label={alt}
>
{src ? (
<img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
) : (
children ?? finalInitials ?? null
)}
{status && (
<span
className={cx("mmg-avatar__status", `mmg-avatar__status--${status}`)}
aria-label={
status === "online"
? "En ligne"
: status === "away"
? "Absent"
: status === "busy"
? "Occupé"
: "Hors ligne"
}
/>
)}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Children, isValidElement, type ReactNode } from "react";
import { cx } from "./utils";
import { Avatar, type AvatarSize } from "./Avatar";
export type AvatarGroupProps = {
/** Avatars à empiler (Avatar enfants ou liste de descripteurs). */
children?: ReactNode;
/** Liste alternative d'avatars (équivalent à <Avatar /> en children). */
avatars?: { initials?: string; src?: string; alt?: string; href?: string }[];
/** Limite d'avatars visibles avant le compteur "+N". Défaut 4. */
max?: number;
size?: AvatarSize;
/** Total réel pour le "+N", utile si on n'affiche qu'un échantillon. */
total?: number;
className?: string;
};
/**
* AvatarGroup — empile des avatars avec un overflow "+N".
*
* Pattern Linear / GitHub / Slack pour montrer plusieurs participants
* sans saturer l'espace. Au survol d'un avatar, le z-index remonte pour
* permettre d'identifier qui c'est. Les avatars deviennent `bordered`
* pour se découper visuellement les uns des autres.
*/
export function AvatarGroup({
children,
avatars,
max = 4,
size = "md",
total,
className,
}: AvatarGroupProps) {
const items = avatars
? avatars.map((a, i) => (
<Avatar key={i} {...a} size={size} bordered />
))
: Children.toArray(children).filter(isValidElement);
const visible = items.slice(0, max);
const remaining = (total ?? items.length) - visible.length;
return (
<div className={cx("mmg-avatar-group", `mmg-avatar-group--${size}`, className)} role="group">
{visible}
{remaining > 0 && (
<span
className={cx("mmg-avatar", "mmg-avatar--bordered", "mmg-avatar--neutral", `mmg-avatar--${size}`, "mmg-avatar-group__more")}
aria-label={`et ${remaining} autre${remaining > 1 ? "s" : ""}`}
>
+{remaining}
</span>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type BannerProps = {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
};
export function Banner({ title, description, action, className }: BannerProps) {
return (
<div className={cx("mmg-banner", className)}>
<div>
<h3 className="mmg-banner__title">{title}</h3>
{description && <p className="mmg-banner__desc">{description}</p>}
</div>
{action && <div className="mmg-banner__action">{action}</div>}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type BreadcrumbProps = {
items: { label: ReactNode; href: string }[];
current: ReactNode;
className?: string;
};
export function Breadcrumb({ items, current, className }: BreadcrumbProps) {
return (
<nav aria-label="Fil d'Ariane" className={cx("mmg-breadcrumb", className)}>
{items.map((item, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<a href={item.href}>{item.label}</a>
<span className="mmg-breadcrumb__sep" aria-hidden>/</span>
</span>
))}
<span className="mmg-breadcrumb__current">{current}</span>
</nav>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { Button } from "./Button";
describe("Button", () => {
it("rend le label", () => {
render(<Button>Continuer</Button>);
expect(screen.getByRole("button", { name: "Continuer" })).toBeInTheDocument();
});
it("appelle onClick quand cliqué", async () => {
const user = userEvent.setup();
let clicks = 0;
render(<Button onClick={() => clicks++}>Cliquer</Button>);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(1);
});
it("est focusable au clavier (Tab)", async () => {
const user = userEvent.setup();
render(<Button>OK</Button>);
await user.tab();
expect(screen.getByRole("button")).toHaveFocus();
});
it("active onClick avec Espace ou Entrée", async () => {
const user = userEvent.setup();
let clicks = 0;
render(<Button onClick={() => clicks++}>Cliquer</Button>);
const btn = screen.getByRole("button");
btn.focus();
await user.keyboard("{Enter}");
await user.keyboard(" ");
expect(clicks).toBe(2);
});
it("respecte disabled", async () => {
const user = userEvent.setup();
let clicks = 0;
render(
<Button disabled onClick={() => clicks++}>
OK
</Button>,
);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(0);
});
it("loading rend le bouton inactif aux clics", async () => {
const user = userEvent.setup();
let clicks = 0;
render(
<Button loading onClick={() => clicks++}>
OK
</Button>,
);
await user.click(screen.getByRole("button"));
expect(clicks).toBe(0);
});
it("n'a pas de violations axe-core (a11y)", async () => {
const { container } = render(
<>
<Button>Primary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
<Button disabled>Disabled</Button>
<Button icon="settings-3-line" aria-label="Paramètres" />
</>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
+139
View File
@@ -0,0 +1,139 @@
import {
forwardRef,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import { cva, type VariantProps } from "./cva";
import { Slot } from "./Slot";
import { Icon, type IconName } from "./Icon";
/**
* mmg-button — primitive bouton DSMMG.
*
* Architecture :
* - Headless via Slot/asChild — peut wrapper un <Link> ou tout autre
* composant tout en conservant le style et le comportement.
* - Variants typés via cva — autocomplete + type-safety.
* - États tous définis : default/hover/active/focus/focus-visible/disabled
* + loading (avec aria-busy).
*
* Accessibilité :
* - Sémantique <button> par défaut (RGAA 7.1)
* - Si `asChild` → l'enfant DOIT être focusable (a, button, [tabindex])
* - aria-busy pendant loading
* - aria-disabled si disabled (en plus de l'attribut natif)
* - Touch target ≥ 44×44 garanti via pseudo-element sur icon-only sm/xs
*/
const buttonStyles = cva("mmg-btn", {
variants: {
variant: {
primary: "mmg-btn--primary",
tonal: "mmg-btn--tonal",
secondary: "mmg-btn--secondary",
tertiary: "mmg-btn--tertiary",
ghost: "mmg-btn--ghost",
elevated: "mmg-btn--elevated",
danger: "mmg-btn--danger",
success: "mmg-btn--success",
},
size: {
xs: "mmg-btn--xs",
sm: "mmg-btn--sm",
md: "mmg-btn--md",
lg: "mmg-btn--lg",
xl: "mmg-btn--xl",
},
shape: {
pill: "mmg-btn--pill",
square: "mmg-btn--square",
},
iconOnly: {
true: "mmg-btn--icon-only",
},
block: {
true: "mmg-btn--block",
},
loading: {
true: "mmg-btn--loading",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
});
type ButtonStyleProps = VariantProps<typeof buttonStyles>;
export type ButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"size"
> &
ButtonStyleProps & {
/** Si vrai, fusionne props avec l'enfant unique (Radix-style polymorphism). */
asChild?: boolean;
/** Icône Remix avant le label */
icon?: IconName;
/** Position de l'icône (par défaut : gauche) */
iconPosition?: "left" | "right";
children?: ReactNode;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
asChild,
variant,
size,
shape,
iconOnly,
block,
loading,
icon,
iconPosition = "left",
className,
disabled,
children,
type,
...rest
},
ref,
) {
const Comp: React.ElementType = asChild ? Slot : "button";
const cls = buttonStyles({
variant,
size,
shape,
iconOnly,
block,
loading,
className,
});
const content = (
<>
{icon && iconPosition === "left" && <Icon name={icon} />}
{children}
{icon && iconPosition === "right" && <Icon name={icon} />}
</>
);
// Si asChild, on délègue le tag mais le wrapping interne reste
const isDisabled = Boolean(disabled || loading);
return (
<Comp
ref={ref}
className={cls}
type={asChild ? undefined : (type ?? "button")}
disabled={asChild ? undefined : isDisabled}
aria-busy={loading ? true : undefined}
aria-disabled={isDisabled ? true : undefined}
{...rest}
>
{content}
</Comp>
);
});
/** Type re-export pour les consommateurs qui veulent partager les variants. */
export type { ButtonStyleProps as ButtonVariants };
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { cx } from "./utils";
export type CalloutProps = {
title?: ReactNode;
children: ReactNode;
action?: ReactNode;
className?: string;
};
export function Callout({ title, children, action, className }: CalloutProps) {
return (
<div className={cx("mmg-callout", className)}>
{title && <h4 className="mmg-callout__title">{title}</h4>}
<div className="mmg-callout__body">{children}</div>
{action && <div>{action}</div>}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import { useState } from "react";
import { Combobox } from "./Combobox";
const OPTIONS = [
{ value: "a", label: "Alpha" },
{ value: "b", label: "Bravo" },
{ value: "c", label: "Charlie" },
];
function Wrapped() {
const [v, setV] = useState<string | undefined>();
return <Combobox label="Choix" options={OPTIONS} value={v} onChange={setV} />;
}
describe("Combobox", () => {
it("rend l'input role=combobox avec aria-expanded", () => {
render(<Wrapped />);
const input = screen.getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "false");
});
it("ouvre la listbox au focus", async () => {
const user = userEvent.setup();
render(<Wrapped />);
const input = screen.getByRole("combobox");
await user.click(input);
expect(input).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
it("filtre les options selon la requête", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("br");
const opts = screen.getAllByRole("option");
expect(opts).toHaveLength(1);
expect(opts[0]).toHaveTextContent("Bravo");
});
it("navigue avec ArrowDown / ArrowUp et sélectionne avec Enter", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
// 2 down → index 1 = Bravo
expect(screen.getByRole("combobox")).toHaveValue("Bravo");
});
it("ferme avec Escape", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("{Escape}");
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
it("affiche un message vide si aucun match", async () => {
const user = userEvent.setup();
render(<Wrapped />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("xyz");
expect(screen.getByText(/aucun résultat/i)).toBeInTheDocument();
});
it("a11y axe-core", async () => {
const { container } = render(<Wrapped />);
expect(await axe(container)).toHaveNoViolations();
});
});
+189
View File
@@ -0,0 +1,189 @@
import {
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from "@floating-ui/react";
import { cx } from "./utils";
export type ComboboxOption = { value: string; label: string; disabled?: boolean };
export type ComboboxProps = {
options: ComboboxOption[];
value?: string;
onChange: (v: string) => void;
label?: ReactNode;
placeholder?: string;
/** Texte affiché quand aucun match. */
emptyMessage?: ReactNode;
className?: string;
};
/**
* Combobox accessible — pattern WAI-ARIA 1.2 "combobox listbox".
*
* - role="combobox" sur l'input, role="listbox" sur la liste, role="option" par item
* - aria-activedescendant pour l'item courant (pas de focus déplacé hors input)
* - aria-expanded synchro avec l'état ouvert
* - flèches ↑↓, Home/End, Enter pour sélectionner, Esc pour fermer
* - Floating UI : flip + shift + size (clamp à la viewport, autoUpdate au scroll)
* - filter case-insensitive (extensible : passer un filterFn custom plus tard)
*
* Pour des cas avancés (multi-select, async, virtualisation), voir
* @TODO: ComboboxAsync / ComboboxMulti dans un futur major.
*/
export function Combobox({
options,
value,
onChange,
label,
placeholder = "Tapez pour rechercher…",
emptyMessage = "Aucun résultat",
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState(
value ? options.find((o) => o.value === value)?.label ?? "" : "",
);
const [activeIndex, setActiveIndex] = useState(-1);
const id = useId();
const listRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q === ""
? options
: options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query]);
const { refs, floatingStyles } = useFloating({
open,
onOpenChange: setOpen,
placement: "bottom-start",
middleware: [
offset(4),
flip({ padding: 8 }),
shift({ padding: 8 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
maxHeight: `${Math.min(320, availableHeight - 8)}px`,
});
},
}),
],
whileElementsMounted: autoUpdate,
});
const select = (opt: ComboboxOption) => {
if (opt.disabled) return;
onChange(opt.value);
setQuery(opt.label);
setOpen(false);
setActiveIndex(-1);
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActiveIndex((i) => Math.min(filtered.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
} else if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setActiveIndex(filtered.length - 1);
} else if (e.key === "Enter" && activeIndex >= 0 && filtered[activeIndex]) {
e.preventDefault();
select(filtered[activeIndex]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div className={cx("mmg-field", className)}>
{label && (
<label className="mmg-field__label" htmlFor={id}>
{label}
</label>
)}
<input
ref={refs.setReference}
id={id}
className="mmg-input"
role="combobox"
aria-expanded={open}
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 && filtered[activeIndex]
? `${id}-opt-${filtered[activeIndex].value}`
: undefined
}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 100)}
onKeyDown={onKey}
/>
{open && (
<div
ref={(el) => {
refs.setFloating(el);
listRef.current = el;
}}
id={`${id}-list`}
role="listbox"
className="mmg-menu mmg-combobox__list"
style={floatingStyles}
>
{filtered.length === 0 ? (
<div className="mmg-menu__empty">{emptyMessage}</div>
) : (
filtered.map((o, i) => (
<button
key={o.value}
id={`${id}-opt-${o.value}`}
type="button"
role="option"
aria-selected={o.value === value}
aria-disabled={o.disabled}
tabIndex={-1}
className={cx(
"mmg-menu__item",
i === activeIndex && "mmg-menu__item--active",
)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => select(o)}
>
{o.label}
</button>
))
)}
</div>
)}
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type Command = {
id: string;
label: ReactNode;
/** Texte indexable pour la recherche (par défaut = label si string). */
keywords?: string;
icon?: IconName;
group?: string;
shortcut?: string[]; // ex. ["⌘", "K"]
onSelect: () => void;
};
export function CommandMenu({
open,
onClose,
commands,
placeholder = "Tapez une commande ou un mot-clé…",
emptyMessage = "Aucun résultat. Essayez un autre mot.",
}: {
open: boolean;
onClose: () => void;
commands: Command[];
placeholder?: string;
emptyMessage?: string;
}) {
const [query, setQuery] = useState("");
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Reset à l'ouverture
useEffect(() => {
if (open) {
setQuery("");
setActiveIdx(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// ESC pour fermer
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Block scroll
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return commands;
return commands.filter((c) => {
const txt = (
(typeof c.label === "string" ? c.label : "") +
" " +
(c.keywords ?? "") +
" " +
(c.group ?? "")
).toLowerCase();
// Matching simple : tous les mots de q doivent être présents
return q.split(/\s+/).every((w) => txt.includes(w));
});
}, [commands, query]);
// Groupes
const groups = useMemo(() => {
const map = new Map<string, Command[]>();
for (const c of filtered) {
const g = c.group ?? "Commandes";
if (!map.has(g)) map.set(g, []);
map.get(g)!.push(c);
}
return [...map.entries()];
}, [filtered]);
// Navigation clavier
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(filtered.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(0, i - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = filtered[activeIdx];
if (cmd) {
cmd.onSelect();
onClose();
}
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, filtered, activeIdx, onClose]);
// Scroll into view
useEffect(() => {
const el = listRef.current?.querySelector('[aria-selected="true"]');
el?.scrollIntoView({ block: "nearest" });
}, [activeIdx]);
if (!open) return null;
let runningIdx = 0;
return (
<div
className="mmg-cmdk-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Palette de commandes"
className="mmg-cmdk"
>
<div className="mmg-cmdk__input-wrap">
<Icon name="search-2-line" size="md" />
<input
ref={inputRef}
className="mmg-cmdk__input"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIdx(0);
}}
placeholder={placeholder}
aria-label="Recherche"
/>
<kbd className="mmg-cmdk__kbd">esc</kbd>
</div>
<div ref={listRef} className="mmg-cmdk__list" role="listbox">
{filtered.length === 0 ? (
<div className="mmg-cmdk__empty">{emptyMessage}</div>
) : (
groups.map(([group, items]) => (
<div key={group}>
<div className="mmg-cmdk__group-label">{group}</div>
{items.map((c) => {
const isActive = filtered[activeIdx]?.id === c.id;
const idx = runningIdx++;
return (
<div
key={c.id}
role="option"
aria-selected={isActive}
data-idx={idx}
className="mmg-cmdk__item"
onClick={() => {
c.onSelect();
onClose();
}}
onMouseEnter={() => setActiveIdx(filtered.indexOf(c))}
>
{c.icon && <Icon name={c.icon} size="md" />}
<span style={{ flex: 1 }}>{c.label}</span>
{c.shortcut && (
<span className="mmg-cmdk__item-shortcut">
{c.shortcut.map((k, i) => (
<kbd key={i} className="mmg-cmdk__kbd">{k}</kbd>
))}
</span>
)}
</div>
);
})}
</div>
))
)}
</div>
<div className="mmg-cmdk__footer">
<span className="mmg-cmdk__footer-key">
<kbd className="mmg-cmdk__kbd"></kbd>
<kbd className="mmg-cmdk__kbd"></kbd> naviguer
</span>
<span className="mmg-cmdk__footer-key">
<kbd className="mmg-cmdk__kbd"></kbd> sélectionner
</span>
<span className="mmg-cmdk__footer-key">
<kbd className="mmg-cmdk__kbd">esc</kbd> fermer
</span>
</div>
</div>
</div>
);
}
/** Hook pour ouvrir la palette via Cmd/Ctrl+K. */
export function useCommandMenu() {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((v) => !v);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
return { open, setOpen, close: () => setOpen(false), toggle: () => setOpen((v) => !v) };
}
+65
View File
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { Dialog } from "./Dialog";
import { Button } from "./Button";
export type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
confirmLabel?: ReactNode;
cancelLabel?: ReactNode;
/** Variante destructive : bouton rouge. */
destructive?: boolean;
onConfirm: () => void | Promise<void>;
/** Affiche un loader pendant onConfirm. */
loading?: boolean;
};
/**
* Boîte de dialogue de confirmation. Pattern destructive : confirm rouge,
* focus initial sur le bouton d'annulation (sécurité). Cf. heuristique
* Nielsen #5 : prevention of errors.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirmer",
cancelLabel = "Annuler",
destructive,
onConfirm,
loading,
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
size="sm"
hideClose
footer={
<div style={{ display: "flex", gap: "var(--mmg-space-2)", justifyContent: "flex-end" }}>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={async () => {
await onConfirm();
onOpenChange(false);
}}
loading={loading}
// Initial focus reste sur Cancel via Radix par défaut.
>
{confirmLabel}
</Button>
</div>
}
>
{null}
</Dialog>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { type ReactNode } from "react";
import * as RadixContextMenu from "@radix-ui/react-context-menu";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
export type ContextMenuItem =
| {
type?: "item";
label: ReactNode;
icon?: IconName;
onSelect?: () => void;
danger?: boolean;
disabled?: boolean;
shortcut?: string;
}
| { type: "divider" }
| { type: "label"; label: ReactNode };
export type ContextMenuProps = {
/** Zone qui déclenche le menu au clic-droit / long-press. */
children: ReactNode;
items: ContextMenuItem[];
};
/**
* ContextMenu — menu contextuel au clic-droit (ou long-press tactile).
*
* Wrapper Radix : ouvre sur clic-droit, navigation flèches, type-ahead,
* Escape, focus management. Aucun appel a11y custom.
*/
export function ContextMenu({ children, items }: ContextMenuProps) {
return (
<RadixContextMenu.Root>
<RadixContextMenu.Trigger asChild>{children}</RadixContextMenu.Trigger>
<RadixContextMenu.Portal>
<RadixContextMenu.Content className="mmg-menu" collisionPadding={8}>
{items.map((item, i) => {
if (item.type === "divider") {
return <RadixContextMenu.Separator key={i} className="mmg-menu__divider" />;
}
if (item.type === "label") {
return (
<RadixContextMenu.Label key={i} className="mmg-menu__label">
{item.label}
</RadixContextMenu.Label>
);
}
const { label, icon, onSelect, danger, disabled, shortcut } = item;
return (
<RadixContextMenu.Item
key={i}
className={cx("mmg-menu__item", danger && "mmg-menu__item--danger")}
disabled={disabled}
onSelect={onSelect}
>
{icon && <Icon name={icon} />}
<span style={{ flex: 1 }}>{label}</span>
{shortcut && <span className="mmg-menu__shortcut">{shortcut}</span>}
</RadixContextMenu.Item>
);
})}
</RadixContextMenu.Content>
</RadixContextMenu.Portal>
</RadixContextMenu.Root>
);
}
export { RadixContextMenu as ContextMenuPrimitive };
+315
View File
@@ -0,0 +1,315 @@
import { useMemo, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon, type IconName } from "./Icon";
import { Pictogram, type PictogramName } from "./Pictogram";
import { Button } from "./Button";
export type DataTableColumn<T> = {
key: string;
header: ReactNode;
/** Accesseur ; par défaut row[key] */
accessor?: (row: T) => unknown;
/** Render personnalisé ; par défaut texte */
cell?: (row: T) => ReactNode;
/** Trie cliquable sur la colonne */
sortable?: boolean;
/** Largeur CSS (ex. "120px", "1fr") */
width?: string | number;
/** Alignement du contenu */
align?: "left" | "right" | "center";
};
type SortState = { key: string; dir: "asc" | "desc" } | null;
export type DataTableProps<T> = {
data: T[];
columns: DataTableColumn<T>[];
/** Colonne identifiante (par défaut "id") */
rowKey?: keyof T | ((row: T) => string | number);
/** Active la sélection multiple */
selectable?: boolean;
selectedKeys?: (string | number)[];
onSelectionChange?: (keys: (string | number)[]) => void;
/** Recherche dans toutes les colonnes */
searchable?: boolean;
searchPlaceholder?: string;
/** Pagination cliente */
pageSize?: number;
/** Densité des lignes */
dense?: boolean;
/** État vide personnalisable */
emptyTitle?: ReactNode;
emptyDescription?: ReactNode;
emptyPictogram?: PictogramName;
emptyAction?: ReactNode;
/** Loading */
loading?: boolean;
/** Actions de la barre d'outils */
toolbarActions?: ReactNode;
className?: string;
onRowClick?: (row: T) => void;
};
export function DataTable<T extends Record<string, any>>({
data,
columns,
rowKey = "id",
selectable,
selectedKeys,
onSelectionChange,
searchable,
searchPlaceholder = "Rechercher…",
pageSize,
dense,
emptyTitle = "Aucun résultat",
emptyDescription = "Aucun élément ne correspond à votre recherche.",
emptyPictogram = "search",
emptyAction,
loading,
toolbarActions,
className,
onRowClick,
}: DataTableProps<T>) {
const [sort, setSort] = useState<SortState>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const getKey = (row: T): string | number =>
typeof rowKey === "function" ? rowKey(row) : (row[rowKey as string] ?? "");
const accessor = (col: DataTableColumn<T>, row: T) =>
col.accessor ? col.accessor(row) : row[col.key];
const filtered = useMemo(() => {
if (!searchable || !search.trim()) return data;
const q = search.toLowerCase();
return data.filter((row) =>
columns.some((c) => {
const v = accessor(c, row);
return v != null && String(v).toLowerCase().includes(q);
}),
);
}, [data, search, searchable, columns]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const col = columns.find((c) => c.key === sort.key);
if (!col) return filtered;
const sign = sort.dir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
const va = accessor(col, a);
const vb = accessor(col, b);
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === "number" && typeof vb === "number") return sign * (va - vb);
return sign * String(va).localeCompare(String(vb));
});
}, [filtered, sort, columns]);
const pageCount = pageSize ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
const paged = pageSize
? sorted.slice((page - 1) * pageSize, page * pageSize)
: sorted;
const allKeys = paged.map(getKey);
const allSelected = allKeys.length > 0 && allKeys.every((k) => selectedKeys?.includes(k));
const someSelected = !allSelected && allKeys.some((k) => selectedKeys?.includes(k));
const toggleAll = () => {
if (!onSelectionChange) return;
if (allSelected) {
onSelectionChange(selectedKeys?.filter((k) => !allKeys.includes(k)) ?? []);
} else {
onSelectionChange([...new Set([...(selectedKeys ?? []), ...allKeys])]);
}
};
const toggleOne = (k: string | number) => {
if (!onSelectionChange) return;
const set = new Set(selectedKeys ?? []);
set.has(k) ? set.delete(k) : set.add(k);
onSelectionChange([...set]);
};
const onSort = (col: DataTableColumn<T>) => {
if (!col.sortable) return;
setSort((cur) =>
cur?.key === col.key
? cur.dir === "asc"
? { key: col.key, dir: "desc" }
: null
: { key: col.key, dir: "asc" },
);
};
const showToolbar = searchable || toolbarActions || (selectable && (selectedKeys?.length ?? 0) > 0);
return (
<div className={cx("mmg-datatable", dense && "mmg-datatable--dense", className)}>
{showToolbar && (
<div className="mmg-datatable__toolbar">
{selectable && (selectedKeys?.length ?? 0) > 0 ? (
<strong style={{ color: "var(--mmg-color-accent)" }}>
{selectedKeys?.length} sélectionné{(selectedKeys?.length ?? 0) > 1 ? "s" : ""}
</strong>
) : searchable ? (
<div className="mmg-input-wrap mmg-datatable__toolbar-search">
<span className="mmg-input-wrap__icon"><Icon name="search-2-line" /></span>
<input
className="mmg-input"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
) : (
<span />
)}
{toolbarActions && <div className="mmg-datatable__toolbar-actions">{toolbarActions}</div>}
</div>
)}
<div className="mmg-datatable__scroll">
<table>
<thead>
<tr>
{selectable && (
<th className="mmg-datatable__cell--checkbox">
<input
type="checkbox"
className="mmg-check__input"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
aria-label="Tout sélectionner"
/>
</th>
)}
{columns.map((col) => {
const isSorted = sort?.key === col.key;
return (
<th
key={col.key}
data-sortable={col.sortable || undefined}
aria-sort={
isSorted
? sort.dir === "asc"
? "ascending"
: "descending"
: undefined
}
onClick={() => onSort(col)}
style={{
width: col.width,
textAlign: col.align,
}}
>
{col.header}
{col.sortable && (
<span className="mmg-datatable__sort-indicator">
<Icon
name={
isSorted
? sort.dir === "asc"
? "arrow-up-line"
: "arrow-down-line"
: "expand-up-down-line" as IconName
}
size="sm"
/>
</span>
)}
</th>
);
})}
</tr>
</thead>
{!loading && paged.length > 0 && (
<tbody>
{paged.map((row) => {
const k = getKey(row);
const selected = selectedKeys?.includes(k);
return (
<tr
key={String(k)}
data-selected={selected || undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={onRowClick ? { cursor: "pointer" } : undefined}
>
{selectable && (
<td className="mmg-datatable__cell--checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className="mmg-check__input"
checked={selected || false}
onChange={() => toggleOne(k)}
aria-label={`Sélectionner ${k}`}
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} style={{ textAlign: col.align }}>
{col.cell ? col.cell(row) : String(accessor(col, row) ?? "—")}
</td>
))}
</tr>
);
})}
</tbody>
)}
</table>
{loading && (
<div className="mmg-datatable__loading">
<span className="mmg-spinner mmg-spinner--lg" />
<span>Chargement</span>
</div>
)}
{!loading && paged.length === 0 && (
<div className="mmg-datatable__empty">
<Pictogram name={emptyPictogram} size={64} />
<h4>{emptyTitle}</h4>
<p>{emptyDescription}</p>
{emptyAction}
</div>
)}
</div>
{pageSize && pageCount > 1 && (
<div className="mmg-datatable__footer">
<span>
Page {page} / {pageCount} {sorted.length} résultat
{sorted.length > 1 ? "s" : ""}
</span>
<div style={{ display: "flex", gap: 4 }}>
<Button
size="sm"
variant="ghost"
icon="arrow-left-line"
iconOnly
aria-label="Page précédente"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
/>
<Button
size="sm"
variant="ghost"
icon="arrow-right-line"
iconOnly
aria-label="Page suivante"
disabled={page === pageCount}
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
/>
</div>
</div>
)}
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
import { Button } from "./Button";
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
const WEEKDAYS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"];
const fmt = (d: Date | null) =>
d
? d.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: "";
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
function buildMonthGrid(year: number, month: number) {
const first = new Date(year, month, 1);
// En France lundi = jour 0
const startDay = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrev = new Date(year, month, 0).getDate();
const cells: { date: Date; outside: boolean }[] = [];
// Préfixe (mois précédent)
for (let i = startDay - 1; i >= 0; i--) {
cells.push({ date: new Date(year, month - 1, daysInPrev - i), outside: true });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(year, month, d), outside: false });
}
// Suffixe (mois suivant)
while (cells.length < 42) {
const idx = cells.length - (startDay + daysInMonth);
cells.push({ date: new Date(year, month + 1, idx + 1), outside: true });
}
return cells;
}
export type DatePickerProps = {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
value?: Date | null;
onChange?: (date: Date | null) => void;
required?: boolean;
disabled?: boolean;
/** Désactive certaines dates */
isDisabled?: (date: Date) => boolean;
placeholder?: string;
className?: string;
};
export function DatePicker({
label,
hint,
error,
value,
onChange,
required,
disabled,
isDisabled,
placeholder = "JJ/MM/AAAA",
className,
}: DatePickerProps) {
const id = useId();
const [open, setOpen] = useState(false);
const [view, setView] = useState(() => value ?? new Date());
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (value) setView(value);
}, [value]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const cells = useMemo(
() => buildMonthGrid(view.getFullYear(), view.getMonth()),
[view],
);
const today = new Date();
return (
<div
ref={ref}
className={cx("mmg-field", error && "mmg-field--error", className)}
style={{ position: "relative" }}
>
{label && (
<label
className={cx("mmg-field__label", required && "mmg-field__label--required")}
htmlFor={id}
>
{label}
</label>
)}
<div className="mmg-input-wrap mmg-input-wrap--with-suffix">
<input
id={id}
className="mmg-input"
readOnly
required={required}
disabled={disabled}
value={fmt(value ?? null)}
placeholder={placeholder}
onClick={() => !disabled && setOpen((v) => !v)}
onFocus={() => !disabled && setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
/>
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
<Icon name="calendar-line" />
</span>
</div>
{error ? (
<span className="mmg-field__error">{error}</span>
) : hint ? (
<span className="mmg-field__hint">{hint}</span>
) : null}
{open && (
<div
className="mmg-datepicker"
style={{ top: "calc(100% + 4px)", left: 0 }}
role="dialog"
aria-label="Sélectionner une date"
>
<div className="mmg-datepicker__header">
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois précédent"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1))}
>
<Icon name="arrow-left-line" />
</button>
<span className="mmg-datepicker__title">
{MONTHS[view.getMonth()]} {view.getFullYear()}
</span>
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois suivant"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1))}
>
<Icon name="arrow-right-line" />
</button>
</div>
<div className="mmg-datepicker__weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="mmg-datepicker__weekday">{w}</div>
))}
</div>
<div className="mmg-datepicker__grid">
{cells.map(({ date, outside }, i) => {
const selected = value && sameDay(date, value);
const todayMatch = sameDay(date, today);
const dis = isDisabled?.(date) ?? false;
return (
<button
key={i}
type="button"
className={cx(
"mmg-datepicker__day",
outside && "mmg-datepicker__day--outside",
todayMatch && "mmg-datepicker__day--today",
)}
aria-selected={selected || undefined}
aria-current={todayMatch ? "date" : undefined}
disabled={dis}
onClick={() => {
onChange?.(date);
setOpen(false);
}}
>
{date.getDate()}
</button>
);
})}
</div>
<div className="mmg-datepicker__footer">
<Button
size="xs"
variant="ghost"
onClick={() => {
onChange?.(null);
setOpen(false);
}}
>
Effacer
</Button>
<Button
size="xs"
variant="tonal"
onClick={() => {
onChange?.(today);
setView(today);
setOpen(false);
}}
>
Aujourd'hui
</Button>
</div>
</div>
)}
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { cx } from "./utils";
import { Icon } from "./Icon";
import { Button } from "./Button";
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
const WEEKDAYS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"];
const fmt = (d: Date | null) =>
d ? d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }) : "";
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const isBefore = (a: Date, b: Date) => a.getTime() < b.getTime();
const isBetween = (d: Date, start: Date, end: Date) =>
d.getTime() > start.getTime() && d.getTime() < end.getTime();
function buildMonthGrid(year: number, month: number) {
const first = new Date(year, month, 1);
const startDay = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrev = new Date(year, month, 0).getDate();
const cells: { date: Date; outside: boolean }[] = [];
for (let i = startDay - 1; i >= 0; i--) {
cells.push({ date: new Date(year, month - 1, daysInPrev - i), outside: true });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(year, month, d), outside: false });
}
while (cells.length < 42) {
const idx = cells.length - (startDay + daysInMonth);
cells.push({ date: new Date(year, month + 1, idx + 1), outside: true });
}
return cells;
}
export type DateRange = { start: Date | null; end: Date | null };
export type DateRangePickerProps = {
label?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
value?: DateRange;
onChange?: (range: DateRange) => void;
required?: boolean;
disabled?: boolean;
/** Désactive certaines dates (ex. dates passées). */
isDisabled?: (date: Date) => boolean;
className?: string;
};
export function DateRangePicker({
label,
hint,
error,
value = { start: null, end: null },
onChange,
required,
disabled,
isDisabled,
className,
}: DateRangePickerProps) {
const id = useId();
const [open, setOpen] = useState(false);
const [view, setView] = useState(() => value.start ?? new Date());
const [hover, setHover] = useState<Date | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const cells = useMemo(
() => buildMonthGrid(view.getFullYear(), view.getMonth()),
[view],
);
const today = new Date();
const handleClick = (date: Date) => {
if (!value.start || (value.start && value.end)) {
onChange?.({ start: date, end: null });
} else if (isBefore(date, value.start)) {
onChange?.({ start: date, end: value.start });
setOpen(false);
} else {
onChange?.({ start: value.start, end: date });
setOpen(false);
}
};
const inRange = (d: Date) => {
if (!value.start) return false;
const end = value.end ?? hover;
if (!end) return false;
if (isBefore(end, value.start)) return isBetween(d, end, value.start);
return isBetween(d, value.start, end);
};
const display = value.start
? value.end
? `${fmt(value.start)}${fmt(value.end)}`
: `${fmt(value.start)} → …`
: "";
return (
<div
ref={ref}
className={cx("mmg-field", error && "mmg-field--error", className)}
style={{ position: "relative" }}
>
{label && (
<label
className={cx("mmg-field__label", required && "mmg-field__label--required")}
htmlFor={id}
>
{label}
</label>
)}
<div className="mmg-input-wrap mmg-input-wrap--with-suffix">
<input
id={id}
className="mmg-input"
readOnly
required={required}
disabled={disabled}
value={display}
placeholder="Sélectionnez une plage…"
onClick={() => !disabled && setOpen((v) => !v)}
onFocus={() => !disabled && setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
/>
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
<Icon name="calendar-line" />
</span>
</div>
{error ? (
<span className="mmg-field__error">{error}</span>
) : hint ? (
<span className="mmg-field__hint">{hint}</span>
) : null}
{open && (
<div
className="mmg-datepicker mmg-datepicker--range"
style={{ top: "calc(100% + 4px)", left: 0, width: 320 }}
role="dialog"
aria-label="Sélectionner une plage de dates"
>
<div className="mmg-datepicker__header">
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois précédent"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1))}
>
<Icon name="arrow-left-line" />
</button>
<span className="mmg-datepicker__title">
{MONTHS[view.getMonth()]} {view.getFullYear()}
</span>
<button
type="button"
className="mmg-datepicker__nav"
aria-label="Mois suivant"
onClick={() => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1))}
>
<Icon name="arrow-right-line" />
</button>
</div>
<div className="mmg-datepicker__weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="mmg-datepicker__weekday">{w}</div>
))}
</div>
<div className="mmg-datepicker__grid">
{cells.map(({ date, outside }, i) => {
const isStart = value.start && sameDay(date, value.start);
const isEnd = value.end && sameDay(date, value.end);
const between = inRange(date);
const todayMatch = sameDay(date, today);
const dis = isDisabled?.(date) ?? false;
return (
<button
key={i}
type="button"
className={cx(
"mmg-datepicker__day",
outside && "mmg-datepicker__day--outside",
todayMatch && "mmg-datepicker__day--today",
between && "mmg-datepicker__day--between",
(isStart || isEnd) && "mmg-datepicker__day--endpoint",
)}
aria-selected={isStart || isEnd || undefined}
disabled={dis}
onClick={() => handleClick(date)}
onMouseEnter={() => setHover(date)}
onMouseLeave={() => setHover(null)}
>
{date.getDate()}
</button>
);
})}
</div>
<div className="mmg-datepicker__footer">
<Button
size="xs"
variant="ghost"
onClick={() => onChange?.({ start: null, end: null })}
>
Effacer
</Button>
<Button
size="xs"
variant="tonal"
onClick={() => {
const last7 = new Date();
last7.setDate(today.getDate() - 6);
onChange?.({ start: last7, end: today });
setView(today);
setOpen(false);
}}
>
7 derniers jours
</Button>
</div>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More