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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { axe } from "vitest-axe";
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
function Wrapped() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Ouvrir</button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Modifier l'utilisateur"
|
||||
description="Met à jour les infos."
|
||||
>
|
||||
<p>Contenu</p>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("est fermé par défaut", () => {
|
||||
render(<Wrapped />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("s'ouvre via setState", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Wrapped />);
|
||||
await user.click(screen.getByText("Ouvrir"));
|
||||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a un titre lié via aria-labelledby (Radix)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Wrapped />);
|
||||
await user.click(screen.getByText("Ouvrir"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveAccessibleName("Modifier l'utilisateur");
|
||||
});
|
||||
|
||||
it("se ferme avec Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Wrapped />);
|
||||
await user.click(screen.getByText("Ouvrir"));
|
||||
await screen.findByRole("dialog");
|
||||
await user.keyboard("{Escape}");
|
||||
// Radix retire le dialog après animation — on attend la disparition
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a11y axe-core (ouvert)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Wrapped />);
|
||||
await user.click(screen.getByText("Ouvrir"));
|
||||
await screen.findByRole("dialog");
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** Largeur max du contenu. */
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
/** Cache le bouton de fermeture en haut à droite. */
|
||||
hideClose?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog (modal) — wrapper Radix Dialog.
|
||||
*
|
||||
* Focus trap, Escape, scroll lock, restitution du focus à la fermeture,
|
||||
* tout est géré par Radix. La sémantique est correcte (role="dialog",
|
||||
* aria-modal, aria-labelledby/-describedby auto via Title/Description).
|
||||
*
|
||||
* Pour confirm/destructive, voir ConfirmDialog.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = "md",
|
||||
hideClose,
|
||||
className,
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay className="mmg-dialog__overlay" />
|
||||
<RadixDialog.Content
|
||||
className={cx("mmg-dialog", `mmg-dialog--${size}`, className)}
|
||||
>
|
||||
{(title || !hideClose) && (
|
||||
<div className="mmg-dialog__header">
|
||||
{title && <RadixDialog.Title className="mmg-dialog__title">{title}</RadixDialog.Title>}
|
||||
{!hideClose && (
|
||||
<RadixDialog.Close asChild>
|
||||
<button type="button" className="mmg-dialog__close" aria-label="Fermer">
|
||||
<Icon name="close-line" size="md" />
|
||||
</button>
|
||||
</RadixDialog.Close>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<RadixDialog.Description className="mmg-dialog__description">
|
||||
{description}
|
||||
</RadixDialog.Description>
|
||||
)}
|
||||
<div className="mmg-dialog__body">{children}</div>
|
||||
{footer && <div className="mmg-dialog__footer">{footer}</div>}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadixDialog as DialogPrimitive };
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type DrawerProps = {
|
||||
open: boolean;
|
||||
/** Callback de fermeture — déclenché par Esc, click backdrop, click close. */
|
||||
onClose: () => void;
|
||||
/** Côté d'apparition. Défaut "right". */
|
||||
side?: "left" | "right";
|
||||
/** Largeur. Défaut "md" (480px). */
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawer — panneau latéral (Radix Dialog).
|
||||
*
|
||||
* API simplifiée par rapport à Sheet : uniquement left/right, callback
|
||||
* `onClose` (au lieu de `onOpenChange`). Pour bottom/top sheet ou plus
|
||||
* de contrôle, utiliser directement Sheet.
|
||||
*
|
||||
* Backing Radix : focus trap, scroll lock, restitution focus, Esc — tout
|
||||
* natif. Anciennement custom (useFocusTrap), maintenant aligné avec Sheet.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
side = "right",
|
||||
size = "md",
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: DrawerProps) {
|
||||
return (
|
||||
<RadixDialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay className="mmg-sheet__overlay" />
|
||||
<RadixDialog.Content
|
||||
className={cx(
|
||||
"mmg-sheet",
|
||||
`mmg-sheet--${side}`,
|
||||
`mmg-sheet--${size}`,
|
||||
"mmg-drawer",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<header className="mmg-sheet__header">
|
||||
<div className="mmg-sheet__header-text">
|
||||
<RadixDialog.Title className="mmg-sheet__title">{title}</RadixDialog.Title>
|
||||
</div>
|
||||
<RadixDialog.Close asChild>
|
||||
<button type="button" className="mmg-sheet__close" aria-label="Fermer">
|
||||
<Icon name="close-line" size="md" />
|
||||
</button>
|
||||
</RadixDialog.Close>
|
||||
</header>
|
||||
)}
|
||||
<div className="mmg-sheet__body">{children}</div>
|
||||
{footer && <footer className="mmg-sheet__footer">{footer}</footer>}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { IconBlock, type IconBlockProps } from "./IconBlock";
|
||||
import type { IconName } from "./Icon";
|
||||
|
||||
export type EmptyStateProps = HTMLAttributes<HTMLDivElement> & {
|
||||
/** Icône Remix à afficher dans un IconBlock. */
|
||||
icon?: IconName;
|
||||
/** Couleur de l'IconBlock. */
|
||||
iconColor?: IconBlockProps["color"];
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Action principale (bouton, lien, etc.) */
|
||||
action?: ReactNode;
|
||||
/** Actions secondaires (lien doc, contact support…) */
|
||||
secondaryAction?: ReactNode;
|
||||
/** Variant compacte pour intégration dans un Card */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* EmptyState — état vide standardisé.
|
||||
*
|
||||
* Cas d'usage : table vide, recherche sans résultat, espace
|
||||
* non encore configuré, fonctionnalité en cours d'arrivée.
|
||||
*
|
||||
* UX : toujours un *next step* clair (action) — jamais juste "rien".
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon = "search-2-line",
|
||||
iconColor = "neutral",
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
compact,
|
||||
className,
|
||||
...rest
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx("mmg-empty", compact && "mmg-empty--compact", className)}
|
||||
role="status"
|
||||
{...rest}
|
||||
>
|
||||
<IconBlock
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
size={compact ? "md" : "xl"}
|
||||
/>
|
||||
<h3 className="mmg-empty__title">{title}</h3>
|
||||
{description && <p className="mmg-empty__desc">{description}</p>}
|
||||
{(action || secondaryAction) && (
|
||||
<div className="mmg-empty__actions">
|
||||
{action}
|
||||
{secondaryAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useId, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
/* ────────────────────────────────────────────────────────
|
||||
SegmentedControl (Apple HIG)
|
||||
- Choix exclusif court (2-5 options)
|
||||
- Mieux qu'un Radio quand l'espace horizontal est dispo
|
||||
- Évite l'overhead cognitif d'un Select pour peu d'options
|
||||
──────────────────────────────────────────────────────── */
|
||||
export type SegmentedItem<V extends string = string> = {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
icon?: IconName;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function SegmentedControl<V extends string = string>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
size = "md",
|
||||
fullWidth,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
items: SegmentedItem<V>[];
|
||||
value: V;
|
||||
onChange: (v: V) => void;
|
||||
size?: "sm" | "md" | "lg";
|
||||
fullWidth?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cx(
|
||||
"mmg-segmented",
|
||||
size !== "md" && `mmg-segmented--${size}`,
|
||||
fullWidth && "mmg-segmented--full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const checked = item.value === value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
disabled={item.disabled}
|
||||
className={cx(
|
||||
"mmg-segmented__item",
|
||||
checked && "mmg-segmented__item--active",
|
||||
)}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
{item.icon && <Icon name={item.icon} size="sm" />}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────
|
||||
DescriptionList — pattern clé/valeur structuré (DSFR-like)
|
||||
Sémantique HTML correcte (<dl><dt><dd>) — meilleur que table
|
||||
pour des paires non-tabulaires. Lecteurs d'écran nativement
|
||||
supportés.
|
||||
──────────────────────────────────────────────────────── */
|
||||
export type DListItem = { label: ReactNode; value: ReactNode };
|
||||
|
||||
export function DescriptionList({
|
||||
items,
|
||||
layout = "horizontal",
|
||||
className,
|
||||
}: {
|
||||
items: DListItem[];
|
||||
/** "horizontal" : label à gauche · "vertical" : label au-dessus */
|
||||
layout?: "horizontal" | "vertical";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<dl className={cx("mmg-dlist", `mmg-dlist--${layout}`, className)}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="mmg-dlist__row">
|
||||
<dt className="mmg-dlist__label">{item.label}</dt>
|
||||
<dd className="mmg-dlist__value">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────
|
||||
Sparkline — micro graphe pour stat cards / dashboards
|
||||
Inline SVG, sans dépendance, accessible (table de données
|
||||
en <title> + role img).
|
||||
──────────────────────────────────────────────────────── */
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 100,
|
||||
height = 32,
|
||||
color = "var(--mmg-color-accent)",
|
||||
fill = "var(--mmg-color-accent-soft)",
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
fill?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (data.length < 2) return null;
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const stepX = width / (data.length - 1);
|
||||
|
||||
const points = data.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - ((v - min) / range) * height;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const linePath = `M ${points.join(" L ")}`;
|
||||
const areaPath = `${linePath} L ${width},${height} L 0,${height} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={ariaLabel ?? `Évolution sur ${data.length} points : min ${min}, max ${max}`}
|
||||
className={cx("mmg-sparkline", className)}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width={width}
|
||||
height={height}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={areaPath} fill={fill} />
|
||||
<path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────
|
||||
Kbd — wrapping <kbd> stylisé
|
||||
Pour shortcuts inline ("Appuyez sur ⌘K").
|
||||
──────────────────────────────────────────────────────── */
|
||||
export function Kbd({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<kbd className={cx("mmg-kbd", className)} {...rest}>
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type FabProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function Fab({ icon, label, className, ...rest }: FabProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cx("mmg-fab", className)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...rest}
|
||||
>
|
||||
<Icon name={icon} size="lg" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type FeatureCardProps = {
|
||||
/** Icône principale (Remix). */
|
||||
icon?: IconName;
|
||||
/** Couleur de l'icône (et du glow associé en hover). */
|
||||
iconColor?: "brand" | "blue" | "green" | "amber" | "violet" | "neutral";
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Slot pour un lien "En savoir plus". Rendu en bas de la carte. */
|
||||
link?: { label: ReactNode; href: string };
|
||||
/** Si true, ajoute un effet gradient borde au hover (Vercel-style). */
|
||||
glowOnHover?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* FeatureCard — carte de mise en avant (icône + titre + description + lien).
|
||||
*
|
||||
* Pattern landing pages Vercel / Linear / Stripe : grille 3 colonnes avec
|
||||
* un IconBlock coloré, un titre fort, une description et un lien optionnel.
|
||||
*
|
||||
* Variante `glowOnHover` : ajoute un effet "border qui s'illumine" au
|
||||
* hover via un dégradé conique (Vercel signature). Performant (un seul
|
||||
* background-image animé via mask).
|
||||
*/
|
||||
export function FeatureCard({
|
||||
icon,
|
||||
iconColor = "brand",
|
||||
title,
|
||||
description,
|
||||
link,
|
||||
glowOnHover,
|
||||
className,
|
||||
}: FeatureCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"mmg-feature-card",
|
||||
glowOnHover && "mmg-feature-card--glow",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span className={cx("mmg-feature-card__icon", `mmg-feature-card__icon--${iconColor}`)} aria-hidden>
|
||||
<Icon name={icon} size="lg" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="mmg-feature-card__title">{title}</h3>
|
||||
{description && <p className="mmg-feature-card__desc">{description}</p>}
|
||||
{link && (
|
||||
<a className="mmg-feature-card__link" href={link.href}>
|
||||
{link.label}
|
||||
<Icon name="arrow-right-line" size="sm" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { useFocusTrap } from "./useFocusTrap";
|
||||
|
||||
type Severity = "info" | "success" | "warning" | "danger";
|
||||
|
||||
const SEV_ICON: Record<Severity, IconName> = {
|
||||
info: "information-fill",
|
||||
success: "checkbox-circle-fill",
|
||||
warning: "alert-fill",
|
||||
danger: "error-warning-fill",
|
||||
};
|
||||
|
||||
/* — Alert ————————————————————————————— */
|
||||
export function Alert({
|
||||
severity = "info",
|
||||
title,
|
||||
children,
|
||||
closable,
|
||||
onClose,
|
||||
className,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
severity?: Severity;
|
||||
title?: ReactNode;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role={severity === "danger" ? "alert" : "status"}
|
||||
className={cx(
|
||||
"mmg-alert",
|
||||
severity !== "info" && `mmg-alert--${severity}`,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Icon name={SEV_ICON[severity]} className="mmg-alert__icon" size="md" />
|
||||
<div className="mmg-alert__body">
|
||||
{title && <div className="mmg-alert__title">{title}</div>}
|
||||
{children && <div className="mmg-alert__desc">{children}</div>}
|
||||
</div>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className="mmg-alert__close"
|
||||
aria-label="Fermer"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close-line" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Notice ————————————————————————————— */
|
||||
export function Notice({
|
||||
variant = "brand",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: "brand" | "info" | "warning" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cx(
|
||||
"mmg-notice",
|
||||
variant !== "brand" && `mmg-notice--${variant}`,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Badge ————————————————————————————— */
|
||||
export function Badge({
|
||||
variant,
|
||||
solid,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: "brand" | "success" | "warning" | "danger" | "info";
|
||||
solid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"mmg-badge",
|
||||
variant && `mmg-badge--${variant}`,
|
||||
solid && "mmg-badge--solid",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Modal ————————————————————————————— */
|
||||
let modalIdCounter = 0;
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
size,
|
||||
children,
|
||||
footer,
|
||||
closable = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
/** Optionnel — relié via aria-describedby aux lecteurs d'écran. */
|
||||
description?: ReactNode;
|
||||
size?: "sm" | "lg";
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
closable?: boolean;
|
||||
}) {
|
||||
const ref = useFocusTrap<HTMLDivElement>(open);
|
||||
const titleIdRef = useRef<string>("mmg-modal-title-" + ++modalIdCounter);
|
||||
const descIdRef = useRef<string>("mmg-modal-desc-" + modalIdCounter);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && closable) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, closable, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="mmg-modal-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && closable) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleIdRef.current : undefined}
|
||||
aria-describedby={description ? descIdRef.current : undefined}
|
||||
className={cx("mmg-modal", size && `mmg-modal--${size}`)}
|
||||
>
|
||||
{title && (
|
||||
<div className="mmg-modal__header">
|
||||
<h3 id={titleIdRef.current} className="mmg-modal__title">
|
||||
{title}
|
||||
</h3>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className="mmg-modal__close"
|
||||
aria-label="Fermer"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close-line" size="md" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mmg-modal__body">
|
||||
{description && (
|
||||
<p
|
||||
id={descIdRef.current}
|
||||
style={{ marginBottom: "var(--mmg-space-3)", color: "var(--mmg-color-text-tertiary)", fontSize: "var(--mmg-font-size-sm)" }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{footer && <div className="mmg-modal__footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Toast (basique, contrôlé par parent) ————————————————————————————— */
|
||||
export type ToastItem = {
|
||||
id: string;
|
||||
severity?: Severity;
|
||||
title: ReactNode;
|
||||
desc?: ReactNode;
|
||||
};
|
||||
export function ToastRegion({
|
||||
toasts,
|
||||
onDismiss,
|
||||
}: {
|
||||
toasts: ToastItem[];
|
||||
onDismiss?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mmg-toast-region" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cx(
|
||||
"mmg-toast",
|
||||
t.severity && t.severity !== "info" && `mmg-toast--${t.severity}`,
|
||||
)}
|
||||
onClick={() => onDismiss?.(t.id)}
|
||||
>
|
||||
<div>
|
||||
<div className="mmg-toast__title">{t.title}</div>
|
||||
{t.desc && <div className="mmg-toast__desc">{t.desc}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Spinner ————————————————————————————— */
|
||||
export function Spinner({
|
||||
size = "md",
|
||||
className,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLSpanElement> & { size?: "sm" | "md" | "lg" }) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="Chargement"
|
||||
className={cx("mmg-spinner", size !== "md" && `mmg-spinner--${size}`, className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type FileUploadProps = {
|
||||
label?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
onFiles?: (files: FileList) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FileUpload({
|
||||
label = "Glissez-déposez vos fichiers ici",
|
||||
hint = "ou cliquez pour parcourir",
|
||||
multiple,
|
||||
accept,
|
||||
onFiles,
|
||||
className,
|
||||
}: FileUploadProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
return (
|
||||
<label
|
||||
className={cx("mmg-upload", className)}
|
||||
data-dragging={dragging || undefined}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files.length > 0) onFiles?.(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<Icon name="upload-line" className="mmg-upload__icon" size="xl" />
|
||||
<div className="mmg-upload__text">{label}</div>
|
||||
<div className="mmg-upload__hint">{hint}</div>
|
||||
<input
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
onChange={(e) => e.target.files && onFiles?.(e.target.files)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type FooterColumn = {
|
||||
title: ReactNode;
|
||||
links: { label: ReactNode; href: string }[];
|
||||
};
|
||||
|
||||
export type FooterProps = {
|
||||
brand?: ReactNode;
|
||||
description?: ReactNode;
|
||||
columns?: FooterColumn[];
|
||||
bottomLinks?: { label: ReactNode; href: string }[];
|
||||
copyright?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Footer({
|
||||
brand,
|
||||
description,
|
||||
columns,
|
||||
bottomLinks,
|
||||
copyright,
|
||||
className,
|
||||
}: FooterProps) {
|
||||
return (
|
||||
<footer className={cx("mmg-footer", className)}>
|
||||
<div className="mmg-container">
|
||||
<div className="mmg-footer__top">
|
||||
<div>
|
||||
{brand && <div className="mmg-footer__col-title">{brand}</div>}
|
||||
{description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "var(--mmg-font-size-sm)",
|
||||
color: "var(--mmg-color-text-tertiary)",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{columns?.map((col, i) => (
|
||||
<div key={i}>
|
||||
<div className="mmg-footer__col-title">{col.title}</div>
|
||||
<ul className="mmg-footer__col-list">
|
||||
{col.links.map((l, j) => (
|
||||
<li key={j}>
|
||||
<a href={l.href}>{l.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mmg-footer__bottom">
|
||||
<div>{copyright ?? `© ${new Date().getFullYear()} ManageMate Group`}</div>
|
||||
{bottomLinks && (
|
||||
<div className="mmg-inline">
|
||||
{bottomLinks.map((l, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={l.href}
|
||||
style={{ fontSize: "var(--mmg-font-size-xs)", color: "var(--mmg-color-text-quaternary)" }}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
type InputHTMLAttributes,
|
||||
type SelectHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
type FieldRenderArg = {
|
||||
/** id du contrôle (à passer à <input>/<textarea>/<select>) */
|
||||
id: string;
|
||||
/** id du message à passer en aria-describedby (peut être undefined) */
|
||||
describedBy: string | undefined;
|
||||
/** L'état d'invalidité, à passer en aria-invalid */
|
||||
invalid: boolean | undefined;
|
||||
};
|
||||
|
||||
type FieldProps = {
|
||||
label?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
success?: ReactNode;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
children: (arg: FieldRenderArg) => ReactNode;
|
||||
};
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
success,
|
||||
required,
|
||||
className,
|
||||
id: idProp,
|
||||
children,
|
||||
}: FieldProps) {
|
||||
const auto = useId();
|
||||
const id = idProp ?? auto;
|
||||
const messageId = `${id}-message`;
|
||||
const hasMessage = Boolean(error || success || hint);
|
||||
const describedBy = hasMessage ? messageId : undefined;
|
||||
const state = error ? "error" : success ? "success" : undefined;
|
||||
return (
|
||||
<div className={cx("mmg-field", state && `mmg-field--${state}`, className)}>
|
||||
{label && (
|
||||
<label
|
||||
className={cx(
|
||||
"mmg-field__label",
|
||||
required && "mmg-field__label--required",
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{children({ id, describedBy, invalid: error ? true : undefined })}
|
||||
{error ? (
|
||||
<span id={messageId} className="mmg-field__error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : success ? (
|
||||
<span id={messageId} className="mmg-field__success">
|
||||
{success}
|
||||
</span>
|
||||
) : hint ? (
|
||||
<span id={messageId} className="mmg-field__hint">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Input ————————————————————————————— */
|
||||
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
|
||||
label?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
success?: ReactNode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
prefixIcon?: IconName;
|
||||
suffixIcon?: IconName;
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, hint, error, success, size = "md", prefixIcon, suffixIcon, required, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = cx(
|
||||
"mmg-input",
|
||||
size !== "md" && `mmg-input--${size}`,
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<Field label={label} hint={hint} error={error} success={success} required={required}>
|
||||
{({ id, describedBy, invalid }) =>
|
||||
prefixIcon || suffixIcon ? (
|
||||
<span className={cx("mmg-input-wrap", suffixIcon && "mmg-input-wrap--with-suffix")}>
|
||||
{prefixIcon && (
|
||||
<span className="mmg-input-wrap__icon"><Icon name={prefixIcon} /></span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cls}
|
||||
required={required}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
{...rest}
|
||||
/>
|
||||
{suffixIcon && (
|
||||
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
|
||||
<Icon name={suffixIcon} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cls}
|
||||
required={required}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
|
||||
/* — Textarea ————————————————————————————— */
|
||||
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
label?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
success?: ReactNode;
|
||||
fieldSize?: "sm" | "md" | "lg";
|
||||
};
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
function Textarea(
|
||||
{ label, hint, error, success, fieldSize = "md", required, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = cx(
|
||||
"mmg-textarea",
|
||||
fieldSize !== "md" && `mmg-textarea--${fieldSize}`,
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<Field label={label} hint={hint} error={error} success={success} required={required}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cls}
|
||||
required={required}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* — Select ————————————————————————————— */
|
||||
export type SelectOption = { value: string; label: string; disabled?: boolean };
|
||||
export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> & {
|
||||
label?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
success?: ReactNode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
};
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ label, hint, error, success, size = "md", options, placeholder, required, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = cx("mmg-select", size !== "md" && `mmg-select--${size}`, className);
|
||||
return (
|
||||
<Field label={label} hint={hint} error={error} success={success} required={required}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<select
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cls}
|
||||
required={required}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
{...rest}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value} disabled={o.disabled}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
|
||||
/* — Checkbox / Radio ————————————————————————————— */
|
||||
type CheckProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: ReactNode;
|
||||
};
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckProps>(function Checkbox(
|
||||
{ label, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<label className={cx("mmg-check", className)}>
|
||||
<input ref={ref} type="checkbox" className="mmg-check__input" {...rest} />
|
||||
<span className="mmg-check__label">{label}</span>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
export const Radio = forwardRef<HTMLInputElement, CheckProps>(function Radio(
|
||||
{ label, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<label className={cx("mmg-check", className)}>
|
||||
<input ref={ref} type="radio" className="mmg-check__input" {...rest} />
|
||||
<span className="mmg-check__label">{label}</span>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
/* — Switch ————————————————————————————— */
|
||||
export const Switch = forwardRef<HTMLInputElement, CheckProps>(function Switch(
|
||||
{ label, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<label className={cx("mmg-switch", className)}>
|
||||
<input ref={ref} type="checkbox" role="switch" className="mmg-switch__input" {...rest} />
|
||||
<span className="mmg-check__label">{label}</span>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type NavItemSimple = {
|
||||
label: ReactNode;
|
||||
href: string;
|
||||
current?: boolean;
|
||||
};
|
||||
|
||||
export type MegaMenuLink = {
|
||||
label: ReactNode;
|
||||
href: string;
|
||||
description?: ReactNode;
|
||||
icon?: IconName;
|
||||
};
|
||||
|
||||
export type MegaMenuColumn = {
|
||||
title?: ReactNode;
|
||||
links: MegaMenuLink[];
|
||||
};
|
||||
|
||||
export type MegaMenuFeatured = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
href: string;
|
||||
cta?: ReactNode;
|
||||
};
|
||||
|
||||
export type NavItemMega = {
|
||||
label: ReactNode;
|
||||
current?: boolean;
|
||||
columns: MegaMenuColumn[];
|
||||
featured?: MegaMenuFeatured;
|
||||
};
|
||||
|
||||
export type HeaderNavItem = NavItemSimple | NavItemMega;
|
||||
|
||||
const isMega = (item: HeaderNavItem): item is NavItemMega => "columns" in item;
|
||||
|
||||
function MegaMenuTrigger({ item, index }: { item: NavItemMega; index: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 180);
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className="mmg-header__nav-item"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="mmg-header__nav-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
aria-controls={`mmg-megamenu-${index}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{item.label}
|
||||
<Icon name="arrow-down-line" className="mmg-header__nav-chevron" size="sm" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={`mmg-megamenu-${index}`}
|
||||
className="mmg-megamenu"
|
||||
role="region"
|
||||
aria-label={typeof item.label === "string" ? item.label : undefined}
|
||||
>
|
||||
<div
|
||||
className="mmg-megamenu__grid"
|
||||
style={{
|
||||
gridTemplateColumns: item.featured
|
||||
? `repeat(${item.columns.length}, minmax(180px, 1fr)) 240px`
|
||||
: `repeat(${item.columns.length}, minmax(180px, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{item.columns.map((col, ci) => (
|
||||
<div key={ci}>
|
||||
{col.title && <div className="mmg-megamenu__col-title">{col.title}</div>}
|
||||
<ul className="mmg-megamenu__list">
|
||||
{col.links.map((l, li) => (
|
||||
<li key={li}>
|
||||
<a href={l.href} className="mmg-megamenu__link">
|
||||
{l.icon && (
|
||||
<span className="mmg-megamenu__link-icon">
|
||||
<Icon name={l.icon} size="md" />
|
||||
</span>
|
||||
)}
|
||||
<span className="mmg-megamenu__link-content">
|
||||
<span className="mmg-megamenu__link-label">{l.label}</span>
|
||||
{l.description && (
|
||||
<span className="mmg-megamenu__link-desc">{l.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{item.featured && (
|
||||
<a
|
||||
href={item.featured.href}
|
||||
className="mmg-megamenu__featured"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
<div className="mmg-megamenu__featured-title">{item.featured.title}</div>
|
||||
<div className="mmg-megamenu__featured-desc">{item.featured.description}</div>
|
||||
{item.featured.cta && (
|
||||
<span style={{ color: "var(--mmg-color-accent)", fontWeight: 600, fontSize: "var(--mmg-font-size-sm)" }}>
|
||||
{item.featured.cta} →
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export type HeaderProps = {
|
||||
brand: ReactNode;
|
||||
brandHref?: string;
|
||||
/** Si fourni avec serviceTitle, active le mode "institutional". */
|
||||
brandTop?: ReactNode;
|
||||
serviceTitle?: ReactNode;
|
||||
serviceTagline?: ReactNode;
|
||||
logoMark?: ReactNode;
|
||||
nav?: HeaderNavItem[];
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Header({
|
||||
brand,
|
||||
brandHref = "/",
|
||||
brandTop,
|
||||
serviceTitle,
|
||||
serviceTagline,
|
||||
logoMark,
|
||||
nav,
|
||||
actions,
|
||||
className,
|
||||
}: HeaderProps) {
|
||||
const initials = typeof brand === "string" ? brand.slice(0, 2).toUpperCase() : null;
|
||||
const isInstitutional = Boolean(brandTop || serviceTitle);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cx(
|
||||
"mmg-header",
|
||||
isInstitutional && "mmg-header--institutional",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mmg-container">
|
||||
<div className="mmg-header__inner">
|
||||
<a href={brandHref} className="mmg-header__brand">
|
||||
<span className="mmg-header__logo">{logoMark ?? initials}</span>
|
||||
{isInstitutional ? (
|
||||
<span className="mmg-header__brand-text">
|
||||
{brandTop && <span className="mmg-header__brand-top">{brandTop}</span>}
|
||||
{serviceTitle && <span className="mmg-header__service">{serviceTitle}</span>}
|
||||
{serviceTagline && (
|
||||
<span className="mmg-header__service-tagline">{serviceTagline}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mmg-header__name">{brand}</span>
|
||||
)}
|
||||
</a>
|
||||
{nav && (
|
||||
<nav className="mmg-header__nav" aria-label="Navigation principale">
|
||||
<ul
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
gap: "var(--mmg-space-1)",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{nav.map((item, i) =>
|
||||
isMega(item) ? (
|
||||
<MegaMenuTrigger key={i} item={item} index={i} />
|
||||
) : (
|
||||
<li
|
||||
key={i}
|
||||
className="mmg-header__nav-item"
|
||||
style={{ display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<a href={item.href} aria-current={item.current ? "page" : undefined}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
{actions && <div className="mmg-header__actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type HighlightProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function Highlight({ children, className, ...rest }: HighlightProps) {
|
||||
return (
|
||||
<div className={cx("mmg-highlight", className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixHoverCard from "@radix-ui/react-hover-card";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type HoverCardProps = {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Délai d'apparition (ms). Défaut 700. */
|
||||
openDelay?: number;
|
||||
/** Délai avant fermeture (ms). Défaut 300. */
|
||||
closeDelay?: number;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HoverCard — preview riche au survol (Radix UI HoverCard).
|
||||
*
|
||||
* Pour : aperçus de profil utilisateur, métadonnées de lien, fiche produit
|
||||
* en survol, etc. Pas pour les tooltips courts (utiliser Tooltip).
|
||||
*
|
||||
* A11y : Radix gère le hover-bridge (la fenêtre reste ouverte pendant la
|
||||
* traversée de l'utilisateur entre trigger et content), le focus management
|
||||
* et l'invocation par focus clavier (le HoverCard ouvre aussi sur :focus).
|
||||
*/
|
||||
export function HoverCard({
|
||||
trigger,
|
||||
children,
|
||||
openDelay = 700,
|
||||
closeDelay = 300,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
sideOffset = 8,
|
||||
className,
|
||||
}: HoverCardProps) {
|
||||
return (
|
||||
<RadixHoverCard.Root openDelay={openDelay} closeDelay={closeDelay}>
|
||||
<RadixHoverCard.Trigger asChild>{trigger}</RadixHoverCard.Trigger>
|
||||
<RadixHoverCard.Portal>
|
||||
<RadixHoverCard.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
className={cx("mmg-hover-card", className)}
|
||||
>
|
||||
{children}
|
||||
<RadixHoverCard.Arrow className="mmg-hover-card__arrow" />
|
||||
</RadixHoverCard.Content>
|
||||
</RadixHoverCard.Portal>
|
||||
</RadixHoverCard.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadixHoverCard as HoverCardPrimitive };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
/** Nom d'une icône DSMMG (préfixe `mmg-icon-`). */
|
||||
export type IconName = string;
|
||||
|
||||
export type IconProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
/** Nom de l'icône (ex. "arrow-right"). Voir packages/icons/dist/icons.json */
|
||||
name: IconName;
|
||||
/** Taille — xs: 12px, sm: 14px, md: 18px, lg: 24px, xl: 32px, 2xl: 48px.
|
||||
Par défaut hérite de font-size. */
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
/** Texte alternatif. Si absent, l'icône est purement décorative (aria-hidden). */
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
}: IconProps) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
className={cx(
|
||||
"mmg-icon",
|
||||
`mmg-icon-${name}`,
|
||||
size && `mmg-icon--${size}`,
|
||||
className,
|
||||
)}
|
||||
style={style as CSSProperties}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type IconBlockProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
/** Icône Remix (préfère les variants -fill pour visibilité). */
|
||||
icon: IconName;
|
||||
/** Taille — xs:32 sm:40 md:56 lg:72 xl:96 px. */
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
/** Variante de couleur sémantique. */
|
||||
color?: "brand" | "success" | "warning" | "danger" | "info" | "neutral";
|
||||
/** Style visuel — soft (par défaut), filled, outline, gradient. */
|
||||
variant?: "soft" | "filled" | "outline" | "gradient";
|
||||
/** Texte alternatif. Si absent, IconBlock est décoratif. */
|
||||
label?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* IconBlock — carré coloré avec icône centrée.
|
||||
*
|
||||
* Pattern utilisé chez Linear, Vercel, Notion. Plus polyvalent que les
|
||||
* pictogrammes hand-codés : se compose à partir de n'importe quelle icône
|
||||
* Remix, scale automatiquement avec les tokens couleur sémantique, et reste
|
||||
* cohérent en dark mode.
|
||||
*
|
||||
* Préférer en Tile pour les pages d'index produits/fonctionnalités.
|
||||
*/
|
||||
export function IconBlock({
|
||||
icon,
|
||||
size = "md",
|
||||
color = "brand",
|
||||
variant = "soft",
|
||||
label,
|
||||
className,
|
||||
...rest
|
||||
}: IconBlockProps) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
className={cx(
|
||||
"mmg-iconblock",
|
||||
`mmg-iconblock--${size}`,
|
||||
`mmg-iconblock--${color}`,
|
||||
variant !== "soft" && `mmg-iconblock--${variant}`,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { HTMLAttributes, ReactNode, AnchorHTMLAttributes, ElementType } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { IconBlock, type IconBlockProps } from "./IconBlock";
|
||||
import { Pictogram, type PictogramName } from "./Pictogram";
|
||||
|
||||
/* — Container ————————————————————————————— */
|
||||
export type ContainerProps = HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: "default" | "narrow" | "wide" | "fluid";
|
||||
as?: ElementType;
|
||||
};
|
||||
export function Container({
|
||||
variant = "default",
|
||||
as: As = "div",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ContainerProps) {
|
||||
const Tag = As as ElementType;
|
||||
return (
|
||||
<Tag
|
||||
className={cx(
|
||||
"mmg-container",
|
||||
variant !== "default" && `mmg-container--${variant}`,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Section ————————————————————————————— */
|
||||
export type SectionProps = HTMLAttributes<HTMLElement> & {
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "default" | "surface" | "muted" | "brand-soft";
|
||||
};
|
||||
export function Section({
|
||||
size = "md",
|
||||
variant = "default",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
className={cx(
|
||||
"mmg-section",
|
||||
size !== "md" && `mmg-section--${size}`,
|
||||
variant !== "default" && `mmg-section--${variant}`,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Stack & Inline ————————————————————————————— */
|
||||
export function Stack({
|
||||
gap = "md",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement> & { gap?: "xs" | "sm" | "md" | "lg" | "xl" }) {
|
||||
return (
|
||||
<div className={cx("mmg-stack", `mmg-stack--${gap}`, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function Inline({
|
||||
align,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement> & { align?: "end" | "between" | "center" }) {
|
||||
return (
|
||||
<div
|
||||
className={cx("mmg-inline", align && `mmg-inline--${align}`, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Card ————————————————————————————— */
|
||||
export function Card({
|
||||
raised,
|
||||
flat,
|
||||
noPadding,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
raised?: boolean;
|
||||
flat?: boolean;
|
||||
noPadding?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"mmg-card",
|
||||
raised && "mmg-card--raised",
|
||||
flat && "mmg-card--flat",
|
||||
noPadding && "mmg-card--no-padding",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Card.Header = function CardHeader({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cx("mmg-card__header", className)} {...rest}>{children}</div>;
|
||||
};
|
||||
Card.Title = function CardTitle({ children, className, ...rest }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cx("mmg-card__title", className)} {...rest}>{children}</h3>;
|
||||
};
|
||||
Card.Desc = function CardDesc({ children, className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cx("mmg-card__desc", className)} {...rest}>{children}</p>;
|
||||
};
|
||||
Card.Footer = function CardFooter({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cx("mmg-card__footer", className)} {...rest}>{children}</div>;
|
||||
};
|
||||
|
||||
/* — Tile ————————————————————————————— */
|
||||
export type TileProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
title: ReactNode;
|
||||
desc?: ReactNode;
|
||||
/** Petite icône carrée (style basique, 48×48). */
|
||||
icon?: IconName;
|
||||
/** IconBlock — pattern recommandé : icône fill colorée sur fond rond. */
|
||||
iconBlock?: IconName;
|
||||
/** Couleur sémantique de l'IconBlock. */
|
||||
iconColor?: IconBlockProps["color"];
|
||||
/** Variante visuelle de l'IconBlock. */
|
||||
iconVariant?: IconBlockProps["variant"];
|
||||
/** Pictogramme illustré (legacy v0.2). */
|
||||
pictogram?: PictogramName;
|
||||
/** Disposition horizontale (icône à gauche). */
|
||||
horizontal?: boolean;
|
||||
/** Cache la flèche d'invitation. */
|
||||
noArrow?: boolean;
|
||||
/** Taille — sm (compact), md (défaut), lg (hero). */
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export function Tile({
|
||||
title,
|
||||
desc,
|
||||
icon,
|
||||
iconBlock,
|
||||
iconColor = "brand",
|
||||
iconVariant = "soft",
|
||||
pictogram,
|
||||
horizontal,
|
||||
noArrow,
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: TileProps) {
|
||||
const blockSize = size === "sm" ? "sm" : size === "lg" ? "lg" : "md";
|
||||
return (
|
||||
<a
|
||||
className={cx(
|
||||
"mmg-tile",
|
||||
size !== "md" && `mmg-tile--${size}`,
|
||||
horizontal && "mmg-tile--horizontal",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{iconBlock ? (
|
||||
<IconBlock icon={iconBlock} color={iconColor} variant={iconVariant} size={blockSize} />
|
||||
) : pictogram ? (
|
||||
<Pictogram name={pictogram} className="mmg-tile__pictogram" />
|
||||
) : icon ? (
|
||||
<span className="mmg-tile__icon">
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mmg-stack mmg-stack--xs" style={{ flex: 1 }}>
|
||||
<span className="mmg-tile__title">
|
||||
{title}
|
||||
{!noArrow && (
|
||||
<span className="mmg-tile__arrow" aria-hidden>
|
||||
<Icon name="arrow-right-line" size="md" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{desc && <span className="mmg-tile__desc">{desc}</span>}
|
||||
{children}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Hero ————————————————————————————— */
|
||||
export function Hero({
|
||||
title,
|
||||
lead,
|
||||
actions,
|
||||
className,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
title: ReactNode;
|
||||
lead?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cx("mmg-hero", className)} {...rest}>
|
||||
<div className="mmg-container">
|
||||
<h1 className="mmg-hero__title">{title}</h1>
|
||||
{lead && <p className="mmg-hero__lead">{lead}</p>}
|
||||
{actions && <div className="mmg-inline">{actions}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type MenuItem =
|
||||
| {
|
||||
type?: "item";
|
||||
label: ReactNode;
|
||||
icon?: IconName;
|
||||
onSelect?: () => void;
|
||||
danger?: boolean;
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
shortcut?: string;
|
||||
}
|
||||
| { type: "divider" }
|
||||
| { type: "label"; label: ReactNode };
|
||||
|
||||
export type MenuProps = {
|
||||
trigger: ReactNode;
|
||||
items: MenuItem[];
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
sideOffset?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Menu (dropdown) — wrapper Radix DropdownMenu.
|
||||
*
|
||||
* Accessibilité gérée par Radix : navigation flèches, type-ahead, Escape,
|
||||
* focus management, ARIA roles. Aucun appel a11y custom.
|
||||
*
|
||||
* Pour les sous-menus, items radio/checkbox, ouvrir
|
||||
* MenuPrimitive et composer manuellement.
|
||||
*/
|
||||
export function Menu({
|
||||
trigger,
|
||||
items,
|
||||
align = "start",
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
}: MenuProps) {
|
||||
return (
|
||||
<RadixMenu.Root>
|
||||
<RadixMenu.Trigger asChild>{trigger}</RadixMenu.Trigger>
|
||||
<RadixMenu.Portal>
|
||||
<RadixMenu.Content
|
||||
align={align}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
className="mmg-menu"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === "divider") {
|
||||
return <RadixMenu.Separator key={i} className="mmg-menu__divider" />;
|
||||
}
|
||||
if (item.type === "label") {
|
||||
return (
|
||||
<RadixMenu.Label key={i} className="mmg-menu__label">
|
||||
{item.label}
|
||||
</RadixMenu.Label>
|
||||
);
|
||||
}
|
||||
const { label, icon, onSelect, danger, href, disabled, shortcut } = item;
|
||||
const cn = cx("mmg-menu__item", danger && "mmg-menu__item--danger");
|
||||
const inner = (
|
||||
<>
|
||||
{icon && <Icon name={icon} />}
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{shortcut && <span className="mmg-menu__shortcut">{shortcut}</span>}
|
||||
</>
|
||||
);
|
||||
if (href) {
|
||||
return (
|
||||
<RadixMenu.Item key={i} className={cn} disabled={disabled} asChild>
|
||||
<a href={href}>{inner}</a>
|
||||
</RadixMenu.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RadixMenu.Item
|
||||
key={i}
|
||||
className={cn}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{inner}
|
||||
</RadixMenu.Item>
|
||||
);
|
||||
})}
|
||||
</RadixMenu.Content>
|
||||
</RadixMenu.Portal>
|
||||
</RadixMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadixMenu as MenuPrimitive };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type MetricTrend = "up" | "down" | "flat";
|
||||
|
||||
export type MetricCardProps = {
|
||||
/** Label de la métrique (ex: "MRR", "NPS", "Tickets ouverts"). */
|
||||
label: ReactNode;
|
||||
/** Valeur principale (ex: "12 480 €", "8.2"). */
|
||||
value: ReactNode;
|
||||
/** Variation chiffrée (ex: "+12.4%", "-3"). Affichée sous la valeur. */
|
||||
delta?: ReactNode;
|
||||
/** Sens de la tendance — détermine la couleur (success/danger/neutre). */
|
||||
trend?: MetricTrend;
|
||||
/** Si true, inverse la sémantique : down=success (ex: "tickets en attente"). */
|
||||
invertTrend?: boolean;
|
||||
/** Période / sous-titre (ex: "vs mois dernier"). */
|
||||
period?: ReactNode;
|
||||
/** Icône optionnelle à gauche du label. */
|
||||
icon?: IconName;
|
||||
/** Slot pour un Sparkline ou autre graphique inline. */
|
||||
sparkline?: ReactNode;
|
||||
/** Si fourni, rend la carte cliquable (drill-down). */
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TREND_ICON: Record<MetricTrend, IconName> = {
|
||||
up: "arrow-right-up-line",
|
||||
down: "arrow-right-up-line", // rotated via CSS
|
||||
flat: "subtract-line",
|
||||
};
|
||||
|
||||
/**
|
||||
* MetricCard — carte KPI avec valeur + delta + tendance + sparkline.
|
||||
*
|
||||
* Pattern Linear / Vercel / Stripe Dashboard : une grosse valeur, une
|
||||
* variation colorée (vert si positif, rouge si négatif), période, et
|
||||
* éventuellement un sparkline en arrière-plan ou inline.
|
||||
*
|
||||
* Sémantique :
|
||||
* - trend "up" : vert par défaut (croissance = bien)
|
||||
* - trend "down" : rouge par défaut (décroissance = mal)
|
||||
* - trend "flat" : neutre
|
||||
* - `invertTrend` inverse pour les KPI où "moins, c'est mieux"
|
||||
* (tickets ouverts, latence, churn).
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
trend = "flat",
|
||||
invertTrend,
|
||||
period,
|
||||
icon,
|
||||
sparkline,
|
||||
href,
|
||||
onClick,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
const Comp: "a" | "button" | "div" = href ? "a" : onClick ? "button" : "div";
|
||||
const semantic =
|
||||
trend === "flat"
|
||||
? "neutral"
|
||||
: (trend === "up" && !invertTrend) || (trend === "down" && invertTrend)
|
||||
? "success"
|
||||
: "danger";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cx(
|
||||
"mmg-metric-card",
|
||||
(href || onClick) && "mmg-metric-card--interactive",
|
||||
className,
|
||||
)}
|
||||
href={href}
|
||||
type={Comp === "button" ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="mmg-metric-card__head">
|
||||
{icon && (
|
||||
<span className="mmg-metric-card__icon" aria-hidden>
|
||||
<Icon name={icon} size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className="mmg-metric-card__label">{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="mmg-metric-card__value">{value}</div>
|
||||
|
||||
{(delta || period) && (
|
||||
<div className="mmg-metric-card__footer">
|
||||
{delta && (
|
||||
<span
|
||||
className={cx(
|
||||
"mmg-metric-card__delta",
|
||||
`mmg-metric-card__delta--${semantic}`,
|
||||
trend === "down" && "mmg-metric-card__delta--rotated",
|
||||
)}
|
||||
>
|
||||
<Icon name={TREND_ICON[trend]} size="xs" />
|
||||
{delta}
|
||||
</span>
|
||||
)}
|
||||
{period && <span className="mmg-metric-card__period">{period}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sparkline && <div className="mmg-metric-card__sparkline">{sparkline}</div>}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type PaginationProps = {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Pagination({ page, pageCount, onChange, className }: PaginationProps) {
|
||||
const pages = Array.from({ length: pageCount }, (_, i) => i + 1);
|
||||
return (
|
||||
<nav aria-label="Pagination">
|
||||
<ul className={cx("mmg-pagination", className)}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
aria-label="Page précédente"
|
||||
>
|
||||
<Icon name="arrow-left-line" />
|
||||
</button>
|
||||
</li>
|
||||
{pages.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
onClick={() => onChange(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(pageCount, page + 1))}
|
||||
disabled={page === pageCount}
|
||||
aria-label="Page suivante"
|
||||
>
|
||||
<Icon name="arrow-right-line" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import type { CSSProperties, JSX } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
/**
|
||||
* Pictogrammes ManageMate — 80×80, palette artwork stable cross-theme.
|
||||
*
|
||||
* Tokens utilisés (définis dans tokens.css) :
|
||||
* --mmg-color-art-major rose marque
|
||||
* --mmg-color-art-minor rose foncé
|
||||
* --mmg-color-art-light fond pâle (rose en light, sombre teinté en dark)
|
||||
* --mmg-color-art-dark silhouette sombre (clair en dark mode)
|
||||
* --mmg-color-art-accent bleu pour accent rare
|
||||
*
|
||||
* Inspiration : DSFR & defense.gouv.fr — illustrations plates, 2-3 couleurs,
|
||||
* silhouette claire, pas plus de 4 formes par picto.
|
||||
*/
|
||||
export type PictogramName =
|
||||
// — Démarches / documents ——————————————
|
||||
| "certificate"
|
||||
| "transfer"
|
||||
| "document"
|
||||
| "duplicate"
|
||||
| "address"
|
||||
// — Produits SIRH —————————————————————
|
||||
| "people"
|
||||
| "payroll"
|
||||
| "calendar"
|
||||
| "training"
|
||||
| "shopping"
|
||||
| "news"
|
||||
// — Marketing / général ——————————————
|
||||
| "rocket"
|
||||
| "shield"
|
||||
| "settings"
|
||||
// — Nouveaux v0.2 ————————————————————
|
||||
| "success"
|
||||
| "alert"
|
||||
| "search"
|
||||
| "support"
|
||||
| "growth"
|
||||
| "target"
|
||||
| "innovation"
|
||||
| "savings"
|
||||
| "handshake"
|
||||
| "cloud"
|
||||
| "lock-secure"
|
||||
| "globe"
|
||||
| "mobile"
|
||||
| "analytics"
|
||||
| "gift"
|
||||
| "messaging"
|
||||
// — v0.5 marine ————————————————————
|
||||
| "marine-casquette"
|
||||
| "marine-ancre"
|
||||
| "marine-porte-avion"
|
||||
// — v0.6 ManageMate Group products ——————————————
|
||||
| "synapse"
|
||||
| "hrtime"
|
||||
| "forge"
|
||||
| "orbit"
|
||||
| "automation"
|
||||
| "insights"
|
||||
| "integration"
|
||||
| "workflow";
|
||||
|
||||
const MAJOR = "var(--mmg-color-art-major)";
|
||||
const MINOR = "var(--mmg-color-art-minor)";
|
||||
const LIGHT = "var(--mmg-color-art-light)";
|
||||
const DARK = "var(--mmg-color-art-dark)";
|
||||
const ACCENT = "var(--mmg-color-art-accent)";
|
||||
|
||||
const PICTOS: Record<PictogramName, JSX.Element> = {
|
||||
certificate: (
|
||||
<>
|
||||
<rect x="14" y="10" width="52" height="62" rx="4" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<line x1="22" y1="24" x2="58" y2="24" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="22" y1="32" x2="50" y2="32" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="22" y1="40" x2="46" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="56" cy="58" r="10" fill={MAJOR} />
|
||||
<path d="M52 58 L55 61 L61 55" stroke="white" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
transfer: (
|
||||
<>
|
||||
<rect x="6" y="34" width="32" height="22" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<circle cx="14" cy="56" r="4" fill={DARK} />
|
||||
<circle cx="30" cy="56" r="4" fill={DARK} />
|
||||
<rect x="42" y="40" width="32" height="22" rx="3" fill={MAJOR} />
|
||||
<circle cx="50" cy="62" r="4" fill={DARK} />
|
||||
<circle cx="66" cy="62" r="4" fill={DARK} />
|
||||
<path d="M36 26 L44 26 L44 22 L52 30 L44 38 L44 34 L36 34 Z" fill={MAJOR} />
|
||||
</>
|
||||
),
|
||||
|
||||
document: (
|
||||
<>
|
||||
<path d="M22 8 L46 8 L58 20 L58 70 Q58 74 54 74 L22 74 Q18 74 18 70 L18 12 Q18 8 22 8 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<path d="M46 8 L46 20 L58 20" fill="none" stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
|
||||
<line x1="26" y1="34" x2="50" y2="34" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="26" y1="44" x2="50" y2="44" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="26" y1="54" x2="42" y2="54" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
duplicate: (
|
||||
<>
|
||||
<rect x="10" y="20" width="40" height="50" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="22" y="10" width="40" height="50" rx="3" fill="white" stroke={MAJOR} strokeWidth="2" />
|
||||
<line x1="30" y1="26" x2="54" y2="26" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="30" y1="34" x2="50" y2="34" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="30" y1="42" x2="46" y2="42" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
address: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="14" ry="3" fill={LIGHT} />
|
||||
<path d="M40 8 C28 8 20 16 20 28 C20 44 40 70 40 70 C40 70 60 44 60 28 C60 16 52 8 40 8 Z" fill={MAJOR} />
|
||||
<circle cx="40" cy="28" r="8" fill="white" />
|
||||
</>
|
||||
),
|
||||
|
||||
people: (
|
||||
<>
|
||||
<circle cx="28" cy="26" r="10" fill={MAJOR} />
|
||||
<circle cx="52" cy="26" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<path d="M10 60 Q10 46 28 46 Q46 46 46 60 L46 70 L10 70 Z" fill={MAJOR} />
|
||||
<path d="M34 60 Q34 46 52 46 Q70 46 70 60 L70 70 L46 70 L46 60 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
</>
|
||||
),
|
||||
|
||||
payroll: (
|
||||
<>
|
||||
<rect x="10" y="18" width="60" height="44" rx="4" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="10" y="26" width="60" height="6" fill={MAJOR} />
|
||||
<circle cx="40" cy="48" r="10" fill="white" stroke={MAJOR} strokeWidth="2" />
|
||||
<text x="40" y="52" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">€</text>
|
||||
<line x1="18" y1="44" x2="26" y2="44" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="18" y1="52" x2="26" y2="52" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="54" y1="44" x2="62" y2="44" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="54" y1="52" x2="62" y2="52" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="10" y="16" width="60" height="56" rx="4" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="10" y="16" width="60" height="14" rx="4" fill={MAJOR} />
|
||||
<line x1="22" y1="10" x2="22" y2="22" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
<line x1="58" y1="10" x2="58" y2="22" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
<rect x="20" y="38" width="8" height="8" fill={MAJOR} />
|
||||
<rect x="36" y="38" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
|
||||
<rect x="52" y="38" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
|
||||
<rect x="20" y="52" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
|
||||
<rect x="36" y="52" width="8" height="8" fill={MAJOR} />
|
||||
<rect x="52" y="52" width="8" height="8" fill={MAJOR} fillOpacity="0.4" />
|
||||
</>
|
||||
),
|
||||
|
||||
training: (
|
||||
<>
|
||||
<path d="M10 30 L40 16 L70 30 L40 44 Z" fill={MAJOR} />
|
||||
<path d="M22 36 L22 54 Q22 64 40 64 Q58 64 58 54 L58 36" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<line x1="68" y1="32" x2="68" y2="50" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="68" cy="54" r="3" fill={MAJOR} />
|
||||
</>
|
||||
),
|
||||
|
||||
shopping: (
|
||||
<>
|
||||
<path d="M14 20 L20 20 L26 56 Q26 60 30 60 L60 60 Q64 60 64 56 L68 30 L24 30" fill={LIGHT} stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
|
||||
<circle cx="32" cy="68" r="4" fill={MAJOR} />
|
||||
<circle cx="58" cy="68" r="4" fill={MAJOR} />
|
||||
<path d="M30 36 L30 44 M40 36 L40 44 M50 36 L50 44 M60 36 L60 44" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
news: (
|
||||
<>
|
||||
<rect x="8" y="18" width="56" height="50" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="64" y="30" width="10" height="38" rx="2" fill="white" stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="14" y="24" width="20" height="14" fill={MAJOR} />
|
||||
<line x1="38" y1="26" x2="58" y2="26" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="38" y1="32" x2="58" y2="32" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="38" y1="38" x2="54" y2="38" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="14" y1="46" x2="58" y2="46" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="14" y1="52" x2="58" y2="52" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="14" y1="58" x2="50" y2="58" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
rocket: (
|
||||
<>
|
||||
<path d="M40 8 Q52 16 52 36 L52 50 L28 50 L28 36 Q28 16 40 8 Z" fill={MAJOR} />
|
||||
<circle cx="40" cy="28" r="6" fill="white" />
|
||||
<path d="M28 50 L18 60 L18 68 L28 64 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
|
||||
<path d="M52 50 L62 60 L62 68 L52 64 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" strokeLinejoin="round" />
|
||||
<path d="M34 50 L34 64 L40 70 L46 64 L46 50 Z" fill={ACCENT} />
|
||||
</>
|
||||
),
|
||||
|
||||
shield: (
|
||||
<>
|
||||
<path d="M40 6 L66 16 L66 38 Q66 60 40 74 Q14 60 14 38 L14 16 Z" fill={MAJOR} />
|
||||
<path d="M40 14 L60 22 L60 38 Q60 54 40 66 Q20 54 20 38 L20 22 Z" fill={LIGHT} />
|
||||
<path d="M30 38 L37 45 L52 30" stroke={MAJOR} strokeWidth="3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="40" cy="40" r="28" fill={LIGHT} />
|
||||
<path d="M40 14 L40 18 M40 62 L40 66 M14 40 L18 40 M62 40 L66 40 M22 22 L25 25 M55 55 L58 58 M22 58 L25 55 M55 25 L58 22" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="40" cy="40" r="14" fill={MAJOR} />
|
||||
<circle cx="40" cy="40" r="6" fill="white" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ─── NOUVEAUX v0.2 ─────────────────────────────────
|
||||
|
||||
success: (
|
||||
<>
|
||||
{/* trophée / médaille */}
|
||||
<ellipse cx="40" cy="72" rx="22" ry="3" fill={LIGHT} />
|
||||
<path d="M28 12 L52 12 L52 28 Q52 44 40 50 Q28 44 28 28 Z" fill={MAJOR} />
|
||||
<path d="M28 18 L18 18 L18 28 Q18 38 28 38" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M52 18 L62 18 L62 28 Q62 38 52 38" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<rect x="34" y="50" width="12" height="14" fill={MINOR} />
|
||||
<rect x="26" y="62" width="28" height="6" rx="2" fill={DARK} />
|
||||
<path d="M36 24 L40 30 L46 18" stroke="white" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
alert: (
|
||||
<>
|
||||
<path d="M40 8 L72 64 L8 64 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
<rect x="37" y="28" width="6" height="20" rx="2" fill={MAJOR} />
|
||||
<circle cx="40" cy="56" r="3" fill={MAJOR} />
|
||||
</>
|
||||
),
|
||||
|
||||
search: (
|
||||
<>
|
||||
<circle cx="32" cy="32" r="20" fill={LIGHT} stroke={MAJOR} strokeWidth="3" />
|
||||
<circle cx="32" cy="32" r="10" fill="white" stroke={MAJOR} strokeWidth="2" />
|
||||
<line x1="48" y1="48" x2="68" y2="68" stroke={MAJOR} strokeWidth="6" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
support: (
|
||||
<>
|
||||
{/* casque audio + bulle */}
|
||||
<path d="M14 44 Q14 18 40 18 Q66 18 66 44 L66 54" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<rect x="10" y="44" width="14" height="22" rx="4" fill={MAJOR} />
|
||||
<rect x="56" y="44" width="14" height="22" rx="4" fill={MAJOR} />
|
||||
<path d="M58 64 Q58 72 50 72 L42 72" fill="none" stroke={DARK} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="40" cy="72" r="3" fill={DARK} />
|
||||
</>
|
||||
),
|
||||
|
||||
growth: (
|
||||
<>
|
||||
{/* graphe en barres + flèche montante */}
|
||||
<line x1="12" y1="68" x2="68" y2="68" stroke={DARK} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="12" y1="68" x2="12" y2="14" stroke={DARK} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<rect x="20" y="48" width="10" height="20" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="34" y="36" width="10" height="32" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="48" y="22" width="10" height="46" fill={MAJOR} />
|
||||
<path d="M16 56 L30 42 L44 30 L60 16" stroke={MAJOR} strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
<path d="M52 16 L60 16 L60 24" stroke={MAJOR} strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
target: (
|
||||
<>
|
||||
<circle cx="40" cy="40" r="32" fill={LIGHT} />
|
||||
<circle cx="40" cy="40" r="22" fill="white" stroke={MAJOR} strokeWidth="2.5" />
|
||||
<circle cx="40" cy="40" r="12" fill={MAJOR} />
|
||||
<circle cx="40" cy="40" r="4" fill="white" />
|
||||
<path d="M58 22 L70 10 M58 22 L65 22 M58 22 L58 15" stroke={DARK} strokeWidth="2.5" fill="none" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
innovation: (
|
||||
<>
|
||||
{/* ampoule */}
|
||||
<path d="M40 8 Q22 8 22 28 Q22 38 30 46 L30 56 L50 56 L50 46 Q58 38 58 28 Q58 8 40 8 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
<rect x="32" y="58" width="16" height="6" rx="1" fill={DARK} />
|
||||
<rect x="34" y="66" width="12" height="4" rx="1" fill={DARK} />
|
||||
<path d="M40 22 L40 42 M34 28 L40 22 L46 28" stroke={MAJOR} strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="14" cy="20" r="2.5" fill={MAJOR} />
|
||||
<circle cx="66" cy="20" r="2.5" fill={MAJOR} />
|
||||
<circle cx="40" cy="74" r="0" />
|
||||
</>
|
||||
),
|
||||
|
||||
savings: (
|
||||
<>
|
||||
{/* tirelire */}
|
||||
<ellipse cx="40" cy="74" rx="20" ry="2" fill={LIGHT} />
|
||||
<path d="M16 44 Q16 28 32 24 Q34 18 40 18 Q46 18 48 24 Q64 28 64 44 L64 56 Q64 66 50 66 L30 66 Q16 66 16 56 Z" fill={MAJOR} />
|
||||
<circle cx="56" cy="38" r="3" fill="white" />
|
||||
<rect x="34" y="20" width="12" height="3" rx="1" fill={DARK} />
|
||||
<line x1="32" y1="64" x2="32" y2="72" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
<line x1="48" y1="64" x2="48" y2="72" stroke={DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
handshake: (
|
||||
<>
|
||||
{/* poignée de main */}
|
||||
<rect x="6" y="32" width="22" height="14" rx="2" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="52" y="32" width="22" height="14" rx="2" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<path d="M28 38 L34 38 L40 44 L48 36 L52 36" stroke={MAJOR} strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M40 44 L46 50 L52 44 L48 40" fill={MAJOR} />
|
||||
<path d="M28 32 L48 32" stroke={MAJOR} strokeWidth="2.5" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
cloud: (
|
||||
<>
|
||||
<path d="M22 50 Q12 50 12 40 Q12 30 22 28 Q24 18 36 18 Q48 18 50 26 Q60 24 64 32 Q72 32 72 42 Q72 52 62 52 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
<path d="M40 36 L40 56 M32 50 L40 56 L48 50" stroke={MAJOR} strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
"lock-secure": (
|
||||
<>
|
||||
<rect x="16" y="34" width="48" height="36" rx="4" fill={MAJOR} />
|
||||
<path d="M22 34 L22 24 Q22 8 40 8 Q58 8 58 24 L58 34" fill="none" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="40" cy="48" r="6" fill={LIGHT} />
|
||||
<rect x="38" y="50" width="4" height="10" rx="1" fill={LIGHT} />
|
||||
</>
|
||||
),
|
||||
|
||||
globe: (
|
||||
<>
|
||||
<circle cx="40" cy="40" r="30" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
<ellipse cx="40" cy="40" rx="14" ry="30" fill="none" stroke={MAJOR} strokeWidth="2" />
|
||||
<line x1="10" y1="40" x2="70" y2="40" stroke={MAJOR} strokeWidth="2" />
|
||||
<path d="M14 28 Q40 22 66 28" fill="none" stroke={MAJOR} strokeWidth="2" />
|
||||
<path d="M14 52 Q40 58 66 52" fill="none" stroke={MAJOR} strokeWidth="2" />
|
||||
<circle cx="58" cy="22" r="4" fill={ACCENT} />
|
||||
</>
|
||||
),
|
||||
|
||||
mobile: (
|
||||
<>
|
||||
<rect x="22" y="8" width="36" height="64" rx="6" fill={MAJOR} />
|
||||
<rect x="26" y="14" width="28" height="46" rx="2" fill={LIGHT} />
|
||||
<circle cx="40" cy="66" r="2.5" fill="white" />
|
||||
<line x1="34" y1="11" x2="46" y2="11" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<rect x="32" y="22" width="16" height="2.5" rx="1" fill={MAJOR} />
|
||||
<rect x="32" y="30" width="12" height="2.5" rx="1" fill={DARK} />
|
||||
<rect x="32" y="38" width="14" height="2.5" rx="1" fill={DARK} />
|
||||
</>
|
||||
),
|
||||
|
||||
analytics: (
|
||||
<>
|
||||
{/* écran avec graphe */}
|
||||
<rect x="6" y="14" width="68" height="44" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="32" y="60" width="16" height="4" fill={DARK} />
|
||||
<rect x="22" y="64" width="36" height="4" rx="1" fill={DARK} />
|
||||
<polyline points="14,46 24,38 32,42 42,28 52,32 66,20" fill="none" stroke={MAJOR} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="14" cy="46" r="2.5" fill={MAJOR} />
|
||||
<circle cx="42" cy="28" r="2.5" fill={MAJOR} />
|
||||
<circle cx="66" cy="20" r="2.5" fill={MAJOR} />
|
||||
</>
|
||||
),
|
||||
|
||||
gift: (
|
||||
<>
|
||||
<rect x="10" y="32" width="60" height="38" rx="3" fill={MAJOR} />
|
||||
<rect x="10" y="22" width="60" height="14" rx="2" fill={MINOR} />
|
||||
<rect x="36" y="22" width="8" height="48" fill={LIGHT} />
|
||||
<path d="M28 22 Q24 14 28 10 Q32 6 36 10 Q40 14 40 22" fill="none" stroke={LIGHT} strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M52 22 Q56 14 52 10 Q48 6 44 10 Q40 14 40 22" fill="none" stroke={LIGHT} strokeWidth="3" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
messaging: (
|
||||
<>
|
||||
<path d="M10 18 Q10 14 14 14 L66 14 Q70 14 70 18 L70 50 Q70 54 66 54 L32 54 L20 66 L20 54 L14 54 Q10 54 10 50 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
<circle cx="26" cy="34" r="3" fill={MAJOR} />
|
||||
<circle cx="40" cy="34" r="3" fill={MAJOR} />
|
||||
<circle cx="54" cy="34" r="3" fill={MAJOR} />
|
||||
</>
|
||||
),
|
||||
|
||||
// ─── Marine v0.5 (defense.gouv-inspired) ─────────────────
|
||||
"marine-casquette": (
|
||||
<>
|
||||
{/* casquette d'officier de marine — visière arrondie + bande + insigne */}
|
||||
<ellipse cx="40" cy="68" rx="26" ry="3" fill={LIGHT} />
|
||||
{/* visière */}
|
||||
<path d="M14 56 Q14 52 18 50 L62 50 Q66 52 66 56 Q66 60 62 60 L18 60 Q14 60 14 56 Z" fill={MINOR} />
|
||||
{/* bande dorée (band) */}
|
||||
<rect x="14" y="44" width="52" height="6" fill={DARK} />
|
||||
{/* dôme principal */}
|
||||
<path d="M16 44 Q16 18 40 18 Q64 18 64 44 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" strokeLinejoin="round" />
|
||||
{/* insigne ancré */}
|
||||
<circle cx="40" cy="32" r="6" fill={MAJOR} />
|
||||
<line x1="40" y1="28" x2="40" y2="38" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M36 36 Q40 40 44 36" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
{/* feuille d'olivier sur la visière */}
|
||||
<path d="M22 54 Q26 56 30 54" stroke={DARK} strokeWidth="1" fill="none" strokeLinecap="round" />
|
||||
<path d="M50 54 Q54 56 58 54" stroke={DARK} strokeWidth="1" fill="none" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
"marine-ancre": (
|
||||
<>
|
||||
{/* ombre */}
|
||||
<ellipse cx="40" cy="74" rx="20" ry="2" fill={LIGHT} />
|
||||
{/* anneau supérieur */}
|
||||
<circle cx="40" cy="14" r="6" fill="none" stroke={MAJOR} strokeWidth="3" />
|
||||
{/* tige verticale */}
|
||||
<line x1="40" y1="20" x2="40" y2="60" stroke={MAJOR} strokeWidth="4" strokeLinecap="round" />
|
||||
{/* barre horizontale (jas) */}
|
||||
<line x1="26" y1="28" x2="54" y2="28" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
|
||||
<circle cx="26" cy="28" r="2.5" fill={MAJOR} />
|
||||
<circle cx="54" cy="28" r="2.5" fill={MAJOR} />
|
||||
{/* bras gauche + patte */}
|
||||
<path d="M40 56 Q22 56 16 44" fill="none" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
|
||||
<path d="M16 44 L12 50 M16 44 L20 48" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
{/* bras droit + patte */}
|
||||
<path d="M40 56 Q58 56 64 44" fill="none" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
|
||||
<path d="M64 44 L68 50 M64 44 L60 48" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
{/* corde enroulée — visualisée par 2 ondes */}
|
||||
<path d="M30 68 Q35 64 40 68 T50 68" fill="none" stroke={DARK} strokeWidth="2" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ─── ManageMate Group products v0.6 ───────────────
|
||||
|
||||
/* Synapse — flux/connexion (réseau de neurones) */
|
||||
synapse: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
|
||||
{/* Nœuds (neurones) */}
|
||||
<circle cx="20" cy="20" r="6" fill={MAJOR} />
|
||||
<circle cx="60" cy="20" r="6" fill={MAJOR} />
|
||||
<circle cx="14" cy="48" r="6" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<circle cx="40" cy="40" r="9" fill={MAJOR} />
|
||||
<circle cx="66" cy="48" r="6" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<circle cx="40" cy="66" r="6" fill={MINOR} />
|
||||
{/* Synapses (connexions) */}
|
||||
<line x1="20" y1="20" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="60" y1="20" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="14" y1="48" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="66" y1="48" x2="40" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="40" y1="40" x2="40" y2="66" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
{/* Pulse au centre */}
|
||||
<circle cx="40" cy="40" r="4" fill="white" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* HRTime — équipe + horloge (gestion du temps RH) */
|
||||
hrtime: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="22" ry="2" fill={LIGHT} />
|
||||
{/* Cadran d'horloge en arrière-plan */}
|
||||
<circle cx="40" cy="40" r="28" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
{/* Marques d'heures */}
|
||||
<line x1="40" y1="14" x2="40" y2="18" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="40" y1="62" x2="40" y2="66" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="14" y1="40" x2="18" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="62" y1="40" x2="66" y2="40" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
{/* Aiguilles */}
|
||||
<line x1="40" y1="40" x2="40" y2="24" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<line x1="40" y1="40" x2="52" y2="44" stroke={MAJOR} strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="40" cy="40" r="3" fill={MAJOR} />
|
||||
{/* Trois personnes en silhouette devant l'horloge */}
|
||||
<circle cx="28" cy="50" r="5" fill={MAJOR} />
|
||||
<path d="M20 66 Q20 56 28 56 Q36 56 36 66 Z" fill={MAJOR} />
|
||||
<circle cx="52" cy="50" r="5" fill={MAJOR} />
|
||||
<path d="M44 66 Q44 56 52 56 Q60 56 60 66 Z" fill={MAJOR} />
|
||||
</>
|
||||
),
|
||||
|
||||
/* Forge — outils (clé + engrenage = construire) */
|
||||
forge: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
|
||||
{/* Engrenage central */}
|
||||
<circle cx="42" cy="38" r="14" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
{/* Dents de l'engrenage */}
|
||||
<rect x="40" y="18" width="4" height="6" fill={MAJOR} />
|
||||
<rect x="40" y="52" width="4" height="6" fill={MAJOR} />
|
||||
<rect x="22" y="36" width="6" height="4" fill={MAJOR} />
|
||||
<rect x="56" y="36" width="6" height="4" fill={MAJOR} />
|
||||
<rect x="27" y="22" width="6" height="4" fill={MAJOR} transform="rotate(-45 30 24)" />
|
||||
<rect x="51" y="22" width="6" height="4" fill={MAJOR} transform="rotate(45 54 24)" />
|
||||
<rect x="27" y="50" width="6" height="4" fill={MAJOR} transform="rotate(45 30 52)" />
|
||||
<rect x="51" y="50" width="6" height="4" fill={MAJOR} transform="rotate(-45 54 52)" />
|
||||
<circle cx="42" cy="38" r="5" fill={MAJOR} />
|
||||
{/* Clé qui croise l'engrenage */}
|
||||
<circle cx="14" cy="60" r="6" fill="none" stroke={MAJOR} strokeWidth="3" />
|
||||
<line x1="18" y1="56" x2="32" y2="42" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
|
||||
<line x1="28" y1="46" x2="32" y2="50" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
|
||||
<line x1="34" y1="40" x2="36" y2="42" stroke={MAJOR} strokeWidth="3.5" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* Orbit — planète + satellites (data orchestration) */
|
||||
orbit: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="22" ry="2" fill={LIGHT} />
|
||||
{/* Orbite extérieure (anneau elliptique) */}
|
||||
<ellipse
|
||||
cx="40"
|
||||
cy="40"
|
||||
rx="32"
|
||||
ry="14"
|
||||
fill="none"
|
||||
stroke={MAJOR}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="3 4"
|
||||
transform="rotate(-25 40 40)"
|
||||
/>
|
||||
{/* Planète centrale */}
|
||||
<circle cx="40" cy="40" r="14" fill={MAJOR} />
|
||||
<ellipse cx="40" cy="40" rx="14" ry="5" fill={MINOR} opacity="0.7" />
|
||||
<circle cx="36" cy="36" r="3" fill="white" opacity="0.5" />
|
||||
{/* Satellites sur l'orbite */}
|
||||
<circle cx="68" cy="28" r="4" fill={MAJOR} />
|
||||
<circle cx="14" cy="56" r="3" fill={MAJOR} />
|
||||
<circle cx="62" cy="58" r="3" fill={MINOR} />
|
||||
{/* Trail */}
|
||||
<path d="M68 28 Q66 30 64 30" stroke={MAJOR} strokeWidth="1" strokeLinecap="round" opacity="0.4" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* Automation — robot/process automatisé */
|
||||
automation: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
|
||||
{/* Tête du robot */}
|
||||
<rect x="20" y="20" width="40" height="32" rx="6" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
{/* Antenne */}
|
||||
<line x1="40" y1="14" x2="40" y2="20" stroke={MAJOR} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="40" cy="12" r="3" fill={MAJOR} />
|
||||
{/* Yeux */}
|
||||
<circle cx="30" cy="34" r="4" fill={MAJOR} />
|
||||
<circle cx="50" cy="34" r="4" fill={MAJOR} />
|
||||
<circle cx="30" cy="34" r="1.5" fill="white" />
|
||||
<circle cx="50" cy="34" r="1.5" fill="white" />
|
||||
{/* Bouche / interface */}
|
||||
<rect x="32" y="42" width="16" height="3" rx="1" fill={MAJOR} />
|
||||
{/* Corps avec circuits */}
|
||||
<rect x="26" y="54" width="28" height="14" rx="3" fill={MAJOR} />
|
||||
<line x1="32" y1="60" x2="36" y2="60" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="40" y1="60" x2="48" y2="60" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* Insights — loupe + données (analytique) */
|
||||
insights: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
|
||||
{/* Tableau de bord en arrière */}
|
||||
<rect x="14" y="14" width="44" height="36" rx="3" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
<rect x="20" y="20" width="14" height="3" rx="1" fill={MAJOR} opacity="0.6" />
|
||||
{/* Mini barchart */}
|
||||
<rect x="20" y="38" width="4" height="8" fill={MAJOR} />
|
||||
<rect x="26" y="32" width="4" height="14" fill={MAJOR} />
|
||||
<rect x="32" y="36" width="4" height="10" fill={MAJOR} opacity="0.7" />
|
||||
<rect x="38" y="28" width="4" height="18" fill={MAJOR} />
|
||||
<rect x="44" y="34" width="4" height="12" fill={MAJOR} opacity="0.7" />
|
||||
{/* Loupe en superposition */}
|
||||
<circle cx="50" cy="50" r="14" fill="white" stroke={MAJOR} strokeWidth="3" />
|
||||
<line x1="60" y1="60" x2="70" y2="70" stroke={MAJOR} strokeWidth="4" strokeLinecap="round" />
|
||||
{/* Trend line dans la loupe */}
|
||||
<polyline points="42,54 46,50 50,52 54,46 58,48" fill="none" stroke={MAJOR} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* Integration — modules connectés (puzzle/blocks) */
|
||||
integration: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="20" ry="2" fill={LIGHT} />
|
||||
{/* Bloc supérieur gauche */}
|
||||
<path d="M14 16 L34 16 L34 24 Q34 28 38 28 Q42 28 42 24 L42 16 L42 36 L34 36 Q30 36 30 40 Q30 44 34 44 L42 44 L42 50 L14 50 Z" fill={MAJOR} />
|
||||
{/* Bloc inférieur droit complémentaire */}
|
||||
<path d="M42 30 L62 30 L62 50 L62 60 Q62 64 58 64 Q54 64 54 60 L54 56 L42 56 Z" fill={LIGHT} stroke={MAJOR} strokeWidth="2" />
|
||||
{/* Bloc top droit */}
|
||||
<rect x="48" y="14" width="20" height="14" rx="3" fill={MINOR} />
|
||||
{/* Petits accent */}
|
||||
<circle cx="58" cy="21" r="2" fill="white" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* Workflow — flèches en boucle (process) */
|
||||
workflow: (
|
||||
<>
|
||||
<ellipse cx="40" cy="72" rx="22" ry="2" fill={LIGHT} />
|
||||
{/* Étape 1 (cercle haut gauche) */}
|
||||
<circle cx="22" cy="22" r="10" fill={MAJOR} />
|
||||
<text x="22" y="27" fontSize="14" fontWeight="700" fill="white" textAnchor="middle" fontFamily="system-ui, sans-serif">1</text>
|
||||
{/* Étape 2 (cercle haut droit) */}
|
||||
<circle cx="58" cy="22" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
<text x="58" y="27" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">2</text>
|
||||
{/* Étape 3 (cercle bas droit) */}
|
||||
<circle cx="58" cy="58" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
<text x="58" y="63" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">3</text>
|
||||
{/* Étape 4 (cercle bas gauche) */}
|
||||
<circle cx="22" cy="58" r="10" fill={LIGHT} stroke={MAJOR} strokeWidth="2.5" />
|
||||
<text x="22" y="63" fontSize="14" fontWeight="700" fill={MAJOR} textAnchor="middle" fontFamily="system-ui, sans-serif">4</text>
|
||||
{/* Flèches entre les étapes */}
|
||||
<path d="M32 22 L48 22" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M44 19 L48 22 L44 25" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M58 32 L58 48" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M55 44 L58 48 L61 44" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M48 58 L32 58" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M36 55 L32 58 L36 61" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M22 48 L22 32" stroke={MAJOR} strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M19 36 L22 32 L25 36" stroke={MAJOR} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
|
||||
"marine-porte-avion": (
|
||||
<>
|
||||
{/* mer + reflets */}
|
||||
<path d="M0 64 Q20 60 40 64 T80 64 L80 80 L0 80 Z" fill={LIGHT} />
|
||||
<path d="M4 70 Q14 68 24 70" stroke={ACCENT} strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M50 72 Q60 70 70 72" stroke={ACCENT} strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
{/* coque grise */}
|
||||
<path d="M4 56 L76 56 L70 64 L10 64 Z" fill={DARK} />
|
||||
{/* pont d'envol — plan supérieur avec angle */}
|
||||
<path d="M2 50 L78 50 L74 56 L6 56 Z" fill="#3D4757" />
|
||||
{/* îlot (tour de commandement) */}
|
||||
<rect x="48" y="32" width="12" height="18" fill={DARK} />
|
||||
<rect x="50" y="34" width="3" height="3" fill={LIGHT} />
|
||||
<rect x="55" y="34" width="3" height="3" fill={LIGHT} />
|
||||
<rect x="50" y="40" width="3" height="3" fill={LIGHT} />
|
||||
<rect x="55" y="40" width="3" height="3" fill={LIGHT} />
|
||||
{/* radar — mât + plateau */}
|
||||
<line x1="54" y1="20" x2="54" y2="32" stroke={DARK} strokeWidth="2" />
|
||||
<ellipse cx="54" cy="20" rx="6" ry="2" fill={MAJOR} />
|
||||
{/* tour antenne secondaire */}
|
||||
<line x1="60" y1="26" x2="60" y2="32" stroke={DARK} strokeWidth="1.5" />
|
||||
<circle cx="60" cy="26" r="1.5" fill={MAJOR} />
|
||||
{/* avion sur le pont — silhouette simplifiée */}
|
||||
<path d="M14 48 L24 44 L28 44 L26 48 L18 50 Z" fill={MAJOR} />
|
||||
<path d="M22 44 L22 40 M26 46 L30 44" stroke={MAJOR} strokeWidth="1.5" strokeLinecap="round" />
|
||||
{/* marquage centré du pont */}
|
||||
<line x1="32" y1="53" x2="44" y2="53" stroke="white" strokeWidth="1" strokeDasharray="2 2" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export type PictogramProps = {
|
||||
name: PictogramName;
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function Pictogram({
|
||||
name,
|
||||
size = 80,
|
||||
className,
|
||||
style,
|
||||
title,
|
||||
}: PictogramProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 80 80"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx("mmg-pictogram", className)}
|
||||
style={style}
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
{PICTOS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const PICTOGRAM_NAMES: PictogramName[] = [
|
||||
"certificate", "transfer", "document", "duplicate", "address",
|
||||
"people", "payroll", "calendar", "training", "shopping", "news",
|
||||
"rocket", "shield", "settings",
|
||||
"success", "alert", "search", "support", "growth", "target",
|
||||
"innovation", "savings", "handshake", "cloud", "lock-secure",
|
||||
"globe", "mobile", "analytics", "gift", "messaging",
|
||||
"marine-casquette", "marine-ancre", "marine-porte-avion",
|
||||
"synapse", "hrtime", "forge", "orbit",
|
||||
"automation", "insights", "integration", "workflow",
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixPopover from "@radix-ui/react-popover";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type PopoverProps = {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
sideOffset?: number;
|
||||
modal?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Popover — wrapper Radix UI Popover.
|
||||
*
|
||||
* Couvre les cas usuels (trigger + contenu). Pour des compositions plus
|
||||
* fines (close button, anchor séparé, sub-popover) utiliser PopoverPrimitive.
|
||||
*/
|
||||
export function Popover({
|
||||
trigger,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultOpen,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
sideOffset = 8,
|
||||
modal,
|
||||
className,
|
||||
}: PopoverProps) {
|
||||
return (
|
||||
<RadixPopover.Root open={open} onOpenChange={onOpenChange} defaultOpen={defaultOpen} modal={modal}>
|
||||
<RadixPopover.Trigger asChild>{trigger}</RadixPopover.Trigger>
|
||||
<RadixPopover.Portal>
|
||||
<RadixPopover.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
className={cx("mmg-popover", className)}
|
||||
>
|
||||
{children}
|
||||
<RadixPopover.Arrow className="mmg-popover__arrow" />
|
||||
</RadixPopover.Content>
|
||||
</RadixPopover.Portal>
|
||||
</RadixPopover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadixPopover as PopoverPrimitive };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type PricingFeature = {
|
||||
label: ReactNode;
|
||||
/** Si false, affiche un icône "x" (feature non incluse dans ce tier). */
|
||||
included?: boolean;
|
||||
/** Texte d'aide en survol. */
|
||||
hint?: ReactNode;
|
||||
};
|
||||
|
||||
export type PricingCardProps = {
|
||||
/** Nom du plan (ex: "Starter", "Pro", "Enterprise"). */
|
||||
name: ReactNode;
|
||||
/** Description courte sous le nom. */
|
||||
description?: ReactNode;
|
||||
/** Prix principal (ex: "29 €", "Sur devis"). */
|
||||
price: ReactNode;
|
||||
/** Suffixe du prix (ex: "/ mois / utilisateur"). */
|
||||
pricePeriod?: ReactNode;
|
||||
/** Liste de features. */
|
||||
features: PricingFeature[];
|
||||
/** CTA principal. */
|
||||
cta: ReactNode;
|
||||
/** Marque ce plan comme "recommandé" (mise en avant visuelle). */
|
||||
highlighted?: boolean;
|
||||
/** Badge en haut à droite (ex: "Populaire", "-20%"). */
|
||||
badge?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* PricingCard — carte de tarification.
|
||||
*
|
||||
* Pattern Stripe / Vercel / Linear : nom + prix + features + CTA. Une
|
||||
* variante `highlighted` met en avant le plan recommandé via une bordure
|
||||
* accent et un fond légèrement teinté.
|
||||
*/
|
||||
export function PricingCard({
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
pricePeriod,
|
||||
features,
|
||||
cta,
|
||||
highlighted,
|
||||
badge,
|
||||
className,
|
||||
}: PricingCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"mmg-pricing-card",
|
||||
highlighted && "mmg-pricing-card--highlighted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{badge && <div className="mmg-pricing-card__badge">{badge}</div>}
|
||||
<div className="mmg-pricing-card__head">
|
||||
<h3 className="mmg-pricing-card__name">{name}</h3>
|
||||
{description && <p className="mmg-pricing-card__desc">{description}</p>}
|
||||
</div>
|
||||
<div className="mmg-pricing-card__price">
|
||||
<span className="mmg-pricing-card__price-value">{price}</span>
|
||||
{pricePeriod && <span className="mmg-pricing-card__price-period">{pricePeriod}</span>}
|
||||
</div>
|
||||
<ul className="mmg-pricing-card__features">
|
||||
{features.map((f, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={cx(
|
||||
"mmg-pricing-card__feature",
|
||||
f.included === false && "mmg-pricing-card__feature--excluded",
|
||||
)}
|
||||
title={typeof f.hint === "string" ? f.hint : undefined}
|
||||
>
|
||||
<Icon
|
||||
name={f.included === false ? "close-line" : "check-line"}
|
||||
size="sm"
|
||||
className="mmg-pricing-card__feature-icon"
|
||||
/>
|
||||
<span>{f.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mmg-pricing-card__cta">{cta}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Avatar, type AvatarStatus } from "./Avatar";
|
||||
|
||||
export type ProfileHeaderProps = {
|
||||
name: ReactNode;
|
||||
/** Sous-titre (poste, organisation…). */
|
||||
subtitle?: ReactNode;
|
||||
/** Description longue / bio. */
|
||||
bio?: ReactNode;
|
||||
/** URL de la photo de profil. */
|
||||
src?: string;
|
||||
initials?: string;
|
||||
status?: AvatarStatus;
|
||||
/** URL de la photo de couverture (cover). Si absente, gradient accent. */
|
||||
coverSrc?: string;
|
||||
/** Stats à droite des actions (commits, reviews, années…). */
|
||||
stats?: { label: ReactNode; value: ReactNode }[];
|
||||
/** Slot d'actions sous la bio (boutons, menu…). */
|
||||
actions?: ReactNode;
|
||||
/** Slot de badges (sous le nom). */
|
||||
badges?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ProfileHeader — en-tête de profil avec photo de couverture + avatar
|
||||
* en débord (LinkedIn / GitHub / Twitter pattern).
|
||||
*
|
||||
* - Cover : image fournie ou gradient accent par défaut.
|
||||
* - Avatar large (xl) en débord sur la cover.
|
||||
* - Identité (nom + sous-titre + bio).
|
||||
* - Stats inline + actions (Suivre, Message…).
|
||||
*/
|
||||
export function ProfileHeader({
|
||||
name,
|
||||
subtitle,
|
||||
bio,
|
||||
src,
|
||||
initials,
|
||||
status,
|
||||
coverSrc,
|
||||
stats,
|
||||
actions,
|
||||
badges,
|
||||
className,
|
||||
}: ProfileHeaderProps) {
|
||||
return (
|
||||
<div className={cx("mmg-profile-header", className)}>
|
||||
<div className="mmg-profile-header__cover">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" aria-hidden />
|
||||
) : (
|
||||
<div className="mmg-profile-header__cover-gradient" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="mmg-profile-header__body">
|
||||
<div className="mmg-profile-header__avatar-wrap">
|
||||
<Avatar
|
||||
src={src}
|
||||
initials={initials}
|
||||
alt={typeof name === "string" ? name : undefined}
|
||||
status={status}
|
||||
size="2xl"
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
<div className="mmg-profile-header__main">
|
||||
<div className="mmg-profile-header__heading">
|
||||
<div>
|
||||
<h2 className="mmg-profile-header__name">{name}</h2>
|
||||
{subtitle && <div className="mmg-profile-header__subtitle">{subtitle}</div>}
|
||||
{badges && <div className="mmg-profile-header__badges">{badges}</div>}
|
||||
</div>
|
||||
{actions && <div className="mmg-profile-header__actions">{actions}</div>}
|
||||
</div>
|
||||
{bio && <p className="mmg-profile-header__bio">{bio}</p>}
|
||||
{stats && stats.length > 0 && (
|
||||
<dl className="mmg-profile-header__stats">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="mmg-profile-header__stat">
|
||||
<dt className="mmg-profile-header__stat-label">{s.label}</dt>
|
||||
<dd className="mmg-profile-header__stat-value">{s.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type ProgressProps = HTMLAttributes<HTMLDivElement> & {
|
||||
value?: number;
|
||||
max?: number;
|
||||
variant?: "success" | "warning" | "danger";
|
||||
indeterminate?: boolean;
|
||||
};
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
max = 100,
|
||||
variant,
|
||||
indeterminate,
|
||||
className,
|
||||
...rest
|
||||
}: ProgressProps) {
|
||||
const pct = indeterminate ? 100 : Math.min(100, Math.max(0, ((value ?? 0) / max) * 100));
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={indeterminate ? undefined : value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
className={cx(
|
||||
"mmg-progress",
|
||||
variant && `mmg-progress--${variant}`,
|
||||
indeterminate && "mmg-progress--indeterminate",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div className="mmg-progress__bar" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type QuoteProps = {
|
||||
author?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Quote({ author, children, className }: QuoteProps) {
|
||||
return (
|
||||
<blockquote className={cx("mmg-quote", className)}>
|
||||
<p style={{ margin: 0 }}>{children}</p>
|
||||
{author && <cite className="mmg-quote__author">— {author}</cite>}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, type InputHTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type SearchBarProps = Omit<InputHTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
onSearch?: (q: string) => void;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
export function SearchBar({
|
||||
placeholder = "Rechercher…",
|
||||
onSearch,
|
||||
buttonLabel = "Rechercher",
|
||||
className,
|
||||
...rest
|
||||
}: SearchBarProps) {
|
||||
const [val, setVal] = useState("");
|
||||
return (
|
||||
<form
|
||||
className={cx("mmg-search", className)}
|
||||
role="search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSearch?.(val);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
className="mmg-search__input"
|
||||
placeholder={placeholder}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
{...rest}
|
||||
/>
|
||||
<button type="submit" className="mmg-search__btn">
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type SheetSide = "left" | "right" | "top" | "bottom";
|
||||
export type SheetSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||
|
||||
export type SheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Côté d'apparition. Défaut "right" (le plus courant pour un sheet desktop). */
|
||||
side?: SheetSide;
|
||||
/** Largeur (left/right) ou hauteur (top/bottom). */
|
||||
size?: SheetSize;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** Cache le bouton de fermeture en haut à droite. */
|
||||
hideClose?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sheet — panneau latéral / drawer (Radix Dialog avec position custom).
|
||||
*
|
||||
* Différence avec Dialog : Sheet glisse depuis un bord (latéral, top, bottom).
|
||||
* Différence avec Drawer (legacy) : Sheet utilise Radix → focus trap, scroll
|
||||
* lock, restitution focus, escape, aria-* tout est natif. Préférer Sheet.
|
||||
*
|
||||
* Usage typique : édition rapide d'une ressource, navigation mobile,
|
||||
* filtres avancés, prévisualisation détaillée.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
side = "right",
|
||||
size = "md",
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
hideClose,
|
||||
className,
|
||||
}: SheetProps) {
|
||||
return (
|
||||
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay className="mmg-sheet__overlay" />
|
||||
<RadixDialog.Content
|
||||
className={cx(
|
||||
"mmg-sheet",
|
||||
`mmg-sheet--${side}`,
|
||||
`mmg-sheet--${size}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(title || !hideClose) && (
|
||||
<header className="mmg-sheet__header">
|
||||
<div className="mmg-sheet__header-text">
|
||||
{title && (
|
||||
<RadixDialog.Title className="mmg-sheet__title">
|
||||
{title}
|
||||
</RadixDialog.Title>
|
||||
)}
|
||||
{description && (
|
||||
<RadixDialog.Description className="mmg-sheet__description">
|
||||
{description}
|
||||
</RadixDialog.Description>
|
||||
)}
|
||||
</div>
|
||||
{!hideClose && (
|
||||
<RadixDialog.Close asChild>
|
||||
<button type="button" className="mmg-sheet__close" aria-label="Fermer">
|
||||
<Icon name="close-line" size="md" />
|
||||
</button>
|
||||
</RadixDialog.Close>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<div className="mmg-sheet__body">{children}</div>
|
||||
{footer && <footer className="mmg-sheet__footer">{footer}</footer>}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadixDialog as SheetPrimitive };
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Système global de raccourcis clavier — DSMMG.
|
||||
*
|
||||
* Architecture :
|
||||
* - <ShortcutProvider> au sommet de l'app
|
||||
* - useShortcut(key, handler, options) dans n'importe quel composant
|
||||
* - <ShortcutCheatsheet> ou hook useShortcutList() pour afficher tous
|
||||
* les raccourcis enregistrés
|
||||
*
|
||||
* Conformité a11y :
|
||||
* - WCAG 2.1.1 (clavier) : tout le système est navigable au clavier
|
||||
* - WCAG 2.1.4 (raccourcis caractère seul) : on accepte les modifiers
|
||||
* Ctrl/Cmd/Alt/Shift, ou les chords (g i) — un caractère unique seul
|
||||
* est désactivé si une saisie est focus (input, textarea, contenteditable)
|
||||
*
|
||||
* Format de clé :
|
||||
* - "cmd+k", "ctrl+/", "shift+?"
|
||||
* - Chord : "g i", "g h", "y y"
|
||||
* - Touche simple : "/" , "?" (uniquement si aucun champ focus)
|
||||
*/
|
||||
|
||||
export type ShortcutOptions = {
|
||||
/** Catégorie pour le regroupement dans la cheatsheet */
|
||||
category?: string;
|
||||
/** Description affichée dans la cheatsheet */
|
||||
description?: string;
|
||||
/** Désactive le raccourci si vrai */
|
||||
disabled?: boolean;
|
||||
/** Autorise même quand un input/textarea est focusé */
|
||||
allowInInput?: boolean;
|
||||
/** Empêche le default du browser */
|
||||
preventDefault?: boolean;
|
||||
};
|
||||
|
||||
export type ShortcutEntry = {
|
||||
id: string;
|
||||
keys: string;
|
||||
category: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type ShortcutHandler = (event: KeyboardEvent) => void;
|
||||
|
||||
type Registered = {
|
||||
keys: string;
|
||||
handler: ShortcutHandler;
|
||||
options: ShortcutOptions;
|
||||
};
|
||||
|
||||
type Ctx = {
|
||||
register: (id: string, entry: Registered) => () => void;
|
||||
list: () => ShortcutEntry[];
|
||||
};
|
||||
|
||||
const ShortcutContext = createContext<Ctx | null>(null);
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
function normalizeCombo(combo: string): string {
|
||||
return combo
|
||||
.toLowerCase()
|
||||
.split("+")
|
||||
.map((p) => p.trim())
|
||||
.map((p) => (p === "cmd" || p === "meta" ? (isMac ? "meta" : "ctrl") : p))
|
||||
.sort()
|
||||
.join("+");
|
||||
}
|
||||
|
||||
function eventToCombo(e: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey) parts.push("ctrl");
|
||||
if (e.metaKey) parts.push("meta");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey) parts.push("shift");
|
||||
// Skip the modifier itself if user is just holding modifiers
|
||||
const k = e.key.toLowerCase();
|
||||
if (!["control", "meta", "alt", "shift"].includes(k)) parts.push(k);
|
||||
return parts.sort().join("+");
|
||||
}
|
||||
|
||||
function isInsideEditableElement(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ShortcutProvider({ children }: { children: ReactNode }) {
|
||||
const registry = useRef(new Map<string, Registered>());
|
||||
const chordBuffer = useRef<{ key: string; expires: number } | null>(null);
|
||||
const CHORD_WINDOW_MS = 800;
|
||||
|
||||
const register = useCallback((id: string, entry: Registered) => {
|
||||
registry.current.set(id, entry);
|
||||
return () => {
|
||||
registry.current.delete(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const list = useCallback((): ShortcutEntry[] => {
|
||||
return Array.from(registry.current.entries()).map(([id, e]) => ({
|
||||
id,
|
||||
keys: e.keys,
|
||||
category: e.options.category ?? "Général",
|
||||
description: e.options.description ?? "",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const inEditable = isInsideEditableElement(e.target);
|
||||
|
||||
const combo = eventToCombo(e);
|
||||
const now = Date.now();
|
||||
|
||||
// Test chord
|
||||
if (chordBuffer.current && chordBuffer.current.expires > now) {
|
||||
const chordCombo = `${chordBuffer.current.key} ${combo}`;
|
||||
for (const [, entry] of registry.current) {
|
||||
if (entry.options.disabled) continue;
|
||||
if (inEditable && !entry.options.allowInInput) continue;
|
||||
const normalized = entry.keys
|
||||
.split(" ")
|
||||
.map(normalizeCombo)
|
||||
.join(" ");
|
||||
if (normalized === chordCombo) {
|
||||
if (entry.options.preventDefault !== false) e.preventDefault();
|
||||
entry.handler(e);
|
||||
chordBuffer.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test single combo
|
||||
for (const [, entry] of registry.current) {
|
||||
if (entry.options.disabled) continue;
|
||||
if (inEditable && !entry.options.allowInInput) continue;
|
||||
const normalized = normalizeCombo(entry.keys);
|
||||
if (normalized === combo) {
|
||||
if (entry.options.preventDefault !== false) e.preventDefault();
|
||||
entry.handler(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Si la touche n'a pas matché et n'est pas un modifier, mémoriser comme début de chord
|
||||
if (
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
e.key.length === 1 &&
|
||||
!inEditable
|
||||
) {
|
||||
chordBuffer.current = {
|
||||
key: combo,
|
||||
expires: now + CHORD_WINDOW_MS,
|
||||
};
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ register, list }), [register, list]);
|
||||
|
||||
return (
|
||||
<ShortcutContext.Provider value={value}>
|
||||
{children}
|
||||
</ShortcutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour enregistrer un raccourci local au cycle de vie du composant.
|
||||
*
|
||||
* useShortcut("cmd+k", () => setOpen(true), {
|
||||
* description: "Ouvrir la palette",
|
||||
* category: "Navigation",
|
||||
* });
|
||||
*/
|
||||
export function useShortcut(
|
||||
keys: string,
|
||||
handler: ShortcutHandler,
|
||||
options: ShortcutOptions = {},
|
||||
) {
|
||||
const ctx = useContext(ShortcutContext);
|
||||
if (!ctx)
|
||||
throw new Error("useShortcut requiert un <ShortcutProvider> ancêtre.");
|
||||
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const id = `${keys}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return ctx.register(id, {
|
||||
keys,
|
||||
handler: (e) => handlerRef.current(e),
|
||||
options,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keys, options.disabled, options.allowInInput, options.preventDefault]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les raccourcis enregistrés (pour cheatsheet).
|
||||
*/
|
||||
export function useShortcutList(): ShortcutEntry[] {
|
||||
const ctx = useContext(ShortcutContext);
|
||||
if (!ctx) return [];
|
||||
const [, force] = useState(0);
|
||||
// Re-render léger — la liste peut bouger
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => force((n) => n + 1), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return ctx.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Affichage cheatsheet (ouvert via "?" par défaut).
|
||||
*/
|
||||
export function ShortcutCheatsheet({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const list = useShortcutList();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Group by category
|
||||
const groups = list.reduce<Record<string, ShortcutEntry[]>>((acc, e) => {
|
||||
(acc[e.category] ??= []).push(e);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mmg-modal-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Raccourcis clavier"
|
||||
className="mmg-modal"
|
||||
style={{ maxWidth: 640 }}
|
||||
>
|
||||
<div className="mmg-modal__header">
|
||||
<h3 className="mmg-modal__title">Raccourcis clavier</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="mmg-modal__close"
|
||||
aria-label="Fermer"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mmg-modal__body">
|
||||
{Object.keys(groups).length === 0 ? (
|
||||
<p style={{ color: "var(--mmg-color-text-tertiary)" }}>
|
||||
Aucun raccourci enregistré dans le contexte actuel.
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(groups).map(([cat, entries]) => (
|
||||
<div key={cat} style={{ marginBottom: "var(--mmg-space-5)" }}>
|
||||
<h4
|
||||
style={{
|
||||
fontSize: "var(--mmg-font-size-xs)",
|
||||
fontWeight: "var(--mmg-font-weight-bold)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--mmg-color-text-tertiary)",
|
||||
marginBottom: "var(--mmg-space-2)",
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</h4>
|
||||
<dl
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: "var(--mmg-space-2) var(--mmg-space-4)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{entries.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
display: "contents",
|
||||
}}
|
||||
>
|
||||
<dt
|
||||
style={{
|
||||
fontSize: "var(--mmg-font-size-sm)",
|
||||
color: "var(--mmg-color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{e.description || e.id}
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<ShortcutKeys keys={e.keys} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affichage joli des keys d'un raccourci (chunks <kbd>).
|
||||
*/
|
||||
export function ShortcutKeys({ keys }: { keys: string }) {
|
||||
const chords = keys.split(" ");
|
||||
return (
|
||||
<span style={{ display: "inline-flex", gap: 4 }}>
|
||||
{chords.map((chord, i) => (
|
||||
<span key={i} style={{ display: "inline-flex", gap: 2 }}>
|
||||
{chord.split("+").map((k, j) => (
|
||||
<kbd key={j} className="mmg-kbd">
|
||||
{k === "cmd" || k === "meta" ? (isMac ? "⌘" : "Ctrl") : k.toUpperCase()}
|
||||
</kbd>
|
||||
))}
|
||||
{i < chords.length - 1 && <span style={{ opacity: 0.5 }}>then</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SkipLinkProps = {
|
||||
targetId?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SkipLink({ targetId = "main", children = "Aller au contenu" }: SkipLinkProps) {
|
||||
return (
|
||||
<a className="mmg-skip-link" href={`#${targetId}`}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixSlider from "@radix-ui/react-slider";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type SliderProps = {
|
||||
/** Valeur(s) du slider. Tableau pour permettre les ranges (2 thumbs). */
|
||||
value: number[];
|
||||
onValueChange: (value: number[]) => void;
|
||||
/** Valeur min. Défaut 0. */
|
||||
min?: number;
|
||||
/** Valeur max. Défaut 100. */
|
||||
max?: number;
|
||||
/** Pas (granularité). Défaut 1. */
|
||||
step?: number;
|
||||
/** Désactivé. */
|
||||
disabled?: boolean;
|
||||
/** Label accessible (lu par les lecteurs d'écran). */
|
||||
label?: string;
|
||||
/** Si true, affiche la valeur au-dessus du thumb actif. */
|
||||
showValue?: boolean;
|
||||
/** Format de la valeur affichée. Défaut: identité. */
|
||||
formatValue?: (v: number) => string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slider — sélecteur de valeur (single ou range, Radix UI).
|
||||
*
|
||||
* - Clavier : flèches (step), Page Up/Down (×10 step), Home/End (min/max).
|
||||
* - aria-valuenow/min/max/text gérés par Radix.
|
||||
* - Pour un range, passer un tableau de 2 valeurs.
|
||||
*
|
||||
* Style DSMMG : track fin, thumb cerclé pill, accent au remplissage.
|
||||
* Focus visible : halo accent autour du thumb.
|
||||
*/
|
||||
export function Slider({
|
||||
value,
|
||||
onValueChange,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled,
|
||||
label,
|
||||
showValue,
|
||||
formatValue = (v) => `${v}`,
|
||||
className,
|
||||
}: SliderProps) {
|
||||
return (
|
||||
<RadixSlider.Root
|
||||
className={cx("mmg-slider", disabled && "mmg-slider--disabled", className)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
<RadixSlider.Track className="mmg-slider__track">
|
||||
<RadixSlider.Range className="mmg-slider__range" />
|
||||
</RadixSlider.Track>
|
||||
{value.map((v, i) => (
|
||||
<RadixSlider.Thumb
|
||||
key={i}
|
||||
className="mmg-slider__thumb"
|
||||
aria-label={label ? `${label} (${i + 1}/${value.length})` : undefined}
|
||||
>
|
||||
{showValue && (
|
||||
<span className="mmg-slider__value" aria-hidden>
|
||||
{formatValue(v)}
|
||||
</span>
|
||||
)}
|
||||
</RadixSlider.Thumb>
|
||||
))}
|
||||
</RadixSlider.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadixSlider as SliderPrimitive };
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Slot — pattern Radix UI / shadcn pour le polymorphisme `asChild`.
|
||||
*
|
||||
* Permet à un composant DSMMG de "passer" son comportement (props, ref,
|
||||
* className fusionnée) à son unique enfant React. Cas d'usage typique :
|
||||
*
|
||||
* <MmgButton asChild>
|
||||
* <Link to="/synapse">Aller à Synapse</Link>
|
||||
* </MmgButton>
|
||||
*
|
||||
* Ici, le `<Link>` reçoit toutes les props du Button (className, onClick,
|
||||
* aria-*, ref) tout en restant un vrai `<a>` côté DOM. Cela évite de
|
||||
* dupliquer un Button et un ButtonAsLink, et préserve la sémantique
|
||||
* native du tag enfant (RGAA 7.1, ARIA rule #1).
|
||||
*/
|
||||
|
||||
export type SlotProps = HTMLAttributes<HTMLElement> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fusionne deux className ; conserve l'ordre source → destination.
|
||||
* Pas de dépendance externe.
|
||||
*/
|
||||
function mergeClassNames(
|
||||
parent?: string,
|
||||
child?: string,
|
||||
): string | undefined {
|
||||
if (!parent && !child) return undefined;
|
||||
if (!parent) return child;
|
||||
if (!child) return parent;
|
||||
return `${parent} ${child}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne deux styles ; les styles enfants priment (override).
|
||||
*/
|
||||
function mergeStyles(
|
||||
parent?: CSSProperties,
|
||||
child?: CSSProperties,
|
||||
): CSSProperties | undefined {
|
||||
if (!parent && !child) return undefined;
|
||||
return { ...parent, ...child };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne deux event handlers ; appelle l'enfant en premier — si
|
||||
* `event.defaultPrevented` est vrai, on n'appelle pas le parent
|
||||
* (laisse l'enfant override le comportement).
|
||||
*/
|
||||
function composeEventHandlers<E extends { defaultPrevented?: boolean }>(
|
||||
parentHandler?: (event: E) => void,
|
||||
childHandler?: (event: E) => void,
|
||||
): ((event: E) => void) | undefined {
|
||||
if (!parentHandler && !childHandler) return undefined;
|
||||
return (event: E) => {
|
||||
childHandler?.(event);
|
||||
if (!event.defaultPrevented) parentHandler?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne refs (callback ou objet).
|
||||
*/
|
||||
function composeRefs<T>(...refs: Array<Ref<T> | undefined>): Ref<T> {
|
||||
return (node: T | null) => {
|
||||
refs.forEach((ref) => {
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref != null) (ref as React.MutableRefObject<T | null>).current = node;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const Slot = forwardRef<HTMLElement, SlotProps>(function Slot(
|
||||
{ children, ...slotProps },
|
||||
forwardedRef,
|
||||
) {
|
||||
if (!isValidElement(children)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childElement = Children.only(children) as ReactElement<
|
||||
HTMLAttributes<HTMLElement> & { ref?: Ref<HTMLElement> }
|
||||
>;
|
||||
const childProps = childElement.props;
|
||||
|
||||
const merged: Record<string, unknown> = { ...slotProps };
|
||||
|
||||
// className : slot puis enfant (l'enfant override visuellement)
|
||||
merged.className = mergeClassNames(slotProps.className, childProps.className);
|
||||
merged.style = mergeStyles(slotProps.style, childProps.style);
|
||||
|
||||
// Compose event handlers
|
||||
for (const key in slotProps) {
|
||||
if (key.startsWith("on") && typeof (slotProps as any)[key] === "function") {
|
||||
merged[key] = composeEventHandlers(
|
||||
(slotProps as any)[key],
|
||||
(childProps as any)[key],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ref combinée
|
||||
merged.ref = composeRefs(forwardedRef, (childElement as any).ref);
|
||||
|
||||
return cloneElement(childElement, merged as never);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper pour les composants qui acceptent `asChild` : retourne `Slot` si
|
||||
* `asChild` est vrai, sinon le tag par défaut.
|
||||
*/
|
||||
export function getSlotComponent<T extends keyof React.JSX.IntrinsicElements>(
|
||||
asChild: boolean | undefined,
|
||||
defaultTag: T,
|
||||
): T | typeof Slot {
|
||||
return asChild ? Slot : defaultTag;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type StatProps = {
|
||||
icon?: IconName;
|
||||
value: ReactNode;
|
||||
label: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Stat({ icon, value, label, className }: StatProps) {
|
||||
return (
|
||||
<div className={cx("mmg-stat", className)}>
|
||||
{icon && (
|
||||
<div className="mmg-stat__icon">
|
||||
<Icon name={icon} size="md" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="mmg-stat__value">{value}</div>
|
||||
<div className="mmg-stat__label">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type StepperProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
title: ReactNode;
|
||||
nextTitle?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Stepper({ current, total, title, nextTitle, className }: StepperProps) {
|
||||
return (
|
||||
<div className={cx("mmg-stepper", className)}>
|
||||
<div className="mmg-stepper__progress">
|
||||
Étape {current} sur {total}
|
||||
</div>
|
||||
<div className="mmg-stepper__bar" aria-hidden>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cx(
|
||||
"mmg-stepper__bar-item",
|
||||
i + 1 < current && "mmg-stepper__bar-item--done",
|
||||
i + 1 === current && "mmg-stepper__bar-item--current",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mmg-stepper__title">{title}</div>
|
||||
{nextTitle && current < total && (
|
||||
<div className="mmg-stepper__next">
|
||||
Étape suivante : <strong>{nextTitle}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type TabItem = { id: string; label: ReactNode };
|
||||
|
||||
export type TabsProps = {
|
||||
items: TabItem[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Tabs({ items, value, onChange, className }: TabsProps) {
|
||||
return (
|
||||
<div role="tablist" className={cx("mmg-tabs", className)}>
|
||||
{items.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={t.id === value}
|
||||
className="mmg-tabs__btn"
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type TagProps = HTMLAttributes<HTMLElement> & {
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
icon?: IconName;
|
||||
};
|
||||
|
||||
export function Tag({ selected, onSelect, children, className, icon, ...rest }: TagProps) {
|
||||
const Comp = onSelect ? "button" : "span";
|
||||
return (
|
||||
<Comp
|
||||
type={onSelect ? "button" : undefined}
|
||||
className={cx(
|
||||
"mmg-tag",
|
||||
onSelect && "mmg-tag--clickable",
|
||||
selected && "mmg-tag--selected",
|
||||
className,
|
||||
)}
|
||||
onClick={onSelect}
|
||||
aria-pressed={onSelect ? selected : undefined}
|
||||
{...(rest as object)}
|
||||
>
|
||||
{icon && <Icon name={icon} size="sm" />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type TextVariant =
|
||||
// Display (landing marketing — Hero produits)
|
||||
| "display-2xl"
|
||||
| "display-xl"
|
||||
| "display-lg"
|
||||
| "display-md"
|
||||
// Headlines (apps + pages)
|
||||
| "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||
// Body
|
||||
| "body-lg" | "body" | "body-sm" | "body-xs"
|
||||
// Auxiliaires
|
||||
| "eyebrow" | "lead" | "overline" | "caption";
|
||||
|
||||
export type TextElement = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
|
||||
|
||||
export type TextProps = HTMLAttributes<HTMLElement> & {
|
||||
/** Variante typographique. Détermine taille/poids/letter-spacing/line-height. */
|
||||
variant?: TextVariant;
|
||||
/** Tag HTML rendu. Choisir selon la sémantique, pas selon la taille. */
|
||||
as?: TextElement;
|
||||
/** Police mono (JetBrains Mono / SF Mono) pour code, IDs, valeurs tabulaires. */
|
||||
mono?: boolean;
|
||||
/** Numérique tabular (alignement vertical des chiffres). Pour stats / tableaux. */
|
||||
tabular?: boolean;
|
||||
/** Gradient accent → accent-strong sur le texte (modern Hero touch). */
|
||||
gradient?: boolean;
|
||||
/** Effet "rainbow" animé. À utiliser une seule fois par page max. */
|
||||
rainbow?: boolean;
|
||||
/** text-wrap: balance — équilibre les retours à la ligne (titres). */
|
||||
balance?: boolean;
|
||||
/** text-wrap: pretty — évite veuves/orphelines (body). */
|
||||
pretty?: boolean;
|
||||
/** Italique — pour titres d'œuvre, citations, mots-clefs sémantiquement à part. */
|
||||
italic?: boolean;
|
||||
/** Italique + couleur d'accent — emphase éditoriale (mot-clef ressortir). */
|
||||
emphasis?: boolean;
|
||||
/** Surligneur teinté accent — highlight ponctuel dans un body. */
|
||||
highlight?: boolean;
|
||||
/** Souligné épais accent — accent fort sur un mot-clef. */
|
||||
underline?: boolean;
|
||||
/** Barré — prix barré, élément déprécié. */
|
||||
strike?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text — composant typographique sémantique du DSMMG.
|
||||
*
|
||||
* **Choisir le tag (`as`) selon la SÉMANTIQUE, pas selon la taille.**
|
||||
* Un titre de page reste `<h1>` même si visuellement il s'affiche en
|
||||
* `display-xl`. Un sous-titre marketing décoratif peut être un `<p>`
|
||||
* stylé en `display-lg`.
|
||||
*
|
||||
* Usage typique sur une landing produit (HRTime, Synapse…) :
|
||||
*
|
||||
* <Text as="span" variant="eyebrow">SIRH simple</Text>
|
||||
* <Text as="h1" variant="display-xl" balance gradient>
|
||||
* Le temps de vos équipes, au cordeau.
|
||||
* </Text>
|
||||
* <Text as="p" variant="lead" pretty>
|
||||
* Plannings, congés, paie. Une seule plateforme, sans surprise.
|
||||
* </Text>
|
||||
*
|
||||
* Usage app (DataTable, dashboard) :
|
||||
*
|
||||
* <Text as="h2" variant="h3">Effectifs</Text>
|
||||
* <Text as="p" variant="body-sm">432 collaborateurs actifs.</Text>
|
||||
* <Text as="span" variant="overline">Section</Text>
|
||||
*/
|
||||
export function Text({
|
||||
variant = "body",
|
||||
as,
|
||||
mono,
|
||||
tabular,
|
||||
gradient,
|
||||
rainbow,
|
||||
balance,
|
||||
pretty,
|
||||
italic,
|
||||
emphasis,
|
||||
highlight,
|
||||
underline,
|
||||
strike,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: TextProps) {
|
||||
const Comp: ElementType =
|
||||
as ??
|
||||
(variant.startsWith("display") ? "h1" :
|
||||
variant === "h1" || variant === "h2" || variant === "h3" ||
|
||||
variant === "h4" || variant === "h5" || variant === "h6" ? variant :
|
||||
variant === "lead" ? "p" :
|
||||
"span");
|
||||
return (
|
||||
<Comp
|
||||
className={cx(
|
||||
`mmg-text-${variant}`,
|
||||
mono && "mmg-text--mono",
|
||||
tabular && "mmg-text--tabular",
|
||||
balance && "mmg-text--balance",
|
||||
pretty && "mmg-text--pretty",
|
||||
gradient && "mmg-text--gradient",
|
||||
rainbow && "mmg-text--rainbow",
|
||||
italic && "mmg-text--italic",
|
||||
emphasis && "mmg-text--emphasis",
|
||||
highlight && "mmg-text--highlight",
|
||||
underline && "mmg-text--underline",
|
||||
strike && "mmg-text--strike",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
/* — Helpers raccourcis pour les variants les plus utilisés ———————————— */
|
||||
|
||||
export function Display(props: Omit<TextProps, "variant"> & { size?: "md" | "lg" | "xl" | "2xl" }) {
|
||||
const { size = "xl", ...rest } = props;
|
||||
return <Text variant={`display-${size}` as TextVariant} balance {...rest} />;
|
||||
}
|
||||
|
||||
export function Eyebrow(props: Omit<TextProps, "variant" | "as">) {
|
||||
return <Text as="span" variant="eyebrow" {...props} />;
|
||||
}
|
||||
|
||||
export function Lead(props: Omit<TextProps, "variant" | "as">) {
|
||||
return <Text as="p" variant="lead" pretty {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { axe } from "vitest-axe";
|
||||
import { ThemePicker } from "./ThemePicker";
|
||||
import { ACCENT_PRESETS } from "./useAccent";
|
||||
|
||||
describe("ThemePicker", () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute("data-mmg-accent");
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("rend une option par preset", () => {
|
||||
render(<ThemePicker />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios).toHaveLength(ACCENT_PRESETS.length);
|
||||
});
|
||||
|
||||
it("synapse est sélectionné par défaut", () => {
|
||||
render(<ThemePicker />);
|
||||
const synapse = screen.getByRole("radio", { name: /synapse/i });
|
||||
expect(synapse).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("clic sur un preset l'active", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemePicker />);
|
||||
const blue = screen.getByRole("radio", { name: /^Bleu$/i });
|
||||
await user.click(blue);
|
||||
expect(blue).toHaveAttribute("aria-checked", "true");
|
||||
expect(document.documentElement.getAttribute("data-mmg-accent")).toBe("blue");
|
||||
expect(window.localStorage.getItem("mmg-accent")).toBe("blue");
|
||||
});
|
||||
|
||||
it("navigation flèche droite passe au preset suivant", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemePicker />);
|
||||
const synapse = screen.getByRole("radio", { name: /synapse/i });
|
||||
synapse.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
const rose = screen.getByRole("radio", { name: /^Rose vif$/i });
|
||||
expect(rose).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("navigation flèche gauche revient au précédent (avec wrap)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemePicker />);
|
||||
const synapse = screen.getByRole("radio", { name: /synapse/i });
|
||||
synapse.focus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
// Wrap : passe au dernier (slate)
|
||||
const slate = screen.getByRole("radio", { name: /ardoise/i });
|
||||
expect(slate).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("Home / End naviguent aux extrêmes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemePicker />);
|
||||
screen.getByRole("radio", { name: /synapse/i }).focus();
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("radio", { name: /ardoise/i })).toHaveAttribute("aria-checked", "true");
|
||||
await user.keyboard("{Home}");
|
||||
expect(screen.getByRole("radio", { name: /synapse/i })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("expose un radiogroup labelisé", () => {
|
||||
render(<ThemePicker />);
|
||||
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("n'a pas de violations axe-core", async () => {
|
||||
const { container } = render(<ThemePicker />);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon } from "./Icon";
|
||||
import { ACCENT_PRESETS, useAccent, type AccentName } from "./useAccent";
|
||||
|
||||
export type { AccentName };
|
||||
|
||||
export type ThemePickerProps = {
|
||||
/** Légende lue par les lecteurs d'écran. Défaut : "Couleur d'accent". */
|
||||
legend?: string;
|
||||
/** Cache le bouton de réinitialisation au défaut Synapse. */
|
||||
hideReset?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ThemePicker — sélecteur de couleur d'accent utilisateur.
|
||||
*
|
||||
* Pattern radiogroup : roving tabindex, navigation flèches gauche/droite,
|
||||
* sélection à Espace ou Entrée. Persiste l'accent choisi dans localStorage.
|
||||
*
|
||||
* Accessibilité :
|
||||
* - role="radiogroup" + aria-labelledby
|
||||
* - chaque pastille : role="radio" + aria-checked
|
||||
* - focus-visible géré, contraste validé sur fonds light & dark
|
||||
* - le label texte ("Rose Synapse", "Bleu", …) est accessible aux lecteurs
|
||||
* d'écran via aria-label, pas seulement la couleur (RGAA 9 — couleur seule)
|
||||
*/
|
||||
export function ThemePicker({ legend = "Couleur d'accent", hideReset, className }: ThemePickerProps) {
|
||||
const { accent, setAccent, reset } = useAccent();
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const headingId = "mmg-theme-picker-heading";
|
||||
|
||||
const focusIndex = (i: number) => {
|
||||
const next = (i + ACCENT_PRESETS.length) % ACCENT_PRESETS.length;
|
||||
refs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusIndex(idx + 1);
|
||||
setAccent(ACCENT_PRESETS[(idx + 1) % ACCENT_PRESETS.length].name);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusIndex(idx - 1);
|
||||
setAccent(ACCENT_PRESETS[(idx - 1 + ACCENT_PRESETS.length) % ACCENT_PRESETS.length].name);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusIndex(0);
|
||||
setAccent(ACCENT_PRESETS[0].name);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusIndex(ACCENT_PRESETS.length - 1);
|
||||
setAccent(ACCENT_PRESETS[ACCENT_PRESETS.length - 1].name);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx("mmg-theme-picker", className)}>
|
||||
<div id={headingId} className="mmg-theme-picker__legend">
|
||||
{legend}
|
||||
</div>
|
||||
<div role="radiogroup" aria-labelledby={headingId} className="mmg-theme-picker__group">
|
||||
{ACCENT_PRESETS.map((preset, i) => {
|
||||
const checked = preset.name === accent;
|
||||
return (
|
||||
<button
|
||||
key={preset.name}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={preset.label}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
className={cx("mmg-theme-picker__swatch", checked && "mmg-theme-picker__swatch--selected")}
|
||||
style={{ "--mmg-swatch-color": preset.sample } as Record<string, string>}
|
||||
onClick={() => setAccent(preset.name)}
|
||||
onKeyDown={(e) => onKey(e, i)}
|
||||
>
|
||||
<span className="mmg-theme-picker__swatch-color" aria-hidden />
|
||||
{checked && (
|
||||
<span className="mmg-theme-picker__swatch-check" aria-hidden>
|
||||
<Icon name="check-line" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className="mmg-u-sr-only">{preset.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!hideReset && accent !== "synapse" && (
|
||||
<button type="button" className="mmg-theme-picker__reset" onClick={reset}>
|
||||
Réinitialiser au défaut
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
type Severity = "info" | "success" | "warning" | "danger";
|
||||
|
||||
export type ToastInput = {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
severity?: Severity;
|
||||
/** Durée avant auto-dismiss (ms). 0 = persistant. Par défaut 5000. */
|
||||
duration?: number;
|
||||
/** Action optionnelle dans le toast. */
|
||||
action?: { label: ReactNode; onClick: () => void };
|
||||
};
|
||||
|
||||
type ToastInternal = ToastInput & { id: string; createdAt: number };
|
||||
|
||||
type Ctx = {
|
||||
toast: (t: ToastInput) => string;
|
||||
dismiss: (id: string) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<Ctx | null>(null);
|
||||
|
||||
const SEV_ICON: Record<Severity, IconName> = {
|
||||
info: "information-fill",
|
||||
success: "checkbox-circle-fill",
|
||||
warning: "alert-fill",
|
||||
danger: "error-warning-fill",
|
||||
};
|
||||
|
||||
export type ToastProviderProps = {
|
||||
children: ReactNode;
|
||||
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
||||
/** Nombre max conservé en mémoire avant écrêtage du plus ancien. Défaut 5. */
|
||||
max?: number;
|
||||
/** Nombre visible avant collapse en pile (Sonner-style). Défaut 3. */
|
||||
visibleCount?: number;
|
||||
/** Durée par défaut (ms) avant auto-dismiss si non précisé. */
|
||||
defaultDuration?: number;
|
||||
/** Gap entre toasts en mode expanded (px). Défaut 12. */
|
||||
gap?: number;
|
||||
/** Offset entre toasts en pile collapsée (px par cran). Défaut 14. */
|
||||
stackOffset?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GAP = 12;
|
||||
const DEFAULT_STACK_OFFSET = 14;
|
||||
const SCALE_PER_INDEX = 0.045;
|
||||
|
||||
/**
|
||||
* ToastProvider — système de notifications empilables (Sonner-style).
|
||||
*
|
||||
* Architecture :
|
||||
* - Chaque toast est en `position: absolute`, ancré bottom: 0 (positions
|
||||
* bottom-*) ou top: 0 (positions top-*).
|
||||
* - Les heights sont mesurées via ResizeObserver et exposées en CSS vars.
|
||||
* - Au repos : translateY = -stackOffset × index (collapsed-y),
|
||||
* scale = 1 - 0.045 × index. Front-most reste pleine taille.
|
||||
* - Au hover/focus : translateY = -(somme des heights précédentes + gap × i)
|
||||
* (expanded-y), scale = 1.
|
||||
* - La hauteur du region s'ajuste : front-most height (collapsed) ou
|
||||
* somme totale (expanded), pour ne pas voler de clics au contenu.
|
||||
*
|
||||
* A11y :
|
||||
* - aria-live="polite" — les toasts s'annoncent sans interrompre.
|
||||
* - Severity "danger" → role="alert" (urgent), sinon role="status".
|
||||
* - Bouton fermer focusable, label complet ("Fermer la notification").
|
||||
* - Pause des timers au hover/focus, reprise au mouseleave/blur.
|
||||
*/
|
||||
export function ToastProvider({
|
||||
children,
|
||||
position = "bottom-right",
|
||||
max = 5,
|
||||
visibleCount = 3,
|
||||
defaultDuration = 5000,
|
||||
gap = DEFAULT_GAP,
|
||||
stackOffset = DEFAULT_STACK_OFFSET,
|
||||
}: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<ToastInternal[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const timers = useRef(new Map<string, number>());
|
||||
const remainingTimes = useRef(new Map<string, number>());
|
||||
const pausedRef = useRef(false);
|
||||
const itemRefs = useRef(new Map<string, HTMLLIElement>());
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const isTop = position.startsWith("top");
|
||||
|
||||
// — Lifecycle ————————————————————————————————————————
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
remainingTimes.current.delete(id);
|
||||
itemRefs.current.delete(id);
|
||||
setHeights((h) => {
|
||||
if (!(id in h)) return h;
|
||||
const next = { ...h };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(id: string, duration: number) => {
|
||||
if (duration <= 0) return;
|
||||
const t = window.setTimeout(() => dismiss(id), duration);
|
||||
timers.current.set(id, t);
|
||||
remainingTimes.current.set(id, duration);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const toast = useCallback(
|
||||
(input: ToastInput) => {
|
||||
const id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const duration = input.duration ?? defaultDuration;
|
||||
const item: ToastInternal = { ...input, id, createdAt: Date.now() };
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, item];
|
||||
return next.length > max ? next.slice(-max) : next;
|
||||
});
|
||||
// Si la pile est déjà hover/focused, on enchaîne en pause aussi.
|
||||
if (!pausedRef.current) scheduleDismiss(id, duration);
|
||||
else remainingTimes.current.set(id, duration);
|
||||
return id;
|
||||
},
|
||||
[defaultDuration, max, scheduleDismiss],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
timers.current.forEach((t) => window.clearTimeout(t));
|
||||
timers.current.clear();
|
||||
remainingTimes.current.clear();
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
const pauseAll = useCallback(() => {
|
||||
if (pausedRef.current) return;
|
||||
pausedRef.current = true;
|
||||
for (const [id, timerId] of timers.current.entries()) {
|
||||
window.clearTimeout(timerId);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
const resumeAll = useCallback(() => {
|
||||
if (!pausedRef.current) return;
|
||||
pausedRef.current = false;
|
||||
for (const [id, remaining] of remainingTimes.current.entries()) {
|
||||
const t = window.setTimeout(() => dismiss(id), remaining);
|
||||
timers.current.set(id, t);
|
||||
}
|
||||
}, [dismiss]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
timers.current.forEach((t) => window.clearTimeout(t));
|
||||
observerRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// — Mesure des hauteurs via ResizeObserver ————————————
|
||||
// Indispensable pour calculer l'expanded-y précisément (somme des heights
|
||||
// précédentes + gap × index). Évite tout layout shift visible.
|
||||
useLayoutEffect(() => {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
let mutated = false;
|
||||
const updates: Record<string, number> = {};
|
||||
for (const entry of entries) {
|
||||
const el = entry.target as HTMLLIElement;
|
||||
const id = el.dataset.toastId;
|
||||
if (!id) continue;
|
||||
const h = el.offsetHeight;
|
||||
updates[id] = h;
|
||||
mutated = true;
|
||||
}
|
||||
if (mutated) {
|
||||
setHeights((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
for (const [id, h] of Object.entries(updates)) {
|
||||
if (next[id] !== h) {
|
||||
next[id] = h;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
});
|
||||
observerRef.current = ro;
|
||||
for (const el of itemRefs.current.values()) ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Ré-observer lors de l'ajout/retrait
|
||||
useLayoutEffect(() => {
|
||||
const ro = observerRef.current;
|
||||
if (!ro) return;
|
||||
for (const el of itemRefs.current.values()) ro.observe(el);
|
||||
}, [toasts.length]);
|
||||
|
||||
// Setter de ref qui (ré-)observe le node
|
||||
const setItemRef = useCallback(
|
||||
(id: string) => (el: HTMLLIElement | null) => {
|
||||
const ro = observerRef.current;
|
||||
if (el) {
|
||||
itemRefs.current.set(id, el);
|
||||
ro?.observe(el);
|
||||
// Capture immédiate pour éviter un flash au premier render
|
||||
const h = el.offsetHeight;
|
||||
if (h > 0) {
|
||||
setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
|
||||
}
|
||||
} else {
|
||||
const old = itemRefs.current.get(id);
|
||||
if (old && ro) ro.unobserve(old);
|
||||
itemRefs.current.delete(id);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// — Ordre + offsets calculés ————————————————————————
|
||||
// ordered[i].stackIndex : 0 = front-most (le plus récent).
|
||||
// Pour bottom-* : DOM order = ancien → récent (column-reverse côté CSS
|
||||
// n'est plus utilisé maintenant qu'on est en absolute). Le récent est
|
||||
// au-dessus visuellement → stackIndex 0 = dernier ajouté.
|
||||
const ordered = useMemo(() => {
|
||||
const list = toasts.map((t, i) => ({
|
||||
...t,
|
||||
stackIndex: toasts.length - 1 - i,
|
||||
}));
|
||||
// Les offsets dépendent des heights (mesurés) :
|
||||
// - collapsedOffset = stackOffset × stackIndex (signe selon position)
|
||||
// - expandedOffset = somme des heights des toasts plus récents (stackIndex < self.stackIndex) + gap × count
|
||||
return list.map((t) => {
|
||||
const collapsed = stackOffset * t.stackIndex;
|
||||
let expanded = 0;
|
||||
for (let j = 0; j < t.stackIndex; j++) {
|
||||
const sibling = list.find((o) => o.stackIndex === j);
|
||||
if (!sibling) continue;
|
||||
const h = heights[sibling.id] ?? 64; // fallback raisonnable avant mesure
|
||||
expanded += h + gap;
|
||||
}
|
||||
return { ...t, collapsed, expanded };
|
||||
});
|
||||
}, [toasts, heights, gap, stackOffset]);
|
||||
|
||||
// Hauteur du region : front-most en collapsed, somme en expanded.
|
||||
const regionHeight = useMemo(() => {
|
||||
if (toasts.length === 0) return 0;
|
||||
if (expanded) {
|
||||
return ordered.reduce((sum, t) => sum + (heights[t.id] ?? 64), 0) + gap * (toasts.length - 1);
|
||||
}
|
||||
const front = ordered.find((t) => t.stackIndex === 0);
|
||||
return heights[front?.id ?? ""] ?? 64;
|
||||
}, [ordered, heights, gap, expanded, toasts.length]);
|
||||
|
||||
const ctx = useMemo<Ctx>(() => ({ toast, dismiss, clear }), [toast, dismiss, clear]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
<ol
|
||||
className={cx("mmg-toast-region", `mmg-toast-region--${position}`)}
|
||||
data-position={position}
|
||||
data-expanded={expanded || undefined}
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
aria-label="Notifications"
|
||||
style={{ height: regionHeight ? `${regionHeight}px` : undefined }}
|
||||
onMouseEnter={() => {
|
||||
setExpanded(true);
|
||||
pauseAll();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setExpanded(false);
|
||||
resumeAll();
|
||||
}}
|
||||
onFocus={() => {
|
||||
setExpanded(true);
|
||||
pauseAll();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setExpanded(false);
|
||||
resumeAll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ordered.map((t) => {
|
||||
const isHidden = !expanded && t.stackIndex >= visibleCount;
|
||||
// Signes selon position : bottom-* → translateY négatif (vers haut),
|
||||
// top-* → translateY positif (vers bas).
|
||||
const sign = isTop ? 1 : -1;
|
||||
const collapsedY = sign * t.collapsed;
|
||||
const expandedY = sign * t.expanded;
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
ref={setItemRef(t.id)}
|
||||
data-toast-id={t.id}
|
||||
data-severity={t.severity ?? "info"}
|
||||
role={t.severity === "danger" ? "alert" : "status"}
|
||||
aria-hidden={isHidden ? true : undefined}
|
||||
className="mmg-toast"
|
||||
data-stack-index={t.stackIndex}
|
||||
data-front={t.stackIndex === 0 || undefined}
|
||||
style={
|
||||
{
|
||||
"--mmg-toast-collapsed-y": `${collapsedY}px`,
|
||||
"--mmg-toast-expanded-y": `${expandedY}px`,
|
||||
"--mmg-toast-scale": `${1 - SCALE_PER_INDEX * t.stackIndex}`,
|
||||
} as Record<string, string>
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={SEV_ICON[t.severity ?? "info"]}
|
||||
className="mmg-toast__icon"
|
||||
size="sm"
|
||||
style={{
|
||||
color:
|
||||
t.severity === "success"
|
||||
? "var(--mmg-color-success)"
|
||||
: t.severity === "warning"
|
||||
? "var(--mmg-color-warning)"
|
||||
: t.severity === "danger"
|
||||
? "var(--mmg-color-danger)"
|
||||
: "var(--mmg-color-info)",
|
||||
}}
|
||||
/>
|
||||
<div className="mmg-toast__body">
|
||||
<div className="mmg-toast__title">{t.title}</div>
|
||||
{t.description && <div className="mmg-toast__desc">{t.description}</div>}
|
||||
{t.action && (
|
||||
<button
|
||||
type="button"
|
||||
className="mmg-toast__action"
|
||||
onClick={() => {
|
||||
t.action!.onClick();
|
||||
dismiss(t.id);
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mmg-toast__close"
|
||||
aria-label="Fermer la notification"
|
||||
onClick={() => dismiss(t.id)}
|
||||
>
|
||||
<Icon name="close-line" size="sm" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook d'accès au système de toasts. Doit être utilisé sous un ToastProvider. */
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useToast doit être utilisé dans un <ToastProvider>. Wrappe ton App.",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type ReactNode } from "react";
|
||||
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
|
||||
import { cx } from "./utils";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
export type ToggleGroupItem = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
icon?: IconName;
|
||||
/** Affiche uniquement l'icône (label devient aria-label). */
|
||||
iconOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type ToggleGroupCommonProps = {
|
||||
items: ToggleGroupItem[];
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "outline" | "solid";
|
||||
disabled?: boolean;
|
||||
/** Label accessible du groupe. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ToggleGroupSingleProps = ToggleGroupCommonProps & {
|
||||
type: "single";
|
||||
value: string | undefined;
|
||||
onValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export type ToggleGroupMultipleProps = ToggleGroupCommonProps & {
|
||||
type: "multiple";
|
||||
value: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
|
||||
|
||||
/**
|
||||
* ToggleGroup — groupe de boutons toggle (single ou multiple).
|
||||
*
|
||||
* Différence avec SegmentedControl : ToggleGroup peut être en mode multiple
|
||||
* (plusieurs boutons actifs simultanément) et ne demande pas de label
|
||||
* obligatoire au-dessus.
|
||||
*
|
||||
* Wrapper Radix : roving tabindex, navigation flèches, aria-pressed.
|
||||
*/
|
||||
export function ToggleGroup(props: ToggleGroupProps) {
|
||||
const { items, size = "md", variant = "outline", disabled, ariaLabel, className } = props;
|
||||
const root = (
|
||||
<RadixToggleGroup.Root
|
||||
type={props.type as "single"}
|
||||
value={props.value as string}
|
||||
onValueChange={props.onValueChange as (v: string) => void}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
className={cx(
|
||||
"mmg-toggle-group",
|
||||
`mmg-toggle-group--${size}`,
|
||||
`mmg-toggle-group--${variant}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<RadixToggleGroup.Item
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
disabled={item.disabled}
|
||||
aria-label={item.iconOnly && typeof item.label === "string" ? item.label : undefined}
|
||||
className="mmg-toggle-group__item"
|
||||
>
|
||||
{item.icon && <Icon name={item.icon} size={size === "lg" ? "md" : "sm"} />}
|
||||
{!item.iconOnly && <span>{item.label}</span>}
|
||||
</RadixToggleGroup.Item>
|
||||
))}
|
||||
</RadixToggleGroup.Root>
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
export { RadixToggleGroup as ToggleGroupPrimitive };
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ReactNode, type ComponentProps } from "react";
|
||||
import * as RadixTooltip from "@radix-ui/react-tooltip";
|
||||
import { cx } from "./utils";
|
||||
|
||||
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export type TooltipProps = {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
placement?: TooltipPlacement;
|
||||
/** Délai avant apparition (ms). */
|
||||
delay?: number;
|
||||
/** Décalage par rapport à la cible (px). Défaut 8. */
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tooltip — wrapper Radix.
|
||||
*
|
||||
* - Positionnement intelligent (auto-flip si pas de place) via Floating UI.
|
||||
* - Focus / hover / Escape gérés par Radix.
|
||||
* - Respecte prefers-reduced-motion.
|
||||
* - Pour usage avancé : utiliser TooltipPrimitive.* directement.
|
||||
*
|
||||
* Exige un <TooltipProvider> à la racine de l'app (déjà inclus dans
|
||||
* AppShell). Sinon wrap localement avec <Tooltip.Provider>.
|
||||
*/
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
placement = "top",
|
||||
delay = 200,
|
||||
sideOffset = 8,
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
return (
|
||||
<RadixTooltip.Root delayDuration={delay}>
|
||||
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
|
||||
<RadixTooltip.Portal>
|
||||
<RadixTooltip.Content
|
||||
side={placement}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
className={cx("mmg-tooltip", className)}
|
||||
>
|
||||
{content}
|
||||
<RadixTooltip.Arrow className="mmg-tooltip__arrow" />
|
||||
</RadixTooltip.Content>
|
||||
</RadixTooltip.Portal>
|
||||
</RadixTooltip.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export type TooltipProviderProps = ComponentProps<typeof RadixTooltip.Provider>;
|
||||
|
||||
/**
|
||||
* Provider à mettre une fois à la racine de l'app pour partager les délais
|
||||
* et la file d'attente des tooltips (skipDelayDuration).
|
||||
*/
|
||||
export function TooltipProvider(props: TooltipProviderProps) {
|
||||
return <RadixTooltip.Provider delayDuration={200} skipDelayDuration={300} {...props} />;
|
||||
}
|
||||
|
||||
/** Sous-primitives Radix réexportées pour usage avancé (compound). */
|
||||
export { RadixTooltip as TooltipPrimitive };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user