refactor(ui): migrar componente DatePicker a HeroUI

This commit is contained in:
Miguel Ortega committed 2026-09-13 20:54:10 -05:00
1 parent 952f30ea19
commit cc8111275e
1 file changed
+317 -251
+317 -251
View File
@@ -1,289 +1,355 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { FiCalendar, FiChevronLeft, FiChevronRight, FiX } from "react-icons/fi";
interface DatePickerProps {
import React, { useMemo } from "react";
import {
DatePicker as HeroUIDatePicker,
DateField as HeroUIDateField,
Calendar as HeroUICalendar,
Label,
FieldError,
} from "@heroui/react";
import type { DateValue } from "@internationalized/date";
import { parseDate as parseInternationalDate } from "@internationalized/date";
import {
FiCalendar,
FiChevronLeft,
FiChevronRight,
FiX,
} from "react-icons/fi";
export interface DatePickerProps {
label?: string;
value?: string; // "YYYY-MM-DD"
onChange: (value: string | undefined) => void;
value?: string; //Fecha en formato YYYY-MM-DD o undefined cuando se limpia
onChange: (value: string | undefined) => void; //Devuelve la fecha en formato YYYY-MM-DD o undefined cuando se limpia.
placeholder?: string;
required?: boolean;
maxDate?: Date;
maxDate?: Date; //Fecha máxima permitida.
className?: string;
errorMessage?: string;
}
const MONTHS = [
"Enero","Febrero","Marzo","Abril","Mayo","Junio",
"Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre",
];
const DAYS = ["Do","Lu","Ma","Mi","Ju","Vi","Sa"];
const DROPDOWN_H = 320; // px estimado del dropdown
function toCalendarDate(dateStr?: string): DateValue | undefined {
if (!dateStr) {
return undefined;
}
function parseDate(value?: string): Date | undefined {
if (!value) return undefined;
const d = new Date(value + "T12:00:00");
return isNaN(d.getTime()) ? undefined : d;
try {
return parseInternationalDate(dateStr);
} catch {
return undefined;
}
}
function formatDisplay(value?: string): string {
const d = parseDate(value);
if (!d) return "";
return d.toLocaleDateString("es-MX", { day: "numeric", month: "long", year: "numeric" });
function toDateString(date: DateValue): string {
const yyyy = date.year;
const mm = String(date.month).padStart(2, "0");
const dd = String(date.day).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
export function DatePicker({
export const DatePicker = React.forwardRef<
HTMLDivElement,
DatePickerProps
>(
(
{
label,
value,
onChange,
placeholder = "Selecciona una fecha",
required,
maxDate = new Date(),
}: DatePickerProps) {
const selected = parseDate(value);
const [open, setOpen] = useState(false);
const [view, setView] = useState<"days" | "months" | "years">("days");
const [cursor, setCursor] = useState(() => {
const d = selected ?? new Date();
return { month: d.getMonth(), year: d.getFullYear() };
});
const [pos, setPos] = useState({ top: 0, left: 0, width: 0, openUp: false });
placeholder,
required = false,
maxDate,
className = "",
errorMessage,
},
ref
) => {
const dateValue = useMemo(
() => toCalendarDate(value),
[value]
);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const maxDateValue = useMemo<DateValue | undefined>(() => {
if (!maxDate) {
return undefined;
}
const calcPos = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < DROPDOWN_H + 8 && r.top > DROPDOWN_H + 8;
setPos({
top: openUp ? r.top - 8 : r.bottom + 8,
left: r.left,
width: r.width,
openUp,
});
}, []);
try {
const yyyy = maxDate.getFullYear();
const mm = String(maxDate.getMonth() + 1).padStart(2, "0");
const dd = String(maxDate.getDate()).padStart(2, "0");
const openPicker = () => {
if (selected) setCursor({ month: selected.getMonth(), year: selected.getFullYear() });
setView("days");
calcPos();
setOpen(true);
return parseInternationalDate(`${yyyy}-${mm}-${dd}`);
} catch {
return undefined;
}
}, [maxDate]);
const handleChange = (newDate: DateValue | null) => {
if (newDate) {
onChange(toDateString(newDate));
} else {
onChange(undefined);
}
};
useEffect(() => {
if (!open) return;
const onScroll = () => calcPos();
const onResize = () => calcPos();
const onMouse = (e: MouseEvent) => {
if (
triggerRef.current?.contains(e.target as Node) ||
dropdownRef.current?.contains(e.target as Node)
) return;
setOpen(false);
const handleClear = () => {
onChange(undefined);
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
document.addEventListener("mousedown", onMouse);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
document.removeEventListener("mousedown", onMouse);
};
}, [open, calcPos]);
const selectDay = (day: number) => {
const d = new Date(cursor.year, cursor.month, day);
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
onChange(iso);
setOpen(false);
};
const daysInMonth = new Date(cursor.year, cursor.month + 1, 0).getDate();
const firstDow = new Date(cursor.year, cursor.month, 1).getDay();
const today = new Date();
const isDisabled = (day: number) => new Date(cursor.year, cursor.month, day) > maxDate;
const isSelected = (day: number) =>
selected?.getFullYear() === cursor.year &&
selected?.getMonth() === cursor.month &&
selected?.getDate() === day;
const isToday = (day: number) =>
today.getFullYear() === cursor.year &&
today.getMonth() === cursor.month &&
today.getDate() === day;
const yearRange = Array.from({ length: 101 }, (_, i) => today.getFullYear() - 100 + i).reverse();
const dropdown = open ? (
<div
ref={dropdownRef}
style={{
position: "fixed",
top: pos.openUp ? "auto" : pos.top,
bottom: pos.openUp ? window.innerHeight - pos.top : "auto",
left: pos.left,
width: Math.max(pos.width, 288),
zIndex: 9999,
}}
className="rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-800">
<button
type="button"
onClick={() => view === "days" && setCursor(c => {
const d = new Date(c.year, c.month - 1, 1);
return { month: d.getMonth(), year: d.getFullYear() };
})}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition text-slate-500 dark:text-slate-400"
>
<FiChevronLeft />
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => setView(v => v === "months" ? "days" : "months")}
className="text-sm font-semibold text-slate-800 dark:text-slate-100 hover:text-violet-600 dark:hover:text-violet-400 transition"
>
{MONTHS[cursor.month]}
</button>
<button
type="button"
onClick={() => setView(v => v === "years" ? "days" : "years")}
className="text-sm font-semibold text-slate-800 dark:text-slate-100 hover:text-violet-600 dark:hover:text-violet-400 transition"
>
{cursor.year}
</button>
</div>
<button
type="button"
onClick={() => view === "days" && setCursor(c => {
const d = new Date(c.year, c.month + 1, 1);
return { month: d.getMonth(), year: d.getFullYear() };
})}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition text-slate-500 dark:text-slate-400"
>
<FiChevronRight />
</button>
</div>
{/* Days */}
{view === "days" && (
<div className="p-3">
<div className="grid grid-cols-7 mb-1">
{DAYS.map(d => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-400 dark:text-slate-500 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5">
{Array.from({ length: firstDow }).map((_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
disabled={isDisabled(day)}
onClick={() => selectDay(day)}
className={`
aspect-square w-full flex items-center justify-center rounded-lg text-sm transition-all
${isSelected(day)
? "bg-violet-600 text-white font-semibold"
: isToday(day)
? "border border-violet-400 text-violet-600 dark:text-violet-400 font-semibold"
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"}
${isDisabled(day) ? "opacity-30 cursor-not-allowed" : ""}
`}
>
{day}
</button>
))}
</div>
</div>
)}
{/* Months */}
{view === "months" && (
<div className="grid grid-cols-3 gap-2 p-3">
{MONTHS.map((m, i) => (
<button
key={m}
type="button"
onClick={() => { setCursor(c => ({ ...c, month: i })); setView("days"); }}
className={`py-2 rounded-xl text-sm font-medium transition ${
cursor.month === i
? "bg-violet-600 text-white"
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
>
{m.slice(0, 3)}
</button>
))}
</div>
)}
{/* Years */}
{view === "years" && (
<div className="h-52 overflow-y-auto p-2 grid grid-cols-3 gap-1 content-start">
{yearRange.map(y => (
<button
key={y}
type="button"
onClick={() => { setCursor(c => ({ ...c, year: y })); setView("days"); }}
className={`py-2 rounded-xl text-sm font-medium transition ${
cursor.year === y
? "bg-violet-600 text-white"
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
>
{y}
</button>
))}
</div>
)}
</div>
) : null;
return (
<div className="w-full space-y-1.5">
<HeroUIDatePicker.Root
ref={ref}
value={dateValue ?? null}
onChange={handleChange}
maxValue={maxDateValue}
isRequired={required}
isInvalid={Boolean(errorMessage)}
className={`w-full flex flex-col gap-1.5 ${className}`}
>
{/* Label */}
{label && (
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
<Label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
{required && (
<span className="ml-1 font-bold text-rose-500">
*
</span>
)}
</Label>
)}
<div className="relative flex items-center">
<div className="absolute left-3.5 pointer-events-none text-slate-400 dark:text-slate-500">
<FiCalendar className="text-base" />
</div>
<button
ref={triggerRef}
type="button"
onClick={openPicker}
className={`
w-full pl-10 pr-10 py-2.5 text-sm rounded-xl text-left transition-all duration-200 outline-none
bg-white dark:bg-slate-900/90
border border-slate-300 dark:border-slate-800
hover:border-slate-400 dark:hover:border-slate-700
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
${value ? "text-slate-900 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}
`}
{/* Campo de fecha */}
<HeroUIDateField.Group
className={[
"bg-white dark:bg-slate-900/90",
"border border-slate-300 dark:border-slate-800",
"hover:border-slate-400 dark:hover:border-slate-700",
"data-[focus-within=true]:border-cyan-500",
"data-[focus-within=true]:ring-4",
"data-[focus-within=true]:ring-cyan-500/15",
"rounded-xl flex items-center shadow-sm",
"transition-all duration-200 h-10",
errorMessage
? "border-rose-500 dark:border-rose-500"
: "",
].join(" ")}
>
{value ? formatDisplay(value) : placeholder}
</button>
{/* Icono izquierdo */}
<HeroUIDateField.Prefix
className="pl-3.5 pr-1.5 text-slate-400 dark:text-slate-500"
>
<FiCalendar className="text-base" />
</HeroUIDateField.Prefix>
{/* Input segmentado */}
<HeroUIDateField.Input
className="flex flex-1 items-center text-sm text-slate-900 outline-none dark:text-slate-100"
>
{(segment) => (
<HeroUIDateField.Segment
segment={segment}
className={[
"min-w-5 rounded px-0.5 text-center outline-none",
"data-[placeholder=true]:text-slate-400",
"dark:data-[placeholder=true]:text-slate-500",
"focus:bg-cyan-500/20",
"focus:text-cyan-700",
"dark:focus:text-cyan-400",
].join(" ")}
/>
)}
</HeroUIDateField.Input>
{!value && placeholder && (
<span className="pointer-events-none absolute ml-10 text-sm text-slate-400 dark:text-slate-500">
{placeholder}
</span>
)}
<HeroUIDateField.Suffix className="flex items-center">
{/* Botón limpiar */}
{value && (
<button
type="button"
onClick={() => onChange(undefined)}
className="absolute right-3 p-1 rounded-md text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
onClick={handleClear}
aria-label="Limpiar fecha"
className={[
"mr-1 rounded-md p-1",
"text-slate-400",
"hover:bg-slate-100 hover:text-slate-700",
"dark:hover:bg-slate-800 dark:hover:text-slate-200",
"outline-none transition",
].join(" ")}
>
<FiX className="text-sm" />
</button>
)}
</div>
{typeof window !== "undefined" && createPortal(dropdown, document.body)}
{/* Botón calendario */}
<HeroUIDatePicker.Trigger
className={[
"rounded-r-xl pl-1.5 pr-3",
"text-slate-400",
"hover:text-slate-700",
"dark:hover:text-slate-200",
"outline-none",
"focus-visible:text-cyan-500",
].join(" ")}
>
<HeroUIDatePicker.TriggerIndicator />
</HeroUIDatePicker.Trigger>
</HeroUIDateField.Suffix>
</HeroUIDateField.Group>
{/* Error */}
{errorMessage && (
<FieldError className="text-xs text-rose-500">
{errorMessage}
</FieldError>
)}
{/* Calendario */}
<HeroUIDatePicker.Popover
className={[
"min-w-[288px]",
"overflow-hidden rounded-2xl",
"border border-slate-200 dark:border-slate-700",
"bg-white dark:bg-slate-900",
"shadow-2xl",
"outline-none",
].join(" ")}
>
<HeroUICalendar.Root aria-label={label ?? "Seleccionar fecha"}>
{/* Header */}
<HeroUICalendar.Header
className={[
"flex items-center justify-between",
"border-b border-slate-100 dark:border-slate-800",
"px-4 py-3",
].join(" ")}
>
{/* Mes anterior */}
<HeroUICalendar.NavButton
slot="previous"
className={[
"rounded-lg p-1.5",
"text-slate-500 dark:text-slate-400",
"hover:bg-slate-100 dark:hover:bg-slate-800",
"transition",
"outline-none",
"focus-visible:ring-2 focus-visible:ring-cyan-500",
].join(" ")}
>
<FiChevronLeft className="text-lg" />
</HeroUICalendar.NavButton>
{/* Mes / año */}
<HeroUICalendar.Heading
className={[
"text-sm font-semibold",
"text-slate-800 dark:text-slate-100",
].join(" ")}
/>
{/* Mes siguiente */}
<HeroUICalendar.NavButton
slot="next"
className={[
"rounded-lg p-1.5",
"text-slate-500 dark:text-slate-400",
"hover:bg-slate-100 dark:hover:bg-slate-800",
"transition",
"outline-none",
"focus-visible:ring-2 focus-visible:ring-cyan-500",
].join(" ")}
>
<FiChevronRight className="text-lg" />
</HeroUICalendar.NavButton>
</HeroUICalendar.Header>
{/* Grid del calendario */}
<div className="p-3">
<HeroUICalendar.Grid className="w-full border-collapse">
{/* Días de la semana */}
<HeroUICalendar.GridHeader>
{(day) => (
<HeroUICalendar.HeaderCell
className={[
"pb-2 text-center",
"text-[10px] font-semibold",
"text-slate-400 dark:text-slate-500",
].join(" ")}
>
{day}
</HeroUICalendar.HeaderCell>
)}
</HeroUICalendar.GridHeader>
{/* Días */}
<HeroUICalendar.GridBody>
{(date) => (
<HeroUICalendar.Cell
date={date}
className="p-0.5 outline-none"
>
{({
formattedDate,
isSelected,
isDisabled,
isToday,
}) => (
<HeroUICalendar.CellIndicator
className={[
"flex aspect-square w-full min-w-8",
"items-center justify-center",
"rounded-lg text-sm transition-all",
"outline-none",
"focus-visible:ring-2",
"focus-visible:ring-cyan-500",
"focus-visible:ring-offset-1",
"focus-visible:ring-offset-white",
"dark:focus-visible:ring-offset-slate-900",
isSelected
? "bg-violet-600 font-semibold text-white shadow-sm"
: isToday
? [
"border border-violet-400",
"font-semibold text-violet-600",
"hover:bg-slate-100",
"dark:text-violet-400",
"dark:hover:bg-slate-800",
].join(" ")
: [
"text-slate-700",
"hover:bg-slate-100",
"dark:text-slate-300",
"dark:hover:bg-slate-800",
].join(" "),
isDisabled
? "cursor-not-allowed opacity-30 hover:bg-transparent"
: "",
].join(" ")}
>
{formattedDate}
</HeroUICalendar.CellIndicator>
)}
</HeroUICalendar.Cell>
)}
</HeroUICalendar.GridBody>
</HeroUICalendar.Grid>
</div>
</HeroUICalendar.Root>
</HeroUIDatePicker.Popover>
</HeroUIDatePicker.Root>
);
}
}
);
DatePicker.displayName = "DatePicker";