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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/Dialog";
|
} from "@/components/ui/Dialog";
|
||||||
|
import { DatePicker } from "@/components/ui/DatePicker";
|
||||||
import { Field } from "@/components/ui/Field";
|
import { Field } from "@/components/ui/Field";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import {
|
import {
|
||||||
@ -238,14 +239,10 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP
|
|||||||
<form.Field name="issueDate">
|
<form.Field name="issueDate">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field label="Date d'émission" htmlFor={field.name}>
|
<Field label="Date d'émission" htmlFor={field.name}>
|
||||||
<Input
|
<DatePicker
|
||||||
id={field.name}
|
id={field.name}
|
||||||
type="date"
|
value={field.state.value}
|
||||||
value={field.state.value.slice(0, 10)}
|
onChange={(d) => field.handleChange(d.toISOString())}
|
||||||
onChange={(e) => {
|
|
||||||
const d = new Date(e.target.value);
|
|
||||||
field.handleChange(d.toISOString());
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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 { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { DatePicker } from "@/components/ui/DatePicker";
|
||||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
import { Field } from "@/components/ui/Field";
|
import { Field } from "@/components/ui/Field";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
@ -358,13 +359,12 @@ function ImportReviewPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Date d'échéance" htmlFor="dueDate">
|
<Field label="Date d'échéance" htmlFor="dueDate">
|
||||||
<Input
|
<DatePicker
|
||||||
id="dueDate"
|
id="dueDate"
|
||||||
type="date"
|
value={draft.dueDate}
|
||||||
value={draft.dueDate.slice(0, 10)}
|
onChange={(d) =>
|
||||||
onChange={(e) =>
|
|
||||||
update({
|
update({
|
||||||
dueDate: new Date(e.target.value).toISOString(),
|
dueDate: d.toISOString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(isLowConfidence("dueDate") && "border-rubis")}
|
className={cn(isLowConfidence("dueDate") && "border-rubis")}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user