Files
DSMMG/packages/react/src/DatePicker.tsx
T
Dinawo 62317f2ad7
Release / Release / open changeset PR (push) Has been cancelled
CI / Build, typecheck, test, a11y (push) Has been cancelled
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>
2026-05-04 22:08:38 +02:00

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>
);
}