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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user