62317f2ad7
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>
242 lines
6.8 KiB
TypeScript
242 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|