[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-pickerDatePicker
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- ene
- feb
- mar
- abr
- may
- jun
- jul
- ago
- sep
- oct
- nov
- dic
- ene
- feb
- mar
- abr
- may
- jun
- jul
- ago
- sep
- oct
- nov
- dic
- 2025
- 2026
- 2027
- 2028
- 2029
- 2030
- 2031
- 2025
- 2026
- 2027
- 2028
- 2029
- 2030
- 2031
martes, 29 de sep de 2026
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- ene
- feb
- mar
- abr
- may
- jun
- jul
- ago
- sep
- oct
- nov
- dic
- ene
- feb
- mar
- abr
- may
- jun
- jul
- ago
- sep
- oct
- nov
- dic
- 2025
- 2026
- 2027
- 2028
- 2029
- 2030
- 2031
- 2025
- 2026
- 2027
- 2028
- 2029
- 2030
- 2031
martes, 29 de sep de 2026
1'use client';2 3/**4 * DatePicker5 * ─────────────────────────────────────────────────────────────6 * Selector de fecha con ruedas (día / mes / año) construido7 * encima de `WheelPicker`. Cada tambor es un WheelPicker8 * independiente, así que el gesto de arrastrar/fling ya viene9 * 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' que13 * usa el calendario (`event-utils`), nunca como `Date`: sin14 * desfase de zona horaria y listo para serializar.15 *16 * const [date, setDate] = useState("2026-09-29");17 *18 * <DatePicker19 * value={date} // controlado20 * onChange={setDate}21 * min="2026-09-29" // no deja elegir fechas pasadas22 * showPresets // Hoy / Mañana / Próxima semana23 * sound // tick al cruzar filas24 * />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 calendario29 * de referencia: ver el mes entero requiere salir de él.30 * - `value=""` (fecha sin definir) solo existe si pasás31 * `allowEmpty`; si no, la fecha siempre está definida.32 * - Es controlled-first: sin `value` usa `defaultValue` y a33 * 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 del123 * tambor son la única fuente de verdad de qué es alcanzable: si acá devuelve124 * 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 en190 // cuanto se toca una vez. El ajuste va durante el render y no en un191 // `useEffect`: sincronizar con setState desde un effect cuesta un pase de192 // 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 global254 // con smoothWheel y solo mira el atributo por composedPath. El tambor hace255 // preventDefault pero no stopPropagation, así que sin esto el scroll de la256 // rueda gira la página mientras el dedo la desliza.257 <div258 data-lenis-prevent259 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 <button267 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 <button280 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 fecha285 </button>286 )}287 </div>288 )}289 290 <div className="flex gap-2">291 {wheels.map((wheel) => (292 <WheelPicker293 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 <p308 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écnicoGA4Páginas Web Corporativas Complejas
Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.
Framer MotionMulti-pageIntegració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
Ver todos los serviciosWORKENTA_OSSFESS_SOLUTIONSPRANALUZ_YOGAREMY_AI