Componentes
[ui] · motion

DatePicker

Selector de fecha con tres ruedas (día / mes / año) sobre WheelPicker, con rango mínimo-máximo, atajos rápidos y customizable por props.

motion
@/components/spm/date-picker

martes, 29 de sep de 2026

1'use client';
2 
3/**
4 * DatePicker
5 * ─────────────────────────────────────────────────────────────
6 * Selector de fecha con ruedas (día / mes / año) construido
7 * encima de `WheelPicker`. Cada tambor es un WheelPicker
8 * independiente, así que el gesto de arrastrar/fling ya viene
9 * resuelto: inercia, rubber-band en los extremos, snap por fila,
10 * navegación con flechas y (opcional) el tick de detent.
11 *
12 * El valor viaja siempre como la misma clave 'YYYY-MM-DD' que
13 * usa el calendario (`event-utils`), nunca como `Date`: sin
14 * desfase de zona horaria y listo para serializar.
15 *
16 * const [date, setDate] = useState("2026-09-29");
17 *
18 * <DatePicker
19 * value={date} // controlado
20 * onChange={setDate}
21 * min="2026-09-29" // no deja elegir fechas pasadas
22 * showPresets // Hoy / Mañana / Próxima semana
23 * sound // tick al cruzar filas
24 * />
25 *
26 * Tres cosas que hace y no hace un <input type="date"> nativo,
27 * y conviene tener presentes al usarlo:
28 * - Es más lindo de usar en el dedo, pero NO tiene calendario
29 * de referencia: ver el mes entero requiere salir de él.
30 * - `value=""` (fecha sin definir) solo existe si pasás
31 * `allowEmpty`; si no, la fecha siempre está definida.
32 * - Es controlled-first: sin `value` usa `defaultValue` y a
33 * partir de ahí se mueve solo.
34 */
35 
36import * as React from 'react';
37import { WheelPicker } from '@/components/motion/wheel-picker';
38import { cn } from '@/lib/utils';
39 
40/* ------------------------------------------------------------------ */
41/* Rótulos */
42/* ------------------------------------------------------------------ */
43 
44const MONTHS = [
45 'ene',
46 'feb',
47 'mar',
48 'abr',
49 'may',
50 'jun',
51 'jul',
52 'ago',
53 'sep',
54 'oct',
55 'nov',
56 'dic',
57];
58 
59const WEEKDAYS = [
60 'domingo',
61 'lunes',
62 'martes',
63 'miércoles',
64 'jueves',
65 'viernes',
66 'sábado',
67];
68 
69const YEAR_SPAN_BEFORE = 1;
70const YEAR_SPAN_AFTER = 5;
71 
72// Qué rueda va primero. `d` día, `m` mes (0-11), `y` año.
73const ORDERS = {
74 dmy: ['d', 'm', 'y'],
75 mdy: ['m', 'd', 'y'],
76 ymd: ['y', 'm', 'd'],
77};
78 
79const WHEEL_META = {
80 d: { label: 'Día', grow: 'flex-1' },
81 m: { label: 'Mes', grow: 'flex-[1.3]' },
82 y: { label: 'Año', grow: 'flex-1' },
83};
84 
85/* ------------------------------------------------------------------ */
86/* Helpers puros sobre 'YYYY-MM-DD' (sin horas ni zonas horarias) */
87/* ------------------------------------------------------------------ */
88 
89/** Días del mes; m es base 0. new Date(y, m + 1, 0) cae en el último día. */
90function daysInMonth(y, m) {
91 return new Date(y, m + 1, 0).getDate();
92}
93 
94/** 'YYYY-MM-DD' → { y, m, d } con m en base 0, o null si no parsea. */
95function parseKey(key) {
96 const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key ?? '');
97 if (!match) return null;
98 const y = Number(match[1]);
99 const m = Number(match[2]) - 1;
100 const d = Number(match[3]);
101 if (m < 0 || m > 11 || d < 1 || d > daysInMonth(y, m)) return null;
102 return { y, m, d };
103}
104 
105function toKey(y, m, d) {
106 return `${String(y).padStart(4, '0')}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
107}
108 
109function clamp(v, lo, hi) {
110 return Math.max(lo, Math.min(v, hi));
111}
112 
113/** Rango de años a recorrer, derivado de min/max o de una ventana por defecto. */
114function yearSpan(min, max) {
115 const now = new Date().getFullYear();
116 const lo = min ? min.y : now - YEAR_SPAN_BEFORE;
117 const hi = max ? max.y : now + YEAR_SPAN_AFTER;
118 return hi < lo ? [lo, lo] : [lo, hi];
119}
120 
121/**
122 * Días válidos dentro de (y, m) una vez aplicado min/max. Las opciones del
123 * tambor son la única fuente de verdad de qué es alcanzable: si acá devuelve
124 * 1..31, el WheelPicker jamás puede emitir un día 31 de febrero.
125 */
126function dayBounds(y, m, min, max) {
127 let lo = 1;
128 let hi = daysInMonth(y, m);
129 if (min && min.y === y && min.m === m) lo = min.d;
130 if (max && max.y === y && max.m === m) hi = max.d;
131 if (lo > hi) lo = hi;
132 return [lo, hi];
133}
134 
135/* ------------------------------------------------------------------ */
136/* Presets */
137/* ------------------------------------------------------------------ */
138 
139function presetList() {
140 const now = new Date();
141 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
142 const tomorrow = new Date(today);
143 tomorrow.setDate(tomorrow.getDate() + 1);
144 // Próximo lunes: si hoy ya es lunes, el de dentro de siete días.
145 const nextMonday = new Date(today);
146 nextMonday.setDate(nextMonday.getDate() + ((8 - (nextMonday.getDay() || 7)) % 7 || 7));
147 return [
148 { id: 'today', label: 'Hoy', date: today },
149 { id: 'tomorrow', label: 'Mañana', date: tomorrow },
150 { id: 'next-week', label: 'Próxima semana', date: nextMonday },
151 ];
152}
153 
154/* ------------------------------------------------------------------ */
155/* Componente */
156/* ------------------------------------------------------------------ */
157 
158export function DatePicker({
159 value,
160 defaultValue,
161 onChange,
162 min,
163 max,
164 order = 'dmy',
165 sound = false,
166 visibleCount = 5,
167 itemHeight = 36,
168 showPresets = false,
169 allowEmpty = false,
170 disabled = false,
171 monthLabels = MONTHS,
172 className,
173 'aria-label': ariaLabel = 'Selector de fecha',
174}) {
175 const minParts = React.useMemo(() => parseKey(min) ?? null, [min]);
176 const maxParts = React.useMemo(() => parseKey(max) ?? null, [max]);
177 const [yearLo, yearHi] = yearSpan(minParts, maxParts);
178 
179 const [parts, setParts] = React.useState(() => {
180 const parsed = parseKey(value) ?? parseKey(defaultValue);
181 if (parsed) return parsed;
182 const now = new Date();
183 const y = clamp(now.getFullYear(), yearLo, yearHi);
184 const m = now.getMonth();
185 return { y, m, d: clamp(now.getDate(), ...dayBounds(y, m, minParts, maxParts)) };
186 });
187 
188 // `value` manda: es el que refleja la fuente real (el store, otro DatePicker,
189 // un preset de afuera). Sin esto el componente quedaría uncontrolled en
190 // cuanto se toca una vez. El ajuste va durante el render y no en un
191 // `useEffect`: sincronizar con setState desde un effect cuesta un pase de
192 // render entero antes de que el valor nuevo llegue a las ruedas.
193 const [syncedFrom, setSyncedFrom] = React.useState(value);
194 if (value !== syncedFrom) {
195 setSyncedFrom(value);
196 const parsed = parseKey(value);
197 if (parsed && (parsed.y !== parts.y || parsed.m !== parts.m || parsed.d !== parts.d)) {
198 setParts(parsed);
199 }
200 }
201 
202 const commit = React.useCallback(
203 (next) => {
204 const y = clamp(next.y, yearLo, yearHi);
205 const [dLo, dHi] = dayBounds(y, next.m, minParts, maxParts);
206 const safe = { y, m: next.m, d: clamp(next.d, dLo, dHi) };
207 setParts(safe);
208 onChange?.(toKey(safe.y, safe.m, safe.d));
209 },
210 [yearLo, yearHi, minParts, maxParts, onChange],
211 );
212 
213 const dayOptions = React.useMemo(() => {
214 const [dLo, dHi] = dayBounds(parts.y, parts.m, minParts, maxParts);
215 return Array.from({ length: dHi - dLo + 1 }, (_, i) => {
216 const d = dLo + i;
217 return { value: d, label: String(d) };
218 });
219 }, [parts.y, parts.m, minParts, maxParts]);
220 
221 const monthOptions = React.useMemo(
222 () => monthLabels.map((label, m) => ({ value: m, label })),
223 [monthLabels],
224 );
225 
226 const yearOptions = React.useMemo(
227 () =>
228 Array.from({ length: yearHi - yearLo + 1 }, (_, i) => {
229 const y = yearLo + i;
230 return { value: y, label: String(y) };
231 }),
232 [yearLo, yearHi],
233 );
234 
235 const wheels = React.useMemo(() => {
236 const options = { d: dayOptions, m: monthOptions, y: yearOptions };
237 return (ORDERS[order] ?? ORDERS.dmy).map((field) => ({
238 field,
239 options: options[field],
240 value: parts[field],
241 onValueChange: (next) => commit({ ...parts, [field]: next }),
242 }));
243 }, [order, dayOptions, monthOptions, yearOptions, parts, commit]);
244 
245 const presets = React.useMemo(() => presetList(), []);
246 
247 const isEmpty = allowEmpty && !value;
248 const resolved = `${WEEKDAYS[new Date(parts.y, parts.m, parts.d).getDay()]}, ${parts.d} de ${
249 monthLabels[parts.m]
250 } de ${parts.y}`;
251 
252 return (
253 // `data-lenis-prevent` no es decorativo: Lenis corre como singleton global
254 // con smoothWheel y solo mira el atributo por composedPath. El tambor hace
255 // preventDefault pero no stopPropagation, así que sin esto el scroll de la
256 // rueda gira la página mientras el dedo la desliza.
257 <div
258 data-lenis-prevent
259 role="group"
260 aria-label={ariaLabel}
261 className={cn('flex flex-col gap-3', className)}>
262 {(showPresets || allowEmpty) && (
263 <div className="flex flex-wrap gap-1.5">
264 {showPresets &&
265 presets.map((preset) => (
266 <button
267 key={preset.id}
268 type="button"
269 disabled={disabled}
270 onClick={() => {
271 const p = new Date(preset.date);
272 commit({ y: p.getFullYear(), m: p.getMonth(), d: p.getDate() });
273 }}
274 className="rounded-full border border-border bg-muted/40 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground disabled:pointer-events-none disabled:opacity-50">
275 {preset.label}
276 </button>
277 ))}
278 {allowEmpty && (
279 <button
280 type="button"
281 disabled={disabled}
282 onClick={() => onChange?.('')}
283 className="rounded-full border border-dashed border-border px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:text-foreground disabled:pointer-events-none disabled:opacity-50">
284 Sin fecha
285 </button>
286 )}
287 </div>
288 )}
289 
290 <div className="flex gap-2">
291 {wheels.map((wheel) => (
292 <WheelPicker
293 key={wheel.field}
294 options={wheel.options}
295 value={wheel.value}
296 onValueChange={wheel.onValueChange}
297 visibleCount={visibleCount}
298 itemHeight={itemHeight}
299 disabled={disabled}
300 sound={sound}
301 aria-label={WHEEL_META[wheel.field].label}
302 className={WHEEL_META[wheel.field].grow}
303 />
304 ))}
305 </div>
306 
307 <p
308 className={cn(
309 'px-1 text-xs tracking-wide text-muted-foreground',
310 isEmpty && 'italic opacity-60',
311 )}>
312 {isEmpty ? 'Sin fecha' : resolved}
313 </p>
314 </div>
315 );
316}
317 
318export default DatePicker;

Uso

Props

PropTipoDefaultDescripción
value'YYYY-MM-DD'—Fecha seleccionada. Sin ella el componente pasa a uncontrolled con defaultValue.
onChange(key: string) => void—Emite siempre una clave 'YYYY-MM-DD', nunca un Date.
defaultValue'YYYY-MM-DD'hoyFecha inicial cuando no es controlado.
min'YYYY-MM-DD'—Fecha mínima. Recorta los años y los días fuera de rango.
max'YYYY-MM-DD'—Fecha máxima. Sin min ni max se ofrecen 5 años hacia adelante.
order'dmy' | 'mdy' | 'ymd''dmy'Orden de las ruedas: día/mes/año, mes/día/año o año/mes/día.
soundbooleanfalseTick de detent al cruzar filas.
visibleCountnumber5Filas visibles por rueda (debe ser impar).
itemHeightnumber36Alto de fila en píxeles.
showPresetsbooleanfalseAtajos Hoy / Mañana / Próxima semana.
allowEmptybooleanfalsePermite una fecha sin definir (value "") y muestra el botón Sin fecha.
disabledbooleanfalseDeshabilita las tres ruedas y los atajos.
monthLabelsstring[]ene…dicEtiquetas de mes. Van en una fila de alto fijo: usá abreviaturas.
classNamestring—Clases del contenedor raíz.

Acciones del componente

Search for a command to run...

Potenciá tu negocio con SFESS

Software a medida, IA integrada y ciberseguridad avanzada para empresas.

0años de ingeniería
0proyectos entregados
0% uptime
Servicios destacados
  • Consultoría SEO Avanzado & Analítica

    Auditoría técnica profunda para posicionar tu negocio en los primeros resultados de Google.

    SEO TécnicoGA4
  • Páginas Web Corporativas Complejas

    Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.

    Framer MotionMulti-page
  • Integración de Inteligencia Artificial en Negocios

    Automatizamos la atención, las reservas y las ventas de tu negocio con agentes de IA.

    AI AgentsChatbots
Nuestro ecosistema
WORKENTA_OSSFESS_SOLUTIONSPRANALUZ_YOGAREMY_AI
Ver todos los servicios