feat(ui): DatePicker brandé Rubis (remplace input type=date)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 20s

Le natif <input type="date"> affiche le calendrier moche du navigateur
(différent par client OS, pas d'alignement palette). On le remplace par
un composant Radix Popover + grille mensuelle Tailwind aux couleurs
Rubis.

Composant : apps/web/src/components/ui/DatePicker.tsx
- Trigger : bouton style Input (border line, focus rubis-glow,
  data-[state=open]:border-rubis pour hint visuel)
- Popover Radix : focus trap, Escape, click outside, animation
- Grille 7×6 (semaine commence lundi, locale FR via date-fns)
- Sélection : bg-rubis text-white + ombre rubis
- Today : ring-inset rubis-glow
- Hover : bg-cream
- Raccourci "Aujourd'hui" en footer

API alignée avec l'usage existant :
- value: string ISO | Date | null
- onChange(date: Date) — l'appelant fait .toISOString() comme avant

Usages migrés :
- ManualInvoiceDialog.tsx : Date d'émission
- factures_.import_.$batchId.tsx : Date d'échéance (avec préservation
  du className aria-invalid pour les low-confidence OCR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 23:01:31 +02:00
parent eb248c98b8
commit 06dcf38fee
3 changed files with 258 additions and 12 deletions

View File

@ -18,6 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/Dialog";
import { DatePicker } from "@/components/ui/DatePicker";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import {
@ -238,14 +239,10 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP
<form.Field name="issueDate">
{(field) => (
<Field label="Date d'émission" htmlFor={field.name}>
<Input
<DatePicker
id={field.name}
type="date"
value={field.state.value.slice(0, 10)}
onChange={(e) => {
const d = new Date(e.target.value);
field.handleChange(d.toISOString());
}}
value={field.state.value}
onChange={(d) => field.handleChange(d.toISOString())}
/>
</Field>
)}

View File

@ -0,0 +1,249 @@
import * as React from "react";
import * as Popover from "@radix-ui/react-popover";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
isToday,
parseISO,
startOfMonth,
startOfWeek,
subMonths,
} from "date-fns";
import { fr } from "date-fns/locale";
import { cn } from "@/lib/utils";
/**
* DatePicker brandé Rubis alternative au natif `<input type="date">` qui
* affiche le calendrier moche du navigateur. Utilise Radix Popover pour le
* positionnement et l'accessibilité (focus trap, Escape, click outside).
*
* Palette : trigger en `Input`-style (border line, focus rubis), grille avec
* jour sélectionné `bg-rubis text-white` + ombre rubis, today `ring-rubis-glow`.
*
* I/O :
* - `value` accepte string ISO (cf. usage existant Tanstack Form) ou `Date`.
* - `onChange(date: Date)` rend toujours un Date que l'appelant convertit
* via `.toISOString()` ou autre format selon son champ.
*/
export type DatePickerProps = {
value?: string | Date | null;
onChange: (date: Date) => void;
placeholder?: string;
id?: string;
className?: string;
disabled?: boolean;
"aria-invalid"?: boolean;
};
function toDate(value: DatePickerProps["value"]): Date | null {
if (!value) return null;
if (value instanceof Date) return value;
try {
const d = parseISO(value);
return Number.isNaN(d.getTime()) ? null : d;
} catch {
return null;
}
}
export function DatePicker({
value,
onChange,
placeholder = "Choisir une date",
id,
className,
disabled,
"aria-invalid": ariaInvalid,
}: DatePickerProps) {
const date = toDate(value);
const [open, setOpen] = React.useState(false);
const [viewMonth, setViewMonth] = React.useState<Date>(date ?? new Date());
// Re-sync viewMonth quand la valeur externe change (reset de form, etc.)
React.useEffect(() => {
if (date) setViewMonth(date);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [date?.getTime()]);
const handleSelect = (d: Date) => {
onChange(d);
setOpen(false);
};
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
id={id}
type="button"
disabled={disabled}
aria-invalid={ariaInvalid}
className={cn(
// Base — alignée avec Input.tsx pour cohérence visuelle
"flex w-full items-center justify-between gap-2 rounded-default border border-line bg-white px-3.5 py-3 text-left",
"font-sans text-base lg:text-[15px]",
// Transitions
"transition-[border-color,box-shadow] duration-150",
// Focus
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
// Open state — petit hint visuel
"data-[state=open]:border-rubis data-[state=open]:ring-4 data-[state=open]:ring-rubis-glow",
// États
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",
"aria-[invalid=true]:border-rubis-deep aria-[invalid=true]:bg-rubis-glow/30",
className,
)}
>
<span className={cn(date ? "text-ink" : "text-ink-3")}>
{date
? format(date, "d MMMM yyyy", { locale: fr })
: placeholder}
</span>
<CalendarIcon className="h-4 w-4 shrink-0 text-ink-3" aria-hidden />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
className={cn(
"z-50 w-[20rem] rounded-default border border-line bg-white p-3",
"shadow-[0_8px_24px_rgba(26,20,16,0.10)]",
// Animation discrète
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
)}
>
<CalendarGrid
viewMonth={viewMonth}
selectedDate={date}
onSelect={handleSelect}
onMonthChange={setViewMonth}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
// ---------------------------------------------------------------------------
// Grille interne — header (chevrons + mois courant) + 7×6 jours + raccourci
// "Aujourd'hui". Semaine commence lundi (FR).
// ---------------------------------------------------------------------------
type CalendarGridProps = {
viewMonth: Date;
selectedDate: Date | null;
onSelect: (date: Date) => void;
onMonthChange: (date: Date) => void;
};
function CalendarGrid({
viewMonth,
selectedDate,
onSelect,
onMonthChange,
}: CalendarGridProps) {
const monthStart = startOfMonth(viewMonth);
const monthEnd = endOfMonth(viewMonth);
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Lundi
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
const weekDays = ["L", "M", "M", "J", "V", "S", "D"];
return (
<div>
{/* Header mois — chevrons + label */}
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() => onMonthChange(subMonths(viewMonth, 1))}
className="rounded-default p-1.5 text-ink-2 transition-colors hover:bg-cream hover:text-rubis focus:outline-none focus:ring-2 focus:ring-rubis-glow"
aria-label="Mois précédent"
>
<ChevronLeft className="h-4 w-4" />
</button>
<div className="text-sm font-semibold capitalize text-ink">
{format(viewMonth, "MMMM yyyy", { locale: fr })}
</div>
<button
type="button"
onClick={() => onMonthChange(addMonths(viewMonth, 1))}
className="rounded-default p-1.5 text-ink-2 transition-colors hover:bg-cream hover:text-rubis focus:outline-none focus:ring-2 focus:ring-rubis-glow"
aria-label="Mois suivant"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Labels jours (L M M J V S D) */}
<div className="mb-2 grid grid-cols-7">
{weekDays.map((d, i) => (
<div
key={i}
className="text-center text-[11px] font-semibold uppercase tracking-wider text-ink-3"
>
{d}
</div>
))}
</div>
{/* Grille 7×6 jours */}
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const isOutside = !isSameMonth(day, viewMonth);
const isSelected = selectedDate ? isSameDay(day, selectedDate) : false;
const isCurrent = isToday(day);
return (
<button
key={day.toISOString()}
type="button"
onClick={() => onSelect(day)}
className={cn(
"aspect-square w-full rounded-default text-sm font-medium tabular-nums",
"transition-colors duration-100",
"focus:outline-none focus:ring-2 focus:ring-rubis-glow",
// Jour hors mois courant — discret
isOutside && !isSelected && "text-ink-3/50",
// Jour normal du mois — hover crème
!isOutside && !isSelected && "text-ink hover:bg-cream",
// Today (non sélectionné) — petit ring rubis-glow pour repère
isCurrent && !isSelected && "ring-1 ring-inset ring-rubis-glow",
// Sélectionné — bg rubis + glow
isSelected &&
"bg-rubis text-white shadow-[0_2px_8px_rgba(159,18,57,0.30)]",
)}
aria-pressed={isSelected || undefined}
aria-current={isCurrent ? "date" : undefined}
>
{format(day, "d")}
</button>
);
})}
</div>
{/* Raccourci "Aujourd'hui" */}
<div className="mt-3 border-t border-line pt-3">
<button
type="button"
onClick={() => onSelect(new Date())}
className="w-full rounded-default py-1.5 text-sm font-semibold text-rubis transition-colors hover:bg-rubis-glow/40 hover:text-rubis-deep focus:outline-none focus:ring-2 focus:ring-rubis-glow"
>
Aujourd&apos;hui
</button>
</div>
</div>
);
}

View File

@ -26,6 +26,7 @@ import { formatEuros } from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DatePicker } from "@/components/ui/DatePicker";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
@ -358,13 +359,12 @@ function ImportReviewPage() {
</Field>
<Field label="Date d'échéance" htmlFor="dueDate">
<Input
<DatePicker
id="dueDate"
type="date"
value={draft.dueDate.slice(0, 10)}
onChange={(e) =>
value={draft.dueDate}
onChange={(d) =>
update({
dueDate: new Date(e.target.value).toISOString(),
dueDate: d.toISOString(),
})
}
className={cn(isLowConfidence("dueDate") && "border-rubis")}