chore: initial DSMMG v0.2 — refonte architecturale complète
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:
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>,
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
@@ -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/).
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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";
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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é
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Generated
+21
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 :</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 été utile ?
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user