feat(ui): DatePicker brandé Rubis (remplace input type=date)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 20s
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:
parent
eb248c98b8
commit
06dcf38fee
@ -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>
|
||||
)}
|
||||
|
||||
249
apps/web/src/components/ui/DatePicker.tsx
Normal file
249
apps/web/src/components/ui/DatePicker.tsx
Normal 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'hui
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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")}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user