@adamosuiteservices/ui 2.11.15 → 2.11.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/colors.css +1 -1
- package/dist/styles.css +1 -1
- package/docs/AI-GUIDE.md +321 -321
- package/docs/components/layout/sidebar.md +399 -399
- package/docs/components/layout/toaster.md +436 -436
- package/docs/components/ui/accordion-rounded.md +584 -584
- package/docs/components/ui/accordion.md +269 -269
- package/docs/components/ui/button-group.md +984 -984
- package/docs/components/ui/button.md +1137 -1137
- package/docs/components/ui/calendar.md +1159 -1159
- package/docs/components/ui/card.md +1455 -1455
- package/docs/components/ui/checkbox.md +292 -292
- package/docs/components/ui/collapsible.md +323 -323
- package/docs/components/ui/command.md +454 -454
- package/docs/components/ui/context-menu.md +540 -540
- package/docs/components/ui/dialog.md +628 -628
- package/docs/components/ui/dropdown-menu.md +709 -709
- package/docs/components/ui/field.md +706 -706
- package/docs/components/ui/hover-card.md +446 -446
- package/docs/components/ui/input.md +362 -362
- package/docs/components/ui/kbd.md +434 -434
- package/docs/components/ui/label.md +359 -359
- package/docs/components/ui/pagination.md +650 -650
- package/docs/components/ui/popover.md +536 -536
- package/docs/components/ui/progress.md +182 -182
- package/docs/components/ui/radio-group.md +311 -311
- package/docs/components/ui/select.md +352 -352
- package/docs/components/ui/separator.md +214 -214
- package/docs/components/ui/sheet.md +142 -142
- package/docs/components/ui/skeleton.md +140 -140
- package/docs/components/ui/slider.md +341 -341
- package/docs/components/ui/spinner.md +170 -170
- package/docs/components/ui/switch.md +408 -408
- package/docs/components/ui/tabs-underline.md +106 -106
- package/docs/components/ui/tabs.md +122 -122
- package/docs/components/ui/textarea.md +243 -243
- package/docs/components/ui/toggle.md +237 -237
- package/docs/components/ui/tooltip.md +317 -317
- package/docs/components/ui/typography.md +280 -280
- package/package.json +1 -1
|
@@ -1,1159 +1,1159 @@
|
|
|
1
|
-
# Calendar
|
|
2
|
-
|
|
3
|
-
Calendario interactivo altamente personalizable basado en `react-day-picker` para selección de fechas individuales, múltiples o rangos. Soporta navegación con dropdowns, múltiples meses, números de semana, timezones y fechas deshabilitadas.
|
|
4
|
-
|
|
5
|
-
## Características Principales
|
|
6
|
-
|
|
7
|
-
- **4 modos de selección**: Single (fecha única), Multiple (múltiples fechas), Range (rango de fechas), Default (sin selección)
|
|
8
|
-
- **Navegación flexible**: Botones tradicionales o dropdowns mes/año para saltos rápidos
|
|
9
|
-
- **Múltiples meses**: Visualización simultánea de 2+ meses para rangos amplios
|
|
10
|
-
- **Números de semana**: Muestra semana del año (ISO 8601)
|
|
11
|
-
- **Fechas deshabilitadas**: Bloquea fechas específicas o rangos completos
|
|
12
|
-
- **Timezone awareness**: Soporte completo para zonas horarias
|
|
13
|
-
- **Variantes de botones**: Personaliza navegación (ghost, outline, secondary, etc.)
|
|
14
|
-
- **RTL support**: Rotación automática de chevrons en idiomas right-to-left
|
|
15
|
-
- **Integración con Card/Popover**: Background transparente automático
|
|
16
|
-
- **Accesibilidad**: Navegación por teclado, ARIA labels, focus visible
|
|
17
|
-
|
|
18
|
-
## Importación
|
|
19
|
-
|
|
20
|
-
```tsx
|
|
21
|
-
import { Calendar } from "@adamosuiteservices/ui/calendar";
|
|
22
|
-
import type { DateRange } from "react-day-picker";
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
```tsx
|
|
26
|
-
import { Calendar } from "@adamosuiteservices/ui/calendar";
|
|
27
|
-
import type { DateRange } from "react-day-picker";
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Uso Básico
|
|
31
|
-
|
|
32
|
-
### Selección de Fecha Única
|
|
33
|
-
|
|
34
|
-
El modo más común para seleccionar una sola fecha.
|
|
35
|
-
|
|
36
|
-
```tsx
|
|
37
|
-
import { useState } from "react";
|
|
38
|
-
import { Calendar } from "@adamosuiteservices/ui/calendar";
|
|
39
|
-
|
|
40
|
-
function DatePicker() {
|
|
41
|
-
const [date, setDate] = useState<Date | undefined>(new Date());
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<Calendar
|
|
45
|
-
mode="single"
|
|
46
|
-
selected={date}
|
|
47
|
-
onSelect={setDate}
|
|
48
|
-
className="rounded-md border"
|
|
49
|
-
/>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
### Navegación con Dropdowns
|
|
55
|
-
|
|
56
|
-
Para saltar rápidamente entre meses y años.
|
|
57
|
-
|
|
58
|
-
```tsx
|
|
59
|
-
const [date, setDate] = useState<Date | undefined>(new Date(2025, 5, 12));
|
|
60
|
-
|
|
61
|
-
<Calendar
|
|
62
|
-
mode="single"
|
|
63
|
-
selected={date}
|
|
64
|
-
onSelect={setDate}
|
|
65
|
-
captionLayout="dropdown"
|
|
66
|
-
className="rounded-md border"
|
|
67
|
-
/>;
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
**Ventaja**: Permite seleccionar fechas lejanas sin hacer múltiples clics en flechas.
|
|
71
|
-
|
|
72
|
-
### Rango de Fechas
|
|
73
|
-
|
|
74
|
-
Para seleccionar periodo entre dos fechas (ej: reservas de hotel).
|
|
75
|
-
|
|
76
|
-
```tsx
|
|
77
|
-
import type { DateRange } from "react-day-picker";
|
|
78
|
-
|
|
79
|
-
function RangePicker() {
|
|
80
|
-
const [range, setRange] = useState<DateRange | undefined>({
|
|
81
|
-
from: new Date(2025, 5, 12),
|
|
82
|
-
to: new Date(2025, 5, 18),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<Calendar
|
|
87
|
-
mode="range"
|
|
88
|
-
defaultMonth={range?.from}
|
|
89
|
-
selected={range}
|
|
90
|
-
onSelect={setRange}
|
|
91
|
-
className="rounded-md border"
|
|
92
|
-
/>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### Múltiples Meses
|
|
98
|
-
|
|
99
|
-
Ideal para rangos largos o visualización amplia.
|
|
100
|
-
|
|
101
|
-
```tsx
|
|
102
|
-
const [range, setRange] = useState<DateRange | undefined>({
|
|
103
|
-
from: new Date(2025, 5, 12),
|
|
104
|
-
to: new Date(2025, 6, 15),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
<Calendar
|
|
108
|
-
mode="range"
|
|
109
|
-
defaultMonth={range?.from}
|
|
110
|
-
selected={range}
|
|
111
|
-
onSelect={setRange}
|
|
112
|
-
numberOfMonths={2}
|
|
113
|
-
className="rounded-md border"
|
|
114
|
-
/>;
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
**Nota**: En pantallas pequeñas, los meses se apilan verticalmente automáticamente.
|
|
118
|
-
|
|
119
|
-
### Selección Múltiple
|
|
120
|
-
|
|
121
|
-
Para seleccionar fechas no consecutivas (ej: días de entrenamiento).
|
|
122
|
-
|
|
123
|
-
```tsx
|
|
124
|
-
const [dates, setDates] = useState<Date[] | undefined>([
|
|
125
|
-
new Date(2025, 5, 12),
|
|
126
|
-
new Date(2025, 5, 15),
|
|
127
|
-
new Date(2025, 5, 20),
|
|
128
|
-
]);
|
|
129
|
-
|
|
130
|
-
<Calendar
|
|
131
|
-
mode="multiple"
|
|
132
|
-
selected={dates}
|
|
133
|
-
onSelect={setDates}
|
|
134
|
-
className="rounded-md border"
|
|
135
|
-
/>;
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## Componentes
|
|
139
|
-
|
|
140
|
-
### Calendar
|
|
141
|
-
|
|
142
|
-
El componente principal que renderiza el calendario completo.
|
|
143
|
-
|
|
144
|
-
#### Props
|
|
145
|
-
|
|
146
|
-
| Prop | Tipo | Default | Descripción |
|
|
147
|
-
| ----------------- | ---------------------------------------------------------------- | ------------- | ------------------------------------------- |
|
|
148
|
-
| `mode` | `"single" \| "multiple" \| "range" \| "default"` | `"default"` | Modo de selección de fechas |
|
|
149
|
-
| `selected` | `Date \| Date[] \| DateRange \| undefined` | `undefined` | Fecha(s) seleccionada(s) según modo |
|
|
150
|
-
| `onSelect` | `(date) => void` | - | Callback cuando cambia la selección |
|
|
151
|
-
| `defaultMonth` | `Date` | Mes actual | Mes inicial a mostrar |
|
|
152
|
-
| `numberOfMonths` | `number` | `1` | Cantidad de meses a mostrar simultáneamente |
|
|
153
|
-
| `captionLayout` | `"label" \| "dropdown" \| "dropdown-months" \| "dropdown-years"` | `"label"` | Tipo de navegación del header |
|
|
154
|
-
| `showOutsideDays` | `boolean` | `true` | Mostrar días de meses adyacentes |
|
|
155
|
-
| `showWeekNumber` | `boolean` | `false` | Mostrar números de semana |
|
|
156
|
-
| `disabled` | `Date \| Date[] \| DateRange \| ((date: Date) => boolean)` | - | Fechas deshabilitadas |
|
|
157
|
-
| `hidden` | `Date \| Date[] \| DateRange \| ((date: Date) => boolean)` | - | Fechas ocultas completamente |
|
|
158
|
-
| `fromDate` | `Date` | - | Fecha mínima seleccionable |
|
|
159
|
-
| `toDate` | `Date` | - | Fecha máxima seleccionable |
|
|
160
|
-
| `fromMonth` | `Date` | - | Mes mínimo navegable |
|
|
161
|
-
| `toMonth` | `Date` | - | Mes máximo navegable |
|
|
162
|
-
| `fromYear` | `number` | - | Año mínimo navegable |
|
|
163
|
-
| `toYear` | `number` | - | Año máximo navegable |
|
|
164
|
-
| `timeZone` | `string` | Sistema | Zona horaria (ej: `"America/New_York"`) |
|
|
165
|
-
| `locale` | `Locale` | Sistema | Objeto Locale de `date-fns` para i18n |
|
|
166
|
-
| `weekStartsOn` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6` | `0` | Día inicio de semana (0=Domingo, 1=Lunes) |
|
|
167
|
-
| `buttonVariant` | `ButtonProps["variant"]` | `"secondary"` | Variante para botones de navegación |
|
|
168
|
-
| `className` | `string` | - | Clases CSS adicionales para el contenedor |
|
|
169
|
-
| `classNames` | `ClassNames` | - | Objeto con clases para elementos internos |
|
|
170
|
-
| `components` | `Components` | - | Componentes custom para override |
|
|
171
|
-
| `formatters` | `Formatters` | - | Funciones de formateo custom |
|
|
172
|
-
|
|
173
|
-
### CalendarDayButton
|
|
174
|
-
|
|
175
|
-
Componente interno que renderiza cada día del calendario. Raramente se usa directamente.
|
|
176
|
-
|
|
177
|
-
## Modos de Selección
|
|
178
|
-
|
|
179
|
-
### Mode: "single"
|
|
180
|
-
|
|
181
|
-
Un solo día seleccionado a la vez.
|
|
182
|
-
|
|
183
|
-
```tsx
|
|
184
|
-
// Tipo de selected
|
|
185
|
-
selected: Date | undefined
|
|
186
|
-
|
|
187
|
-
// Callback
|
|
188
|
-
onSelect: (date: Date | undefined) => void
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
**Uso común**: Fecha de nacimiento, vencimiento de documento, fecha límite.
|
|
192
|
-
|
|
193
|
-
### Mode: "multiple"
|
|
194
|
-
|
|
195
|
-
Array de días no consecutivos.
|
|
196
|
-
|
|
197
|
-
```tsx
|
|
198
|
-
// Tipo de selected
|
|
199
|
-
selected: Date[] | undefined
|
|
200
|
-
|
|
201
|
-
// Callback
|
|
202
|
-
onSelect: (dates: Date[] | undefined) => void
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
**Uso común**: Días de entrenamiento, fechas de eventos recurrentes, días festivos.
|
|
206
|
-
|
|
207
|
-
### Mode: "range"
|
|
208
|
-
|
|
209
|
-
Rango continuo entre dos fechas.
|
|
210
|
-
|
|
211
|
-
```tsx
|
|
212
|
-
import type { DateRange } from "react-day-picker";
|
|
213
|
-
|
|
214
|
-
// Tipo de selected
|
|
215
|
-
selected: DateRange | undefined // { from?: Date; to?: Date }
|
|
216
|
-
|
|
217
|
-
// Callback
|
|
218
|
-
onSelect: (range: DateRange | undefined) => void
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
**Uso común**: Reservas de hotel, filtros de reportes, periodos de vacaciones.
|
|
222
|
-
|
|
223
|
-
### Mode: "default"
|
|
224
|
-
|
|
225
|
-
Sin selección (solo visualización).
|
|
226
|
-
|
|
227
|
-
```tsx
|
|
228
|
-
<Calendar mode="default" />
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
**Uso común**: Mostrar calendario estático, vista de disponibilidad sin interacción.
|
|
232
|
-
|
|
233
|
-
## Captionlayout (Navegación)
|
|
234
|
-
|
|
235
|
-
### "label" (Default)
|
|
236
|
-
|
|
237
|
-
Navegación tradicional con flechas.
|
|
238
|
-
|
|
239
|
-
```tsx
|
|
240
|
-
<Calendar captionLayout="label" />
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
**Ventaja**: UI simple, menos espacio vertical.
|
|
244
|
-
|
|
245
|
-
### "dropdown"
|
|
246
|
-
|
|
247
|
-
Dropdowns para mes Y año.
|
|
248
|
-
|
|
249
|
-
```tsx
|
|
250
|
-
<Calendar captionLayout="dropdown" fromYear={2020} toYear={2030} />
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
**Ventaja**: Saltos rápidos a fechas lejanas. Ideal para fecha de nacimiento.
|
|
254
|
-
|
|
255
|
-
### "dropdown-months"
|
|
256
|
-
|
|
257
|
-
Solo dropdown de meses, año con flechas.
|
|
258
|
-
|
|
259
|
-
```tsx
|
|
260
|
-
<Calendar captionLayout="dropdown-months" />
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
### "dropdown-years"
|
|
264
|
-
|
|
265
|
-
Solo dropdown de años, mes con flechas.
|
|
266
|
-
|
|
267
|
-
```tsx
|
|
268
|
-
<Calendar captionLayout="dropdown-years" />
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
## Patrones Avanzados
|
|
272
|
-
|
|
273
|
-
### Fechas Deshabilitadas (Múltiples Formas)
|
|
274
|
-
|
|
275
|
-
#### Fechas Específicas
|
|
276
|
-
|
|
277
|
-
```tsx
|
|
278
|
-
const disabledDays = [
|
|
279
|
-
new Date(2025, 9, 15),
|
|
280
|
-
new Date(2025, 9, 16),
|
|
281
|
-
new Date(2025, 9, 17),
|
|
282
|
-
];
|
|
283
|
-
|
|
284
|
-
<Calendar
|
|
285
|
-
mode="single"
|
|
286
|
-
disabled={disabledDays}
|
|
287
|
-
selected={date}
|
|
288
|
-
onSelect={setDate}
|
|
289
|
-
/>;
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
#### Rangos de Fechas
|
|
293
|
-
|
|
294
|
-
```tsx
|
|
295
|
-
const disabledRanges = [
|
|
296
|
-
{ from: new Date(2025, 9, 20), to: new Date(2025, 9, 25) },
|
|
297
|
-
{ from: new Date(2025, 10, 1), to: new Date(2025, 10, 7) },
|
|
298
|
-
];
|
|
299
|
-
|
|
300
|
-
<Calendar disabled={disabledRanges} />;
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
#### Función Dinámica
|
|
304
|
-
|
|
305
|
-
```tsx
|
|
306
|
-
// Deshabilitar fines de semana
|
|
307
|
-
const isWeekend = (date: Date) => {
|
|
308
|
-
const day = date.getDay();
|
|
309
|
-
return day === 0 || day === 6;
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
<Calendar disabled={isWeekend} />;
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
#### Deshabilitar Pasado
|
|
316
|
-
|
|
317
|
-
```tsx
|
|
318
|
-
// Solo fechas futuras
|
|
319
|
-
<Calendar
|
|
320
|
-
disabled={{ before: new Date() }}
|
|
321
|
-
mode="single"
|
|
322
|
-
selected={date}
|
|
323
|
-
onSelect={setDate}
|
|
324
|
-
/>
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
#### Deshabilitar Futuro
|
|
328
|
-
|
|
329
|
-
```tsx
|
|
330
|
-
// Solo fechas pasadas
|
|
331
|
-
<Calendar
|
|
332
|
-
disabled={{ after: new Date() }}
|
|
333
|
-
mode="single"
|
|
334
|
-
selected={date}
|
|
335
|
-
onSelect={setDate}
|
|
336
|
-
/>
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
### Rango de Fechas Permitidas
|
|
340
|
-
|
|
341
|
-
#### Con fromDate y toDate
|
|
342
|
-
|
|
343
|
-
```tsx
|
|
344
|
-
// Solo permitir 30 días hacia adelante
|
|
345
|
-
<Calendar
|
|
346
|
-
mode="single"
|
|
347
|
-
fromDate={new Date()}
|
|
348
|
-
toDate={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)}
|
|
349
|
-
selected={date}
|
|
350
|
-
onSelect={setDate}
|
|
351
|
-
/>
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
#### Con fromYear y toYear
|
|
355
|
-
|
|
356
|
-
```tsx
|
|
357
|
-
// Para fecha de nacimiento (mayores de 18)
|
|
358
|
-
const currentYear = new Date().getFullYear();
|
|
359
|
-
|
|
360
|
-
<Calendar
|
|
361
|
-
mode="single"
|
|
362
|
-
captionLayout="dropdown"
|
|
363
|
-
fromYear={1950}
|
|
364
|
-
toYear={currentYear - 18}
|
|
365
|
-
defaultMonth={new Date(2000, 0)}
|
|
366
|
-
selected={birthdate}
|
|
367
|
-
onSelect={setBirthdate}
|
|
368
|
-
/>;
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
### Calendario en Card
|
|
372
|
-
|
|
373
|
-
Integración perfecta con componente Card (background transparente automático).
|
|
374
|
-
|
|
375
|
-
```tsx
|
|
376
|
-
import {
|
|
377
|
-
Card,
|
|
378
|
-
CardContent,
|
|
379
|
-
CardDescription,
|
|
380
|
-
CardHeader,
|
|
381
|
-
CardTitle,
|
|
382
|
-
} from "@adamosuiteservices/ui/card";
|
|
383
|
-
|
|
384
|
-
<Card className="w-fit">
|
|
385
|
-
<CardHeader>
|
|
386
|
-
<CardTitle>Select Date</CardTitle>
|
|
387
|
-
<CardDescription>Choose a date for your appointment</CardDescription>
|
|
388
|
-
</CardHeader>
|
|
389
|
-
<CardContent>
|
|
390
|
-
<Calendar
|
|
391
|
-
mode="single"
|
|
392
|
-
selected={date}
|
|
393
|
-
onSelect={setDate}
|
|
394
|
-
captionLayout="dropdown"
|
|
395
|
-
/>
|
|
396
|
-
</CardContent>
|
|
397
|
-
</Card>;
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
### Calendario en Popover
|
|
401
|
-
|
|
402
|
-
Para date pickers compactos.
|
|
403
|
-
|
|
404
|
-
```tsx
|
|
405
|
-
import {
|
|
406
|
-
Popover,
|
|
407
|
-
PopoverContent,
|
|
408
|
-
PopoverTrigger,
|
|
409
|
-
} from "@adamosuiteservices/ui/popover";
|
|
410
|
-
import { Button } from "@adamosuiteservices/ui/button";
|
|
411
|
-
import { Icon } from "@adamosuiteservices/ui/icon";
|
|
412
|
-
import { format } from "date-fns";
|
|
413
|
-
|
|
414
|
-
function DatePickerPopover() {
|
|
415
|
-
const [date, setDate] = useState<Date>();
|
|
416
|
-
|
|
417
|
-
return (
|
|
418
|
-
<Popover>
|
|
419
|
-
<PopoverTrigger asChild>
|
|
420
|
-
<Button variant="outline">
|
|
421
|
-
<Icon symbol="calendar_today" />
|
|
422
|
-
{date ? format(date, "PPP") : "Pick a date"}
|
|
423
|
-
</Button>
|
|
424
|
-
</PopoverTrigger>
|
|
425
|
-
<PopoverContent className="w-auto p-0">
|
|
426
|
-
<Calendar
|
|
427
|
-
mode="single"
|
|
428
|
-
selected={date}
|
|
429
|
-
onSelect={setDate}
|
|
430
|
-
initialFocus
|
|
431
|
-
/>
|
|
432
|
-
</PopoverContent>
|
|
433
|
-
</Popover>
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
**Nota**: El background se vuelve transparente automáticamente dentro de PopoverContent.
|
|
439
|
-
|
|
440
|
-
### Con Timezone
|
|
441
|
-
|
|
442
|
-
```tsx
|
|
443
|
-
import { useState } from "react";
|
|
444
|
-
|
|
445
|
-
function TimezoneCalendar() {
|
|
446
|
-
const [date, setDate] = useState<Date>();
|
|
447
|
-
const [timeZone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
448
|
-
|
|
449
|
-
return (
|
|
450
|
-
<div className="space-y-2">
|
|
451
|
-
<p className="text-sm text-muted-foreground">Timezone: {timeZone}</p>
|
|
452
|
-
<Calendar
|
|
453
|
-
mode="single"
|
|
454
|
-
selected={date}
|
|
455
|
-
onSelect={setDate}
|
|
456
|
-
timeZone={timeZone}
|
|
457
|
-
className="rounded-md border"
|
|
458
|
-
/>
|
|
459
|
-
</div>
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
### Sin Días Externos (Outside Days)
|
|
465
|
-
|
|
466
|
-
Oculta días de meses adyacentes para UI más limpia.
|
|
467
|
-
|
|
468
|
-
```tsx
|
|
469
|
-
<Calendar
|
|
470
|
-
mode="single"
|
|
471
|
-
selected={date}
|
|
472
|
-
onSelect={setDate}
|
|
473
|
-
showOutsideDays={false}
|
|
474
|
-
className="rounded-md border"
|
|
475
|
-
/>
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
### Con Números de Semana
|
|
479
|
-
|
|
480
|
-
Útil para reportes empresariales o planificación semanal.
|
|
481
|
-
|
|
482
|
-
```tsx
|
|
483
|
-
<Calendar
|
|
484
|
-
mode="single"
|
|
485
|
-
selected={date}
|
|
486
|
-
onSelect={setDate}
|
|
487
|
-
showWeekNumber
|
|
488
|
-
className="rounded-md border"
|
|
489
|
-
/>
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
**Formato**: Números de semana según ISO 8601 (1-53).
|
|
493
|
-
|
|
494
|
-
### Variantes de Botones de Navegación
|
|
495
|
-
|
|
496
|
-
Personaliza la apariencia de los botones prev/next.
|
|
497
|
-
|
|
498
|
-
```tsx
|
|
499
|
-
<div className="space-y-4">
|
|
500
|
-
{/* Ghost (default) */}
|
|
501
|
-
<Calendar mode="single" buttonVariant="ghost" className="rounded-md border" />
|
|
502
|
-
|
|
503
|
-
{/* Outline */}
|
|
504
|
-
<Calendar
|
|
505
|
-
mode="single"
|
|
506
|
-
buttonVariant="outline"
|
|
507
|
-
className="rounded-md border"
|
|
508
|
-
/>
|
|
509
|
-
|
|
510
|
-
{/* Secondary */}
|
|
511
|
-
<Calendar
|
|
512
|
-
mode="single"
|
|
513
|
-
buttonVariant="secondary"
|
|
514
|
-
className="rounded-md border"
|
|
515
|
-
/>
|
|
516
|
-
</div>
|
|
517
|
-
```
|
|
518
|
-
|
|
519
|
-
### Rango Preseleccionado (Últimos 7 Días)
|
|
520
|
-
|
|
521
|
-
```tsx
|
|
522
|
-
import { subDays } from "date-fns";
|
|
523
|
-
|
|
524
|
-
function Last7DaysRange() {
|
|
525
|
-
const [range, setRange] = useState<DateRange>({
|
|
526
|
-
from: subDays(new Date(), 7),
|
|
527
|
-
to: new Date(),
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
return (
|
|
531
|
-
<Calendar
|
|
532
|
-
mode="range"
|
|
533
|
-
selected={range}
|
|
534
|
-
onSelect={setRange}
|
|
535
|
-
numberOfMonths={2}
|
|
536
|
-
className="rounded-md border"
|
|
537
|
-
/>
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
### Validación de Rango Máximo
|
|
543
|
-
|
|
544
|
-
Limita la cantidad de días seleccionables en un rango.
|
|
545
|
-
|
|
546
|
-
```tsx
|
|
547
|
-
import { differenceInDays } from "date-fns";
|
|
548
|
-
|
|
549
|
-
function MaxRangeCalendar() {
|
|
550
|
-
const [range, setRange] = useState<DateRange>();
|
|
551
|
-
const MAX_DAYS = 14;
|
|
552
|
-
|
|
553
|
-
const handleSelect = (selectedRange: DateRange | undefined) => {
|
|
554
|
-
if (!selectedRange?.from || !selectedRange?.to) {
|
|
555
|
-
setRange(selectedRange);
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const daysDiff = differenceInDays(selectedRange.to, selectedRange.from);
|
|
560
|
-
|
|
561
|
-
if (daysDiff > MAX_DAYS) {
|
|
562
|
-
alert(`Maximum range is ${MAX_DAYS} days`);
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
setRange(selectedRange);
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
return (
|
|
570
|
-
<div className="space-y-2">
|
|
571
|
-
<p className="text-sm text-muted-foreground">
|
|
572
|
-
Maximum range: {MAX_DAYS} days
|
|
573
|
-
</p>
|
|
574
|
-
<Calendar
|
|
575
|
-
mode="range"
|
|
576
|
-
selected={range}
|
|
577
|
-
onSelect={handleSelect}
|
|
578
|
-
numberOfMonths={2}
|
|
579
|
-
className="rounded-md border"
|
|
580
|
-
/>
|
|
581
|
-
</div>
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
### Calendario con Presets de Rango
|
|
587
|
-
|
|
588
|
-
Botones rápidos para rangos comunes.
|
|
589
|
-
|
|
590
|
-
```tsx
|
|
591
|
-
import {
|
|
592
|
-
startOfMonth,
|
|
593
|
-
endOfMonth,
|
|
594
|
-
subMonths,
|
|
595
|
-
startOfWeek,
|
|
596
|
-
endOfWeek,
|
|
597
|
-
} from "date-fns";
|
|
598
|
-
import { Button } from "@adamosuiteservices/ui/button";
|
|
599
|
-
|
|
600
|
-
function CalendarWithPresets() {
|
|
601
|
-
const [range, setRange] = useState<DateRange>();
|
|
602
|
-
|
|
603
|
-
const presets = [
|
|
604
|
-
{
|
|
605
|
-
label: "Today",
|
|
606
|
-
range: { from: new Date(), to: new Date() },
|
|
607
|
-
},
|
|
608
|
-
{
|
|
609
|
-
label: "Last 7 days",
|
|
610
|
-
range: { from: subDays(new Date(), 7), to: new Date() },
|
|
611
|
-
},
|
|
612
|
-
{
|
|
613
|
-
label: "This week",
|
|
614
|
-
range: { from: startOfWeek(new Date()), to: endOfWeek(new Date()) },
|
|
615
|
-
},
|
|
616
|
-
{
|
|
617
|
-
label: "This month",
|
|
618
|
-
range: { from: startOfMonth(new Date()), to: endOfMonth(new Date()) },
|
|
619
|
-
},
|
|
620
|
-
{
|
|
621
|
-
label: "Last month",
|
|
622
|
-
range: {
|
|
623
|
-
from: startOfMonth(subMonths(new Date(), 1)),
|
|
624
|
-
to: endOfMonth(subMonths(new Date(), 1)),
|
|
625
|
-
},
|
|
626
|
-
},
|
|
627
|
-
];
|
|
628
|
-
|
|
629
|
-
return (
|
|
630
|
-
<div className="flex gap-4">
|
|
631
|
-
<div className="flex flex-col gap-2">
|
|
632
|
-
{presets.map((preset) => (
|
|
633
|
-
<Button
|
|
634
|
-
key={preset.label}
|
|
635
|
-
variant="outline"
|
|
636
|
-
onClick={() => setRange(preset.range)}
|
|
637
|
-
className="justify-start"
|
|
638
|
-
>
|
|
639
|
-
{preset.label}
|
|
640
|
-
</Button>
|
|
641
|
-
))}
|
|
642
|
-
</div>
|
|
643
|
-
<Calendar
|
|
644
|
-
mode="range"
|
|
645
|
-
selected={range}
|
|
646
|
-
onSelect={setRange}
|
|
647
|
-
numberOfMonths={2}
|
|
648
|
-
className="rounded-md border"
|
|
649
|
-
/>
|
|
650
|
-
</div>
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
```
|
|
654
|
-
|
|
655
|
-
### Formateo Custom de Meses
|
|
656
|
-
|
|
657
|
-
```tsx
|
|
658
|
-
<Calendar
|
|
659
|
-
mode="single"
|
|
660
|
-
captionLayout="dropdown"
|
|
661
|
-
formatters={{
|
|
662
|
-
formatMonthDropdown: (date) =>
|
|
663
|
-
date.toLocaleString("es-ES", { month: "long" }),
|
|
664
|
-
}}
|
|
665
|
-
/>
|
|
666
|
-
```
|
|
667
|
-
|
|
668
|
-
### Internacionalización (i18n)
|
|
669
|
-
|
|
670
|
-
```tsx
|
|
671
|
-
import { es } from "date-fns/locale";
|
|
672
|
-
|
|
673
|
-
<Calendar
|
|
674
|
-
mode="single"
|
|
675
|
-
locale={es}
|
|
676
|
-
weekStartsOn={1} // Lunes
|
|
677
|
-
selected={date}
|
|
678
|
-
onSelect={setDate}
|
|
679
|
-
/>;
|
|
680
|
-
```
|
|
681
|
-
|
|
682
|
-
## Casos de Uso Comunes
|
|
683
|
-
|
|
684
|
-
### Date Picker Simple
|
|
685
|
-
|
|
686
|
-
```tsx
|
|
687
|
-
import {
|
|
688
|
-
Popover,
|
|
689
|
-
PopoverContent,
|
|
690
|
-
PopoverTrigger,
|
|
691
|
-
} from "@adamosuiteservices/ui/popover";
|
|
692
|
-
import { Button } from "@adamosuiteservices/ui/button";
|
|
693
|
-
import { Icon } from "@adamosuiteservices/ui/icon";
|
|
694
|
-
import { format } from "date-fns";
|
|
695
|
-
|
|
696
|
-
<Popover>
|
|
697
|
-
<PopoverTrigger asChild>
|
|
698
|
-
<Button variant="outline">
|
|
699
|
-
<Icon symbol="calendar_today" />
|
|
700
|
-
{date ? format(date, "PPP") : "Pick a date"}
|
|
701
|
-
</Button>
|
|
702
|
-
</PopoverTrigger>
|
|
703
|
-
<PopoverContent className="w-auto p-0">
|
|
704
|
-
<Calendar mode="single" selected={date} onSelect={setDate} />
|
|
705
|
-
</PopoverContent>
|
|
706
|
-
</Popover>;
|
|
707
|
-
```
|
|
708
|
-
|
|
709
|
-
### Date Range Picker
|
|
710
|
-
|
|
711
|
-
```tsx
|
|
712
|
-
<Popover>
|
|
713
|
-
<PopoverTrigger asChild>
|
|
714
|
-
<Button variant="outline">
|
|
715
|
-
<Icon symbol="calendar_today" />
|
|
716
|
-
{range?.from
|
|
717
|
-
? range.to
|
|
718
|
-
? `${format(range.from, "LLL dd")} - ${format(range.to, "LLL dd, y")}`
|
|
719
|
-
: format(range.from, "LLL dd, y")
|
|
720
|
-
: "Pick a date range"}
|
|
721
|
-
</Button>
|
|
722
|
-
</PopoverTrigger>
|
|
723
|
-
<PopoverContent className="w-auto p-0">
|
|
724
|
-
<Calendar
|
|
725
|
-
mode="range"
|
|
726
|
-
selected={range}
|
|
727
|
-
onSelect={setRange}
|
|
728
|
-
numberOfMonths={2}
|
|
729
|
-
/>
|
|
730
|
-
</PopoverContent>
|
|
731
|
-
</Popover>
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
### Fecha de Nacimiento
|
|
735
|
-
|
|
736
|
-
```tsx
|
|
737
|
-
const currentYear = new Date().getFullYear();
|
|
738
|
-
|
|
739
|
-
<Calendar
|
|
740
|
-
mode="single"
|
|
741
|
-
captionLayout="dropdown"
|
|
742
|
-
fromYear={1950}
|
|
743
|
-
toYear={currentYear - 18}
|
|
744
|
-
defaultMonth={new Date(2000, 0)}
|
|
745
|
-
selected={birthdate}
|
|
746
|
-
onSelect={setBirthdate}
|
|
747
|
-
/>;
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
### Reserva de Hotel
|
|
751
|
-
|
|
752
|
-
```tsx
|
|
753
|
-
<Calendar
|
|
754
|
-
mode="range"
|
|
755
|
-
selected={reservation}
|
|
756
|
-
onSelect={setReservation}
|
|
757
|
-
disabled={[
|
|
758
|
-
// Fechas ya reservadas
|
|
759
|
-
{ from: new Date(2025, 6, 10), to: new Date(2025, 6, 15) },
|
|
760
|
-
{ from: new Date(2025, 6, 20), to: new Date(2025, 6, 25) },
|
|
761
|
-
// No permitir pasado
|
|
762
|
-
{ before: new Date() },
|
|
763
|
-
]}
|
|
764
|
-
numberOfMonths={2}
|
|
765
|
-
className="rounded-md border"
|
|
766
|
-
/>
|
|
767
|
-
```
|
|
768
|
-
|
|
769
|
-
### Calendario de Disponibilidad (Solo Vista)
|
|
770
|
-
|
|
771
|
-
```tsx
|
|
772
|
-
const bookedDates = [
|
|
773
|
-
new Date(2025, 6, 10),
|
|
774
|
-
new Date(2025, 6, 15),
|
|
775
|
-
new Date(2025, 6, 20),
|
|
776
|
-
];
|
|
777
|
-
|
|
778
|
-
<Calendar
|
|
779
|
-
mode="default"
|
|
780
|
-
modifiers={{
|
|
781
|
-
booked: bookedDates,
|
|
782
|
-
}}
|
|
783
|
-
modifiersClassNames={{
|
|
784
|
-
booked: "bg-destructive/10 text-destructive",
|
|
785
|
-
}}
|
|
786
|
-
className="rounded-md border"
|
|
787
|
-
/>;
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
### Filtro de Reportes
|
|
791
|
-
|
|
792
|
-
```tsx
|
|
793
|
-
import { startOfMonth, endOfMonth } from "date-fns";
|
|
794
|
-
|
|
795
|
-
function ReportFilter() {
|
|
796
|
-
const [range, setRange] = useState<DateRange>({
|
|
797
|
-
from: startOfMonth(new Date()),
|
|
798
|
-
to: endOfMonth(new Date()),
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
return (
|
|
802
|
-
<div className="space-y-4">
|
|
803
|
-
<Calendar
|
|
804
|
-
mode="range"
|
|
805
|
-
selected={range}
|
|
806
|
-
onSelect={setRange}
|
|
807
|
-
numberOfMonths={2}
|
|
808
|
-
className="rounded-md border"
|
|
809
|
-
/>
|
|
810
|
-
<Button onClick={() => fetchReport(range)}>Generate Report</Button>
|
|
811
|
-
</div>
|
|
812
|
-
);
|
|
813
|
-
}
|
|
814
|
-
```
|
|
815
|
-
|
|
816
|
-
## Mejores Prácticas
|
|
817
|
-
|
|
818
|
-
### Usa defaultMonth para Rangos
|
|
819
|
-
|
|
820
|
-
```tsx
|
|
821
|
-
{
|
|
822
|
-
/* ✅ Correcto - Muestra el mes de inicio del rango */
|
|
823
|
-
}
|
|
824
|
-
<Calendar
|
|
825
|
-
mode="range"
|
|
826
|
-
defaultMonth={range?.from}
|
|
827
|
-
selected={range}
|
|
828
|
-
onSelect={setRange}
|
|
829
|
-
/>;
|
|
830
|
-
|
|
831
|
-
{
|
|
832
|
-
/* ❌ Incorrecto - Puede mostrar mes actual sin datos */
|
|
833
|
-
}
|
|
834
|
-
<Calendar mode="range" selected={range} onSelect={setRange} />;
|
|
835
|
-
```
|
|
836
|
-
|
|
837
|
-
### Usa numberOfMonths con Rangos Largos
|
|
838
|
-
|
|
839
|
-
```tsx
|
|
840
|
-
{
|
|
841
|
-
/* ✅ Correcto - 2 meses para rangos */
|
|
842
|
-
}
|
|
843
|
-
<Calendar
|
|
844
|
-
mode="range"
|
|
845
|
-
numberOfMonths={2}
|
|
846
|
-
selected={range}
|
|
847
|
-
onSelect={setRange}
|
|
848
|
-
/>;
|
|
849
|
-
|
|
850
|
-
{
|
|
851
|
-
/* ⚠️ Aceptable - 1 mes para rangos cortos */
|
|
852
|
-
}
|
|
853
|
-
<Calendar mode="range" selected={range} onSelect={setRange} />;
|
|
854
|
-
```
|
|
855
|
-
|
|
856
|
-
### Usa captionLayout="dropdown" para Fechas Lejanas
|
|
857
|
-
|
|
858
|
-
```tsx
|
|
859
|
-
{
|
|
860
|
-
/* ✅ Correcto - Fecha de nacimiento con dropdowns */
|
|
861
|
-
}
|
|
862
|
-
<Calendar
|
|
863
|
-
mode="single"
|
|
864
|
-
captionLayout="dropdown"
|
|
865
|
-
fromYear={1950}
|
|
866
|
-
toYear={2010}
|
|
867
|
-
defaultMonth={new Date(1990, 0)}
|
|
868
|
-
/>;
|
|
869
|
-
|
|
870
|
-
{
|
|
871
|
-
/* ❌ Incorrecto - Navegación con flechas para 30+ años */
|
|
872
|
-
}
|
|
873
|
-
<Calendar mode="single" captionLayout="label" fromYear={1950} />;
|
|
874
|
-
```
|
|
875
|
-
|
|
876
|
-
### Deshabilita Pasado para Reservas
|
|
877
|
-
|
|
878
|
-
```tsx
|
|
879
|
-
{
|
|
880
|
-
/* ✅ Correcto - Solo futuro */
|
|
881
|
-
}
|
|
882
|
-
<Calendar
|
|
883
|
-
mode="range"
|
|
884
|
-
disabled={{ before: new Date() }}
|
|
885
|
-
selected={range}
|
|
886
|
-
onSelect={setRange}
|
|
887
|
-
/>;
|
|
888
|
-
```
|
|
889
|
-
|
|
890
|
-
### Valida Rangos en onSelect
|
|
891
|
-
|
|
892
|
-
```tsx
|
|
893
|
-
{
|
|
894
|
-
/* ✅ Correcto - Valida antes de guardar */
|
|
895
|
-
}
|
|
896
|
-
const handleSelect = (selectedRange: DateRange | undefined) => {
|
|
897
|
-
if (!selectedRange?.from || !selectedRange?.to) {
|
|
898
|
-
setRange(selectedRange);
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const daysDiff = differenceInDays(selectedRange.to, selectedRange.from);
|
|
903
|
-
|
|
904
|
-
if (daysDiff > MAX_DAYS) {
|
|
905
|
-
toast.error(`Maximum range is ${MAX_DAYS} days`);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
setRange(selectedRange);
|
|
910
|
-
};
|
|
911
|
-
|
|
912
|
-
{
|
|
913
|
-
/* ❌ Incorrecto - Sin validación */
|
|
914
|
-
}
|
|
915
|
-
<Calendar mode="range" selected={range} onSelect={setRange} />;
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
### Usa initialFocus en Popovers
|
|
919
|
-
|
|
920
|
-
```tsx
|
|
921
|
-
{
|
|
922
|
-
/* ✅ Correcto - Focus automático al abrir */
|
|
923
|
-
}
|
|
924
|
-
<PopoverContent>
|
|
925
|
-
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus />
|
|
926
|
-
</PopoverContent>;
|
|
927
|
-
|
|
928
|
-
{
|
|
929
|
-
/* ❌ Incorrecto - Sin initialFocus */
|
|
930
|
-
}
|
|
931
|
-
<PopoverContent>
|
|
932
|
-
<Calendar mode="single" selected={date} onSelect={setDate} />
|
|
933
|
-
</PopoverContent>;
|
|
934
|
-
```
|
|
935
|
-
|
|
936
|
-
### Timezone para Aplicaciones Globales
|
|
937
|
-
|
|
938
|
-
```tsx
|
|
939
|
-
{
|
|
940
|
-
/* ✅ Correcto - Timezone explícito */
|
|
941
|
-
}
|
|
942
|
-
<Calendar
|
|
943
|
-
mode="single"
|
|
944
|
-
timeZone="America/New_York"
|
|
945
|
-
selected={date}
|
|
946
|
-
onSelect={setDate}
|
|
947
|
-
/>;
|
|
948
|
-
|
|
949
|
-
{
|
|
950
|
-
/* ⚠️ Cuidado - Usa timezone del navegador por defecto */
|
|
951
|
-
}
|
|
952
|
-
<Calendar mode="single" selected={date} onSelect={setDate} />;
|
|
953
|
-
```
|
|
954
|
-
|
|
955
|
-
## Notas de Implementación
|
|
956
|
-
|
|
957
|
-
- **Basado en `react-day-picker` v9+**: Librería madura y accesible
|
|
958
|
-
- **Wrapper con mejoras**: Agrega variantes de botones, RTL support, integración con Card/Popover
|
|
959
|
-
- **CSS Variables**: Usa `--cell-size` para tamaño de celdas (default: `--spacing(8)` = 32px)
|
|
960
|
-
- **Auto-transparencia**: Background transparente dentro de `[data-slot=card-content]` y `[data-slot=popover-content]`
|
|
961
|
-
- **RTL Support**: Rotación automática de chevrons con `rtl:**:[.rdp-button_next>svg]:rotate-180`
|
|
962
|
-
- **Grid Layout**: Usa CSS Grid internamente para layout del calendario
|
|
963
|
-
- **Focus Management**: CalendarDayButton usa `useEffect` para foco automático en día con modifier `focused`
|
|
964
|
-
- **Data Attributes**: Días usan `data-selected-single`, `data-range-start`, `data-range-end`, `data-range-middle` para estilos específicos
|
|
965
|
-
- **Aspect Ratio**: Días tienen `aspect-square` para celdas perfectamente cuadradas
|
|
966
|
-
- **Button Variants**: Navegación usa `buttonVariants()` de Button component para consistencia visual
|
|
967
|
-
- **Dropdown con Overlay**: Dropdowns usan select nativo con overlay invisible (`opacity-0`) para accesibilidad
|
|
968
|
-
- **Responsive**: Múltiples meses cambian de `flex-row` a `flex-col` en móviles con `md:flex-row`
|
|
969
|
-
|
|
970
|
-
## Accesibilidad
|
|
971
|
-
|
|
972
|
-
### Navegación por Teclado
|
|
973
|
-
|
|
974
|
-
- ✅ **Arrow keys**: Navega entre días (↑ ↓ ← →)
|
|
975
|
-
- ✅ **PageUp/PageDown**: Navega entre meses
|
|
976
|
-
- ✅ **Home/End**: Primer/último día del mes
|
|
977
|
-
- ✅ **Enter/Space**: Selecciona día enfocado
|
|
978
|
-
- ✅ **Tab**: Navega entre controles (botones prev/next, dropdowns)
|
|
979
|
-
|
|
980
|
-
### ARIA Labels
|
|
981
|
-
|
|
982
|
-
```tsx
|
|
983
|
-
{
|
|
984
|
-
/* ✅ Automático - react-day-picker agrega ARIA */
|
|
985
|
-
}
|
|
986
|
-
<Calendar mode="single" selected={date} onSelect={setDate} />;
|
|
987
|
-
```
|
|
988
|
-
|
|
989
|
-
**Atributos automáticos**:
|
|
990
|
-
|
|
991
|
-
- `role="application"` en contenedor
|
|
992
|
-
- `aria-label` en días (ej: "Monday, October 31, 2025")
|
|
993
|
-
- `aria-selected="true"` en días seleccionados
|
|
994
|
-
- `aria-disabled="true"` en días deshabilitados
|
|
995
|
-
- `aria-current="date"` en día actual
|
|
996
|
-
|
|
997
|
-
### Focus Visible
|
|
998
|
-
|
|
999
|
-
Días enfocados muestran ring azul con `group-data-[focused=true]/day:ring-ring/50`.
|
|
1000
|
-
|
|
1001
|
-
### Screen Readers
|
|
1002
|
-
|
|
1003
|
-
- Anuncia mes y año actual
|
|
1004
|
-
- Anuncia días seleccionados
|
|
1005
|
-
- Anuncia rangos ("from ... to ...")
|
|
1006
|
-
- Anuncia días deshabilitados
|
|
1007
|
-
|
|
1008
|
-
### Labels Personalizadas
|
|
1009
|
-
|
|
1010
|
-
```tsx
|
|
1011
|
-
<Calendar
|
|
1012
|
-
mode="single"
|
|
1013
|
-
aria-label="Select appointment date"
|
|
1014
|
-
selected={date}
|
|
1015
|
-
onSelect={setDate}
|
|
1016
|
-
/>
|
|
1017
|
-
```
|
|
1018
|
-
|
|
1019
|
-
## Troubleshooting
|
|
1020
|
-
|
|
1021
|
-
### Calendario No Se Ve (Invisible)
|
|
1022
|
-
|
|
1023
|
-
**Problema**: El calendario se renderiza pero no es visible.
|
|
1024
|
-
|
|
1025
|
-
**Solución**:
|
|
1026
|
-
|
|
1027
|
-
```tsx
|
|
1028
|
-
// ❌ Problema - Falta CSS de react-day-picker
|
|
1029
|
-
// Asegúrate de importar el CSS global
|
|
1030
|
-
|
|
1031
|
-
// ✅ Solución - Importa en tu layout/app
|
|
1032
|
-
import "react-day-picker/style.css";
|
|
1033
|
-
```
|
|
1034
|
-
|
|
1035
|
-
### Meses Múltiples No Se Alinean
|
|
1036
|
-
|
|
1037
|
-
**Problema**: Con `numberOfMonths={2}`, los meses se apilan en desktop.
|
|
1038
|
-
|
|
1039
|
-
**Solución**:
|
|
1040
|
-
|
|
1041
|
-
```tsx
|
|
1042
|
-
// ✅ El componente usa md:flex-row automáticamente
|
|
1043
|
-
// Si sigue apilando, verifica que Tailwind tenga @media queries compiladas
|
|
1044
|
-
<Calendar numberOfMonths={2} className="rounded-md border" />
|
|
1045
|
-
```
|
|
1046
|
-
|
|
1047
|
-
### Dropdowns No Funcionan
|
|
1048
|
-
|
|
1049
|
-
**Problema**: Los dropdowns de mes/año no responden.
|
|
1050
|
-
|
|
1051
|
-
**Solución**:
|
|
1052
|
-
|
|
1053
|
-
```tsx
|
|
1054
|
-
// ❌ Problema - Sin fromYear/toYear
|
|
1055
|
-
<Calendar captionLayout="dropdown" />
|
|
1056
|
-
|
|
1057
|
-
// ✅ Solución - Define rango de años
|
|
1058
|
-
<Calendar
|
|
1059
|
-
captionLayout="dropdown"
|
|
1060
|
-
fromYear={2020}
|
|
1061
|
-
toYear={2030}
|
|
1062
|
-
/>
|
|
1063
|
-
```
|
|
1064
|
-
|
|
1065
|
-
### Rango No Se Completa
|
|
1066
|
-
|
|
1067
|
-
**Problema**: Al seleccionar segunda fecha, el rango se reinicia.
|
|
1068
|
-
|
|
1069
|
-
**Solución**:
|
|
1070
|
-
|
|
1071
|
-
```tsx
|
|
1072
|
-
// ✅ Verifica que el tipo sea DateRange
|
|
1073
|
-
const [range, setRange] = useState<DateRange | undefined>();
|
|
1074
|
-
|
|
1075
|
-
// ✅ Usa defaultMonth para estabilidad
|
|
1076
|
-
<Calendar
|
|
1077
|
-
mode="range"
|
|
1078
|
-
defaultMonth={range?.from}
|
|
1079
|
-
selected={range}
|
|
1080
|
-
onSelect={setRange}
|
|
1081
|
-
/>;
|
|
1082
|
-
```
|
|
1083
|
-
|
|
1084
|
-
### Días Deshabilitados Siguen Seleccionables
|
|
1085
|
-
|
|
1086
|
-
**Problema**: Días con `disabled` aún se pueden clickear.
|
|
1087
|
-
|
|
1088
|
-
**Solución**:
|
|
1089
|
-
|
|
1090
|
-
```tsx
|
|
1091
|
-
// ❌ Problema - Sintaxis incorrecta
|
|
1092
|
-
<Calendar disabled={[new Date("2025-10-15")]} /> // String no funciona
|
|
1093
|
-
|
|
1094
|
-
// ✅ Solución - Objetos Date válidos
|
|
1095
|
-
<Calendar disabled={[new Date(2025, 9, 15)]} /> // Mes 9 = Octubre
|
|
1096
|
-
```
|
|
1097
|
-
|
|
1098
|
-
### Timezone Incorrecto
|
|
1099
|
-
|
|
1100
|
-
**Problema**: Fechas se guardan en timezone diferente.
|
|
1101
|
-
|
|
1102
|
-
**Solución**:
|
|
1103
|
-
|
|
1104
|
-
```tsx
|
|
1105
|
-
// ✅ Especifica timezone explícito
|
|
1106
|
-
<Calendar mode="single" timeZone="UTC" selected={date} onSelect={setDate} />;
|
|
1107
|
-
|
|
1108
|
-
// O convierte al guardar
|
|
1109
|
-
const handleSelect = (selectedDate: Date | undefined) => {
|
|
1110
|
-
if (selectedDate) {
|
|
1111
|
-
const utcDate = new Date(selectedDate.toISOString());
|
|
1112
|
-
setDate(utcDate);
|
|
1113
|
-
}
|
|
1114
|
-
};
|
|
1115
|
-
```
|
|
1116
|
-
|
|
1117
|
-
### Popover Se Cierra al Seleccionar
|
|
1118
|
-
|
|
1119
|
-
**Problema**: El popover se cierra inmediatamente al clickear un día.
|
|
1120
|
-
|
|
1121
|
-
**Solución**:
|
|
1122
|
-
|
|
1123
|
-
```tsx
|
|
1124
|
-
// ✅ Cierra manualmente después de selección
|
|
1125
|
-
function DatePickerWithControl() {
|
|
1126
|
-
const [open, setOpen] = useState(false);
|
|
1127
|
-
const [date, setDate] = useState<Date>();
|
|
1128
|
-
|
|
1129
|
-
return (
|
|
1130
|
-
<Popover open={open} onOpenChange={setOpen}>
|
|
1131
|
-
<PopoverTrigger asChild>
|
|
1132
|
-
<Button variant="outline">
|
|
1133
|
-
<Icon symbol="calendar_today" />
|
|
1134
|
-
{date ? format(date, "PPP") : "Pick a date"}
|
|
1135
|
-
</Button>
|
|
1136
|
-
</PopoverTrigger>
|
|
1137
|
-
<PopoverContent className="w-auto p-0">
|
|
1138
|
-
<Calendar
|
|
1139
|
-
mode="single"
|
|
1140
|
-
selected={date}
|
|
1141
|
-
onSelect={(newDate) => {
|
|
1142
|
-
setDate(newDate);
|
|
1143
|
-
setOpen(false); // Cierra manualmente
|
|
1144
|
-
}}
|
|
1145
|
-
initialFocus
|
|
1146
|
-
/>
|
|
1147
|
-
</PopoverContent>
|
|
1148
|
-
</Popover>
|
|
1149
|
-
);
|
|
1150
|
-
}
|
|
1151
|
-
```
|
|
1152
|
-
|
|
1153
|
-
## Referencias
|
|
1154
|
-
|
|
1155
|
-
- **react-day-picker**: https://react-day-picker.js.org
|
|
1156
|
-
- **date-fns** (formateo): https://date-fns.org
|
|
1157
|
-
- **Radix UI Calendar** (inspiración): https://www.radix-ui.com/primitives/docs/components/calendar
|
|
1158
|
-
- **shadcn/ui Calendar**: https://ui.shadcn.com/docs/components/calendar
|
|
1159
|
-
- **ARIA Calendar Pattern**: https://www.w3.org/WAI/ARIA/apg/patterns/calendar/
|
|
1
|
+
# Calendar
|
|
2
|
+
|
|
3
|
+
Calendario interactivo altamente personalizable basado en `react-day-picker` para selección de fechas individuales, múltiples o rangos. Soporta navegación con dropdowns, múltiples meses, números de semana, timezones y fechas deshabilitadas.
|
|
4
|
+
|
|
5
|
+
## Características Principales
|
|
6
|
+
|
|
7
|
+
- **4 modos de selección**: Single (fecha única), Multiple (múltiples fechas), Range (rango de fechas), Default (sin selección)
|
|
8
|
+
- **Navegación flexible**: Botones tradicionales o dropdowns mes/año para saltos rápidos
|
|
9
|
+
- **Múltiples meses**: Visualización simultánea de 2+ meses para rangos amplios
|
|
10
|
+
- **Números de semana**: Muestra semana del año (ISO 8601)
|
|
11
|
+
- **Fechas deshabilitadas**: Bloquea fechas específicas o rangos completos
|
|
12
|
+
- **Timezone awareness**: Soporte completo para zonas horarias
|
|
13
|
+
- **Variantes de botones**: Personaliza navegación (ghost, outline, secondary, etc.)
|
|
14
|
+
- **RTL support**: Rotación automática de chevrons en idiomas right-to-left
|
|
15
|
+
- **Integración con Card/Popover**: Background transparente automático
|
|
16
|
+
- **Accesibilidad**: Navegación por teclado, ARIA labels, focus visible
|
|
17
|
+
|
|
18
|
+
## Importación
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { Calendar } from "@adamosuiteservices/ui/calendar";
|
|
22
|
+
import type { DateRange } from "react-day-picker";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Calendar } from "@adamosuiteservices/ui/calendar";
|
|
27
|
+
import type { DateRange } from "react-day-picker";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Uso Básico
|
|
31
|
+
|
|
32
|
+
### Selección de Fecha Única
|
|
33
|
+
|
|
34
|
+
El modo más común para seleccionar una sola fecha.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useState } from "react";
|
|
38
|
+
import { Calendar } from "@adamosuiteservices/ui/calendar";
|
|
39
|
+
|
|
40
|
+
function DatePicker() {
|
|
41
|
+
const [date, setDate] = useState<Date | undefined>(new Date());
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Calendar
|
|
45
|
+
mode="single"
|
|
46
|
+
selected={date}
|
|
47
|
+
onSelect={setDate}
|
|
48
|
+
className="rounded-md border"
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Navegación con Dropdowns
|
|
55
|
+
|
|
56
|
+
Para saltar rápidamente entre meses y años.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
const [date, setDate] = useState<Date | undefined>(new Date(2025, 5, 12));
|
|
60
|
+
|
|
61
|
+
<Calendar
|
|
62
|
+
mode="single"
|
|
63
|
+
selected={date}
|
|
64
|
+
onSelect={setDate}
|
|
65
|
+
captionLayout="dropdown"
|
|
66
|
+
className="rounded-md border"
|
|
67
|
+
/>;
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Ventaja**: Permite seleccionar fechas lejanas sin hacer múltiples clics en flechas.
|
|
71
|
+
|
|
72
|
+
### Rango de Fechas
|
|
73
|
+
|
|
74
|
+
Para seleccionar periodo entre dos fechas (ej: reservas de hotel).
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import type { DateRange } from "react-day-picker";
|
|
78
|
+
|
|
79
|
+
function RangePicker() {
|
|
80
|
+
const [range, setRange] = useState<DateRange | undefined>({
|
|
81
|
+
from: new Date(2025, 5, 12),
|
|
82
|
+
to: new Date(2025, 5, 18),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Calendar
|
|
87
|
+
mode="range"
|
|
88
|
+
defaultMonth={range?.from}
|
|
89
|
+
selected={range}
|
|
90
|
+
onSelect={setRange}
|
|
91
|
+
className="rounded-md border"
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Múltiples Meses
|
|
98
|
+
|
|
99
|
+
Ideal para rangos largos o visualización amplia.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
const [range, setRange] = useState<DateRange | undefined>({
|
|
103
|
+
from: new Date(2025, 5, 12),
|
|
104
|
+
to: new Date(2025, 6, 15),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
<Calendar
|
|
108
|
+
mode="range"
|
|
109
|
+
defaultMonth={range?.from}
|
|
110
|
+
selected={range}
|
|
111
|
+
onSelect={setRange}
|
|
112
|
+
numberOfMonths={2}
|
|
113
|
+
className="rounded-md border"
|
|
114
|
+
/>;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Nota**: En pantallas pequeñas, los meses se apilan verticalmente automáticamente.
|
|
118
|
+
|
|
119
|
+
### Selección Múltiple
|
|
120
|
+
|
|
121
|
+
Para seleccionar fechas no consecutivas (ej: días de entrenamiento).
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
const [dates, setDates] = useState<Date[] | undefined>([
|
|
125
|
+
new Date(2025, 5, 12),
|
|
126
|
+
new Date(2025, 5, 15),
|
|
127
|
+
new Date(2025, 5, 20),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
<Calendar
|
|
131
|
+
mode="multiple"
|
|
132
|
+
selected={dates}
|
|
133
|
+
onSelect={setDates}
|
|
134
|
+
className="rounded-md border"
|
|
135
|
+
/>;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Componentes
|
|
139
|
+
|
|
140
|
+
### Calendar
|
|
141
|
+
|
|
142
|
+
El componente principal que renderiza el calendario completo.
|
|
143
|
+
|
|
144
|
+
#### Props
|
|
145
|
+
|
|
146
|
+
| Prop | Tipo | Default | Descripción |
|
|
147
|
+
| ----------------- | ---------------------------------------------------------------- | ------------- | ------------------------------------------- |
|
|
148
|
+
| `mode` | `"single" \| "multiple" \| "range" \| "default"` | `"default"` | Modo de selección de fechas |
|
|
149
|
+
| `selected` | `Date \| Date[] \| DateRange \| undefined` | `undefined` | Fecha(s) seleccionada(s) según modo |
|
|
150
|
+
| `onSelect` | `(date) => void` | - | Callback cuando cambia la selección |
|
|
151
|
+
| `defaultMonth` | `Date` | Mes actual | Mes inicial a mostrar |
|
|
152
|
+
| `numberOfMonths` | `number` | `1` | Cantidad de meses a mostrar simultáneamente |
|
|
153
|
+
| `captionLayout` | `"label" \| "dropdown" \| "dropdown-months" \| "dropdown-years"` | `"label"` | Tipo de navegación del header |
|
|
154
|
+
| `showOutsideDays` | `boolean` | `true` | Mostrar días de meses adyacentes |
|
|
155
|
+
| `showWeekNumber` | `boolean` | `false` | Mostrar números de semana |
|
|
156
|
+
| `disabled` | `Date \| Date[] \| DateRange \| ((date: Date) => boolean)` | - | Fechas deshabilitadas |
|
|
157
|
+
| `hidden` | `Date \| Date[] \| DateRange \| ((date: Date) => boolean)` | - | Fechas ocultas completamente |
|
|
158
|
+
| `fromDate` | `Date` | - | Fecha mínima seleccionable |
|
|
159
|
+
| `toDate` | `Date` | - | Fecha máxima seleccionable |
|
|
160
|
+
| `fromMonth` | `Date` | - | Mes mínimo navegable |
|
|
161
|
+
| `toMonth` | `Date` | - | Mes máximo navegable |
|
|
162
|
+
| `fromYear` | `number` | - | Año mínimo navegable |
|
|
163
|
+
| `toYear` | `number` | - | Año máximo navegable |
|
|
164
|
+
| `timeZone` | `string` | Sistema | Zona horaria (ej: `"America/New_York"`) |
|
|
165
|
+
| `locale` | `Locale` | Sistema | Objeto Locale de `date-fns` para i18n |
|
|
166
|
+
| `weekStartsOn` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6` | `0` | Día inicio de semana (0=Domingo, 1=Lunes) |
|
|
167
|
+
| `buttonVariant` | `ButtonProps["variant"]` | `"secondary"` | Variante para botones de navegación |
|
|
168
|
+
| `className` | `string` | - | Clases CSS adicionales para el contenedor |
|
|
169
|
+
| `classNames` | `ClassNames` | - | Objeto con clases para elementos internos |
|
|
170
|
+
| `components` | `Components` | - | Componentes custom para override |
|
|
171
|
+
| `formatters` | `Formatters` | - | Funciones de formateo custom |
|
|
172
|
+
|
|
173
|
+
### CalendarDayButton
|
|
174
|
+
|
|
175
|
+
Componente interno que renderiza cada día del calendario. Raramente se usa directamente.
|
|
176
|
+
|
|
177
|
+
## Modos de Selección
|
|
178
|
+
|
|
179
|
+
### Mode: "single"
|
|
180
|
+
|
|
181
|
+
Un solo día seleccionado a la vez.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
// Tipo de selected
|
|
185
|
+
selected: Date | undefined
|
|
186
|
+
|
|
187
|
+
// Callback
|
|
188
|
+
onSelect: (date: Date | undefined) => void
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Uso común**: Fecha de nacimiento, vencimiento de documento, fecha límite.
|
|
192
|
+
|
|
193
|
+
### Mode: "multiple"
|
|
194
|
+
|
|
195
|
+
Array de días no consecutivos.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// Tipo de selected
|
|
199
|
+
selected: Date[] | undefined
|
|
200
|
+
|
|
201
|
+
// Callback
|
|
202
|
+
onSelect: (dates: Date[] | undefined) => void
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Uso común**: Días de entrenamiento, fechas de eventos recurrentes, días festivos.
|
|
206
|
+
|
|
207
|
+
### Mode: "range"
|
|
208
|
+
|
|
209
|
+
Rango continuo entre dos fechas.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
import type { DateRange } from "react-day-picker";
|
|
213
|
+
|
|
214
|
+
// Tipo de selected
|
|
215
|
+
selected: DateRange | undefined // { from?: Date; to?: Date }
|
|
216
|
+
|
|
217
|
+
// Callback
|
|
218
|
+
onSelect: (range: DateRange | undefined) => void
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Uso común**: Reservas de hotel, filtros de reportes, periodos de vacaciones.
|
|
222
|
+
|
|
223
|
+
### Mode: "default"
|
|
224
|
+
|
|
225
|
+
Sin selección (solo visualización).
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
<Calendar mode="default" />
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Uso común**: Mostrar calendario estático, vista de disponibilidad sin interacción.
|
|
232
|
+
|
|
233
|
+
## Captionlayout (Navegación)
|
|
234
|
+
|
|
235
|
+
### "label" (Default)
|
|
236
|
+
|
|
237
|
+
Navegación tradicional con flechas.
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
<Calendar captionLayout="label" />
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Ventaja**: UI simple, menos espacio vertical.
|
|
244
|
+
|
|
245
|
+
### "dropdown"
|
|
246
|
+
|
|
247
|
+
Dropdowns para mes Y año.
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
<Calendar captionLayout="dropdown" fromYear={2020} toYear={2030} />
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Ventaja**: Saltos rápidos a fechas lejanas. Ideal para fecha de nacimiento.
|
|
254
|
+
|
|
255
|
+
### "dropdown-months"
|
|
256
|
+
|
|
257
|
+
Solo dropdown de meses, año con flechas.
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
<Calendar captionLayout="dropdown-months" />
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### "dropdown-years"
|
|
264
|
+
|
|
265
|
+
Solo dropdown de años, mes con flechas.
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
<Calendar captionLayout="dropdown-years" />
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Patrones Avanzados
|
|
272
|
+
|
|
273
|
+
### Fechas Deshabilitadas (Múltiples Formas)
|
|
274
|
+
|
|
275
|
+
#### Fechas Específicas
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
278
|
+
const disabledDays = [
|
|
279
|
+
new Date(2025, 9, 15),
|
|
280
|
+
new Date(2025, 9, 16),
|
|
281
|
+
new Date(2025, 9, 17),
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
<Calendar
|
|
285
|
+
mode="single"
|
|
286
|
+
disabled={disabledDays}
|
|
287
|
+
selected={date}
|
|
288
|
+
onSelect={setDate}
|
|
289
|
+
/>;
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
#### Rangos de Fechas
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
const disabledRanges = [
|
|
296
|
+
{ from: new Date(2025, 9, 20), to: new Date(2025, 9, 25) },
|
|
297
|
+
{ from: new Date(2025, 10, 1), to: new Date(2025, 10, 7) },
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
<Calendar disabled={disabledRanges} />;
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
#### Función Dinámica
|
|
304
|
+
|
|
305
|
+
```tsx
|
|
306
|
+
// Deshabilitar fines de semana
|
|
307
|
+
const isWeekend = (date: Date) => {
|
|
308
|
+
const day = date.getDay();
|
|
309
|
+
return day === 0 || day === 6;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
<Calendar disabled={isWeekend} />;
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
#### Deshabilitar Pasado
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
// Solo fechas futuras
|
|
319
|
+
<Calendar
|
|
320
|
+
disabled={{ before: new Date() }}
|
|
321
|
+
mode="single"
|
|
322
|
+
selected={date}
|
|
323
|
+
onSelect={setDate}
|
|
324
|
+
/>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
#### Deshabilitar Futuro
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
// Solo fechas pasadas
|
|
331
|
+
<Calendar
|
|
332
|
+
disabled={{ after: new Date() }}
|
|
333
|
+
mode="single"
|
|
334
|
+
selected={date}
|
|
335
|
+
onSelect={setDate}
|
|
336
|
+
/>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Rango de Fechas Permitidas
|
|
340
|
+
|
|
341
|
+
#### Con fromDate y toDate
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
// Solo permitir 30 días hacia adelante
|
|
345
|
+
<Calendar
|
|
346
|
+
mode="single"
|
|
347
|
+
fromDate={new Date()}
|
|
348
|
+
toDate={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)}
|
|
349
|
+
selected={date}
|
|
350
|
+
onSelect={setDate}
|
|
351
|
+
/>
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
#### Con fromYear y toYear
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
// Para fecha de nacimiento (mayores de 18)
|
|
358
|
+
const currentYear = new Date().getFullYear();
|
|
359
|
+
|
|
360
|
+
<Calendar
|
|
361
|
+
mode="single"
|
|
362
|
+
captionLayout="dropdown"
|
|
363
|
+
fromYear={1950}
|
|
364
|
+
toYear={currentYear - 18}
|
|
365
|
+
defaultMonth={new Date(2000, 0)}
|
|
366
|
+
selected={birthdate}
|
|
367
|
+
onSelect={setBirthdate}
|
|
368
|
+
/>;
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Calendario en Card
|
|
372
|
+
|
|
373
|
+
Integración perfecta con componente Card (background transparente automático).
|
|
374
|
+
|
|
375
|
+
```tsx
|
|
376
|
+
import {
|
|
377
|
+
Card,
|
|
378
|
+
CardContent,
|
|
379
|
+
CardDescription,
|
|
380
|
+
CardHeader,
|
|
381
|
+
CardTitle,
|
|
382
|
+
} from "@adamosuiteservices/ui/card";
|
|
383
|
+
|
|
384
|
+
<Card className="w-fit">
|
|
385
|
+
<CardHeader>
|
|
386
|
+
<CardTitle>Select Date</CardTitle>
|
|
387
|
+
<CardDescription>Choose a date for your appointment</CardDescription>
|
|
388
|
+
</CardHeader>
|
|
389
|
+
<CardContent>
|
|
390
|
+
<Calendar
|
|
391
|
+
mode="single"
|
|
392
|
+
selected={date}
|
|
393
|
+
onSelect={setDate}
|
|
394
|
+
captionLayout="dropdown"
|
|
395
|
+
/>
|
|
396
|
+
</CardContent>
|
|
397
|
+
</Card>;
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Calendario en Popover
|
|
401
|
+
|
|
402
|
+
Para date pickers compactos.
|
|
403
|
+
|
|
404
|
+
```tsx
|
|
405
|
+
import {
|
|
406
|
+
Popover,
|
|
407
|
+
PopoverContent,
|
|
408
|
+
PopoverTrigger,
|
|
409
|
+
} from "@adamosuiteservices/ui/popover";
|
|
410
|
+
import { Button } from "@adamosuiteservices/ui/button";
|
|
411
|
+
import { Icon } from "@adamosuiteservices/ui/icon";
|
|
412
|
+
import { format } from "date-fns";
|
|
413
|
+
|
|
414
|
+
function DatePickerPopover() {
|
|
415
|
+
const [date, setDate] = useState<Date>();
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<Popover>
|
|
419
|
+
<PopoverTrigger asChild>
|
|
420
|
+
<Button variant="outline">
|
|
421
|
+
<Icon symbol="calendar_today" />
|
|
422
|
+
{date ? format(date, "PPP") : "Pick a date"}
|
|
423
|
+
</Button>
|
|
424
|
+
</PopoverTrigger>
|
|
425
|
+
<PopoverContent className="w-auto p-0">
|
|
426
|
+
<Calendar
|
|
427
|
+
mode="single"
|
|
428
|
+
selected={date}
|
|
429
|
+
onSelect={setDate}
|
|
430
|
+
initialFocus
|
|
431
|
+
/>
|
|
432
|
+
</PopoverContent>
|
|
433
|
+
</Popover>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Nota**: El background se vuelve transparente automáticamente dentro de PopoverContent.
|
|
439
|
+
|
|
440
|
+
### Con Timezone
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
import { useState } from "react";
|
|
444
|
+
|
|
445
|
+
function TimezoneCalendar() {
|
|
446
|
+
const [date, setDate] = useState<Date>();
|
|
447
|
+
const [timeZone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<div className="space-y-2">
|
|
451
|
+
<p className="text-sm text-muted-foreground">Timezone: {timeZone}</p>
|
|
452
|
+
<Calendar
|
|
453
|
+
mode="single"
|
|
454
|
+
selected={date}
|
|
455
|
+
onSelect={setDate}
|
|
456
|
+
timeZone={timeZone}
|
|
457
|
+
className="rounded-md border"
|
|
458
|
+
/>
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Sin Días Externos (Outside Days)
|
|
465
|
+
|
|
466
|
+
Oculta días de meses adyacentes para UI más limpia.
|
|
467
|
+
|
|
468
|
+
```tsx
|
|
469
|
+
<Calendar
|
|
470
|
+
mode="single"
|
|
471
|
+
selected={date}
|
|
472
|
+
onSelect={setDate}
|
|
473
|
+
showOutsideDays={false}
|
|
474
|
+
className="rounded-md border"
|
|
475
|
+
/>
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Con Números de Semana
|
|
479
|
+
|
|
480
|
+
Útil para reportes empresariales o planificación semanal.
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
<Calendar
|
|
484
|
+
mode="single"
|
|
485
|
+
selected={date}
|
|
486
|
+
onSelect={setDate}
|
|
487
|
+
showWeekNumber
|
|
488
|
+
className="rounded-md border"
|
|
489
|
+
/>
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Formato**: Números de semana según ISO 8601 (1-53).
|
|
493
|
+
|
|
494
|
+
### Variantes de Botones de Navegación
|
|
495
|
+
|
|
496
|
+
Personaliza la apariencia de los botones prev/next.
|
|
497
|
+
|
|
498
|
+
```tsx
|
|
499
|
+
<div className="space-y-4">
|
|
500
|
+
{/* Ghost (default) */}
|
|
501
|
+
<Calendar mode="single" buttonVariant="ghost" className="rounded-md border" />
|
|
502
|
+
|
|
503
|
+
{/* Outline */}
|
|
504
|
+
<Calendar
|
|
505
|
+
mode="single"
|
|
506
|
+
buttonVariant="outline"
|
|
507
|
+
className="rounded-md border"
|
|
508
|
+
/>
|
|
509
|
+
|
|
510
|
+
{/* Secondary */}
|
|
511
|
+
<Calendar
|
|
512
|
+
mode="single"
|
|
513
|
+
buttonVariant="secondary"
|
|
514
|
+
className="rounded-md border"
|
|
515
|
+
/>
|
|
516
|
+
</div>
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Rango Preseleccionado (Últimos 7 Días)
|
|
520
|
+
|
|
521
|
+
```tsx
|
|
522
|
+
import { subDays } from "date-fns";
|
|
523
|
+
|
|
524
|
+
function Last7DaysRange() {
|
|
525
|
+
const [range, setRange] = useState<DateRange>({
|
|
526
|
+
from: subDays(new Date(), 7),
|
|
527
|
+
to: new Date(),
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return (
|
|
531
|
+
<Calendar
|
|
532
|
+
mode="range"
|
|
533
|
+
selected={range}
|
|
534
|
+
onSelect={setRange}
|
|
535
|
+
numberOfMonths={2}
|
|
536
|
+
className="rounded-md border"
|
|
537
|
+
/>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Validación de Rango Máximo
|
|
543
|
+
|
|
544
|
+
Limita la cantidad de días seleccionables en un rango.
|
|
545
|
+
|
|
546
|
+
```tsx
|
|
547
|
+
import { differenceInDays } from "date-fns";
|
|
548
|
+
|
|
549
|
+
function MaxRangeCalendar() {
|
|
550
|
+
const [range, setRange] = useState<DateRange>();
|
|
551
|
+
const MAX_DAYS = 14;
|
|
552
|
+
|
|
553
|
+
const handleSelect = (selectedRange: DateRange | undefined) => {
|
|
554
|
+
if (!selectedRange?.from || !selectedRange?.to) {
|
|
555
|
+
setRange(selectedRange);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const daysDiff = differenceInDays(selectedRange.to, selectedRange.from);
|
|
560
|
+
|
|
561
|
+
if (daysDiff > MAX_DAYS) {
|
|
562
|
+
alert(`Maximum range is ${MAX_DAYS} days`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
setRange(selectedRange);
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<div className="space-y-2">
|
|
571
|
+
<p className="text-sm text-muted-foreground">
|
|
572
|
+
Maximum range: {MAX_DAYS} days
|
|
573
|
+
</p>
|
|
574
|
+
<Calendar
|
|
575
|
+
mode="range"
|
|
576
|
+
selected={range}
|
|
577
|
+
onSelect={handleSelect}
|
|
578
|
+
numberOfMonths={2}
|
|
579
|
+
className="rounded-md border"
|
|
580
|
+
/>
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### Calendario con Presets de Rango
|
|
587
|
+
|
|
588
|
+
Botones rápidos para rangos comunes.
|
|
589
|
+
|
|
590
|
+
```tsx
|
|
591
|
+
import {
|
|
592
|
+
startOfMonth,
|
|
593
|
+
endOfMonth,
|
|
594
|
+
subMonths,
|
|
595
|
+
startOfWeek,
|
|
596
|
+
endOfWeek,
|
|
597
|
+
} from "date-fns";
|
|
598
|
+
import { Button } from "@adamosuiteservices/ui/button";
|
|
599
|
+
|
|
600
|
+
function CalendarWithPresets() {
|
|
601
|
+
const [range, setRange] = useState<DateRange>();
|
|
602
|
+
|
|
603
|
+
const presets = [
|
|
604
|
+
{
|
|
605
|
+
label: "Today",
|
|
606
|
+
range: { from: new Date(), to: new Date() },
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
label: "Last 7 days",
|
|
610
|
+
range: { from: subDays(new Date(), 7), to: new Date() },
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
label: "This week",
|
|
614
|
+
range: { from: startOfWeek(new Date()), to: endOfWeek(new Date()) },
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
label: "This month",
|
|
618
|
+
range: { from: startOfMonth(new Date()), to: endOfMonth(new Date()) },
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
label: "Last month",
|
|
622
|
+
range: {
|
|
623
|
+
from: startOfMonth(subMonths(new Date(), 1)),
|
|
624
|
+
to: endOfMonth(subMonths(new Date(), 1)),
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
return (
|
|
630
|
+
<div className="flex gap-4">
|
|
631
|
+
<div className="flex flex-col gap-2">
|
|
632
|
+
{presets.map((preset) => (
|
|
633
|
+
<Button
|
|
634
|
+
key={preset.label}
|
|
635
|
+
variant="outline"
|
|
636
|
+
onClick={() => setRange(preset.range)}
|
|
637
|
+
className="justify-start"
|
|
638
|
+
>
|
|
639
|
+
{preset.label}
|
|
640
|
+
</Button>
|
|
641
|
+
))}
|
|
642
|
+
</div>
|
|
643
|
+
<Calendar
|
|
644
|
+
mode="range"
|
|
645
|
+
selected={range}
|
|
646
|
+
onSelect={setRange}
|
|
647
|
+
numberOfMonths={2}
|
|
648
|
+
className="rounded-md border"
|
|
649
|
+
/>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Formateo Custom de Meses
|
|
656
|
+
|
|
657
|
+
```tsx
|
|
658
|
+
<Calendar
|
|
659
|
+
mode="single"
|
|
660
|
+
captionLayout="dropdown"
|
|
661
|
+
formatters={{
|
|
662
|
+
formatMonthDropdown: (date) =>
|
|
663
|
+
date.toLocaleString("es-ES", { month: "long" }),
|
|
664
|
+
}}
|
|
665
|
+
/>
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Internacionalización (i18n)
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
import { es } from "date-fns/locale";
|
|
672
|
+
|
|
673
|
+
<Calendar
|
|
674
|
+
mode="single"
|
|
675
|
+
locale={es}
|
|
676
|
+
weekStartsOn={1} // Lunes
|
|
677
|
+
selected={date}
|
|
678
|
+
onSelect={setDate}
|
|
679
|
+
/>;
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
## Casos de Uso Comunes
|
|
683
|
+
|
|
684
|
+
### Date Picker Simple
|
|
685
|
+
|
|
686
|
+
```tsx
|
|
687
|
+
import {
|
|
688
|
+
Popover,
|
|
689
|
+
PopoverContent,
|
|
690
|
+
PopoverTrigger,
|
|
691
|
+
} from "@adamosuiteservices/ui/popover";
|
|
692
|
+
import { Button } from "@adamosuiteservices/ui/button";
|
|
693
|
+
import { Icon } from "@adamosuiteservices/ui/icon";
|
|
694
|
+
import { format } from "date-fns";
|
|
695
|
+
|
|
696
|
+
<Popover>
|
|
697
|
+
<PopoverTrigger asChild>
|
|
698
|
+
<Button variant="outline">
|
|
699
|
+
<Icon symbol="calendar_today" />
|
|
700
|
+
{date ? format(date, "PPP") : "Pick a date"}
|
|
701
|
+
</Button>
|
|
702
|
+
</PopoverTrigger>
|
|
703
|
+
<PopoverContent className="w-auto p-0">
|
|
704
|
+
<Calendar mode="single" selected={date} onSelect={setDate} />
|
|
705
|
+
</PopoverContent>
|
|
706
|
+
</Popover>;
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Date Range Picker
|
|
710
|
+
|
|
711
|
+
```tsx
|
|
712
|
+
<Popover>
|
|
713
|
+
<PopoverTrigger asChild>
|
|
714
|
+
<Button variant="outline">
|
|
715
|
+
<Icon symbol="calendar_today" />
|
|
716
|
+
{range?.from
|
|
717
|
+
? range.to
|
|
718
|
+
? `${format(range.from, "LLL dd")} - ${format(range.to, "LLL dd, y")}`
|
|
719
|
+
: format(range.from, "LLL dd, y")
|
|
720
|
+
: "Pick a date range"}
|
|
721
|
+
</Button>
|
|
722
|
+
</PopoverTrigger>
|
|
723
|
+
<PopoverContent className="w-auto p-0">
|
|
724
|
+
<Calendar
|
|
725
|
+
mode="range"
|
|
726
|
+
selected={range}
|
|
727
|
+
onSelect={setRange}
|
|
728
|
+
numberOfMonths={2}
|
|
729
|
+
/>
|
|
730
|
+
</PopoverContent>
|
|
731
|
+
</Popover>
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Fecha de Nacimiento
|
|
735
|
+
|
|
736
|
+
```tsx
|
|
737
|
+
const currentYear = new Date().getFullYear();
|
|
738
|
+
|
|
739
|
+
<Calendar
|
|
740
|
+
mode="single"
|
|
741
|
+
captionLayout="dropdown"
|
|
742
|
+
fromYear={1950}
|
|
743
|
+
toYear={currentYear - 18}
|
|
744
|
+
defaultMonth={new Date(2000, 0)}
|
|
745
|
+
selected={birthdate}
|
|
746
|
+
onSelect={setBirthdate}
|
|
747
|
+
/>;
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### Reserva de Hotel
|
|
751
|
+
|
|
752
|
+
```tsx
|
|
753
|
+
<Calendar
|
|
754
|
+
mode="range"
|
|
755
|
+
selected={reservation}
|
|
756
|
+
onSelect={setReservation}
|
|
757
|
+
disabled={[
|
|
758
|
+
// Fechas ya reservadas
|
|
759
|
+
{ from: new Date(2025, 6, 10), to: new Date(2025, 6, 15) },
|
|
760
|
+
{ from: new Date(2025, 6, 20), to: new Date(2025, 6, 25) },
|
|
761
|
+
// No permitir pasado
|
|
762
|
+
{ before: new Date() },
|
|
763
|
+
]}
|
|
764
|
+
numberOfMonths={2}
|
|
765
|
+
className="rounded-md border"
|
|
766
|
+
/>
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
### Calendario de Disponibilidad (Solo Vista)
|
|
770
|
+
|
|
771
|
+
```tsx
|
|
772
|
+
const bookedDates = [
|
|
773
|
+
new Date(2025, 6, 10),
|
|
774
|
+
new Date(2025, 6, 15),
|
|
775
|
+
new Date(2025, 6, 20),
|
|
776
|
+
];
|
|
777
|
+
|
|
778
|
+
<Calendar
|
|
779
|
+
mode="default"
|
|
780
|
+
modifiers={{
|
|
781
|
+
booked: bookedDates,
|
|
782
|
+
}}
|
|
783
|
+
modifiersClassNames={{
|
|
784
|
+
booked: "bg-destructive/10 text-destructive",
|
|
785
|
+
}}
|
|
786
|
+
className="rounded-md border"
|
|
787
|
+
/>;
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
### Filtro de Reportes
|
|
791
|
+
|
|
792
|
+
```tsx
|
|
793
|
+
import { startOfMonth, endOfMonth } from "date-fns";
|
|
794
|
+
|
|
795
|
+
function ReportFilter() {
|
|
796
|
+
const [range, setRange] = useState<DateRange>({
|
|
797
|
+
from: startOfMonth(new Date()),
|
|
798
|
+
to: endOfMonth(new Date()),
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<div className="space-y-4">
|
|
803
|
+
<Calendar
|
|
804
|
+
mode="range"
|
|
805
|
+
selected={range}
|
|
806
|
+
onSelect={setRange}
|
|
807
|
+
numberOfMonths={2}
|
|
808
|
+
className="rounded-md border"
|
|
809
|
+
/>
|
|
810
|
+
<Button onClick={() => fetchReport(range)}>Generate Report</Button>
|
|
811
|
+
</div>
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
## Mejores Prácticas
|
|
817
|
+
|
|
818
|
+
### Usa defaultMonth para Rangos
|
|
819
|
+
|
|
820
|
+
```tsx
|
|
821
|
+
{
|
|
822
|
+
/* ✅ Correcto - Muestra el mes de inicio del rango */
|
|
823
|
+
}
|
|
824
|
+
<Calendar
|
|
825
|
+
mode="range"
|
|
826
|
+
defaultMonth={range?.from}
|
|
827
|
+
selected={range}
|
|
828
|
+
onSelect={setRange}
|
|
829
|
+
/>;
|
|
830
|
+
|
|
831
|
+
{
|
|
832
|
+
/* ❌ Incorrecto - Puede mostrar mes actual sin datos */
|
|
833
|
+
}
|
|
834
|
+
<Calendar mode="range" selected={range} onSelect={setRange} />;
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
### Usa numberOfMonths con Rangos Largos
|
|
838
|
+
|
|
839
|
+
```tsx
|
|
840
|
+
{
|
|
841
|
+
/* ✅ Correcto - 2 meses para rangos */
|
|
842
|
+
}
|
|
843
|
+
<Calendar
|
|
844
|
+
mode="range"
|
|
845
|
+
numberOfMonths={2}
|
|
846
|
+
selected={range}
|
|
847
|
+
onSelect={setRange}
|
|
848
|
+
/>;
|
|
849
|
+
|
|
850
|
+
{
|
|
851
|
+
/* ⚠️ Aceptable - 1 mes para rangos cortos */
|
|
852
|
+
}
|
|
853
|
+
<Calendar mode="range" selected={range} onSelect={setRange} />;
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Usa captionLayout="dropdown" para Fechas Lejanas
|
|
857
|
+
|
|
858
|
+
```tsx
|
|
859
|
+
{
|
|
860
|
+
/* ✅ Correcto - Fecha de nacimiento con dropdowns */
|
|
861
|
+
}
|
|
862
|
+
<Calendar
|
|
863
|
+
mode="single"
|
|
864
|
+
captionLayout="dropdown"
|
|
865
|
+
fromYear={1950}
|
|
866
|
+
toYear={2010}
|
|
867
|
+
defaultMonth={new Date(1990, 0)}
|
|
868
|
+
/>;
|
|
869
|
+
|
|
870
|
+
{
|
|
871
|
+
/* ❌ Incorrecto - Navegación con flechas para 30+ años */
|
|
872
|
+
}
|
|
873
|
+
<Calendar mode="single" captionLayout="label" fromYear={1950} />;
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Deshabilita Pasado para Reservas
|
|
877
|
+
|
|
878
|
+
```tsx
|
|
879
|
+
{
|
|
880
|
+
/* ✅ Correcto - Solo futuro */
|
|
881
|
+
}
|
|
882
|
+
<Calendar
|
|
883
|
+
mode="range"
|
|
884
|
+
disabled={{ before: new Date() }}
|
|
885
|
+
selected={range}
|
|
886
|
+
onSelect={setRange}
|
|
887
|
+
/>;
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
### Valida Rangos en onSelect
|
|
891
|
+
|
|
892
|
+
```tsx
|
|
893
|
+
{
|
|
894
|
+
/* ✅ Correcto - Valida antes de guardar */
|
|
895
|
+
}
|
|
896
|
+
const handleSelect = (selectedRange: DateRange | undefined) => {
|
|
897
|
+
if (!selectedRange?.from || !selectedRange?.to) {
|
|
898
|
+
setRange(selectedRange);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const daysDiff = differenceInDays(selectedRange.to, selectedRange.from);
|
|
903
|
+
|
|
904
|
+
if (daysDiff > MAX_DAYS) {
|
|
905
|
+
toast.error(`Maximum range is ${MAX_DAYS} days`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
setRange(selectedRange);
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
{
|
|
913
|
+
/* ❌ Incorrecto - Sin validación */
|
|
914
|
+
}
|
|
915
|
+
<Calendar mode="range" selected={range} onSelect={setRange} />;
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
### Usa initialFocus en Popovers
|
|
919
|
+
|
|
920
|
+
```tsx
|
|
921
|
+
{
|
|
922
|
+
/* ✅ Correcto - Focus automático al abrir */
|
|
923
|
+
}
|
|
924
|
+
<PopoverContent>
|
|
925
|
+
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus />
|
|
926
|
+
</PopoverContent>;
|
|
927
|
+
|
|
928
|
+
{
|
|
929
|
+
/* ❌ Incorrecto - Sin initialFocus */
|
|
930
|
+
}
|
|
931
|
+
<PopoverContent>
|
|
932
|
+
<Calendar mode="single" selected={date} onSelect={setDate} />
|
|
933
|
+
</PopoverContent>;
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
### Timezone para Aplicaciones Globales
|
|
937
|
+
|
|
938
|
+
```tsx
|
|
939
|
+
{
|
|
940
|
+
/* ✅ Correcto - Timezone explícito */
|
|
941
|
+
}
|
|
942
|
+
<Calendar
|
|
943
|
+
mode="single"
|
|
944
|
+
timeZone="America/New_York"
|
|
945
|
+
selected={date}
|
|
946
|
+
onSelect={setDate}
|
|
947
|
+
/>;
|
|
948
|
+
|
|
949
|
+
{
|
|
950
|
+
/* ⚠️ Cuidado - Usa timezone del navegador por defecto */
|
|
951
|
+
}
|
|
952
|
+
<Calendar mode="single" selected={date} onSelect={setDate} />;
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
## Notas de Implementación
|
|
956
|
+
|
|
957
|
+
- **Basado en `react-day-picker` v9+**: Librería madura y accesible
|
|
958
|
+
- **Wrapper con mejoras**: Agrega variantes de botones, RTL support, integración con Card/Popover
|
|
959
|
+
- **CSS Variables**: Usa `--cell-size` para tamaño de celdas (default: `--spacing(8)` = 32px)
|
|
960
|
+
- **Auto-transparencia**: Background transparente dentro de `[data-slot=card-content]` y `[data-slot=popover-content]`
|
|
961
|
+
- **RTL Support**: Rotación automática de chevrons con `rtl:**:[.rdp-button_next>svg]:rotate-180`
|
|
962
|
+
- **Grid Layout**: Usa CSS Grid internamente para layout del calendario
|
|
963
|
+
- **Focus Management**: CalendarDayButton usa `useEffect` para foco automático en día con modifier `focused`
|
|
964
|
+
- **Data Attributes**: Días usan `data-selected-single`, `data-range-start`, `data-range-end`, `data-range-middle` para estilos específicos
|
|
965
|
+
- **Aspect Ratio**: Días tienen `aspect-square` para celdas perfectamente cuadradas
|
|
966
|
+
- **Button Variants**: Navegación usa `buttonVariants()` de Button component para consistencia visual
|
|
967
|
+
- **Dropdown con Overlay**: Dropdowns usan select nativo con overlay invisible (`opacity-0`) para accesibilidad
|
|
968
|
+
- **Responsive**: Múltiples meses cambian de `flex-row` a `flex-col` en móviles con `md:flex-row`
|
|
969
|
+
|
|
970
|
+
## Accesibilidad
|
|
971
|
+
|
|
972
|
+
### Navegación por Teclado
|
|
973
|
+
|
|
974
|
+
- ✅ **Arrow keys**: Navega entre días (↑ ↓ ← →)
|
|
975
|
+
- ✅ **PageUp/PageDown**: Navega entre meses
|
|
976
|
+
- ✅ **Home/End**: Primer/último día del mes
|
|
977
|
+
- ✅ **Enter/Space**: Selecciona día enfocado
|
|
978
|
+
- ✅ **Tab**: Navega entre controles (botones prev/next, dropdowns)
|
|
979
|
+
|
|
980
|
+
### ARIA Labels
|
|
981
|
+
|
|
982
|
+
```tsx
|
|
983
|
+
{
|
|
984
|
+
/* ✅ Automático - react-day-picker agrega ARIA */
|
|
985
|
+
}
|
|
986
|
+
<Calendar mode="single" selected={date} onSelect={setDate} />;
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
**Atributos automáticos**:
|
|
990
|
+
|
|
991
|
+
- `role="application"` en contenedor
|
|
992
|
+
- `aria-label` en días (ej: "Monday, October 31, 2025")
|
|
993
|
+
- `aria-selected="true"` en días seleccionados
|
|
994
|
+
- `aria-disabled="true"` en días deshabilitados
|
|
995
|
+
- `aria-current="date"` en día actual
|
|
996
|
+
|
|
997
|
+
### Focus Visible
|
|
998
|
+
|
|
999
|
+
Días enfocados muestran ring azul con `group-data-[focused=true]/day:ring-ring/50`.
|
|
1000
|
+
|
|
1001
|
+
### Screen Readers
|
|
1002
|
+
|
|
1003
|
+
- Anuncia mes y año actual
|
|
1004
|
+
- Anuncia días seleccionados
|
|
1005
|
+
- Anuncia rangos ("from ... to ...")
|
|
1006
|
+
- Anuncia días deshabilitados
|
|
1007
|
+
|
|
1008
|
+
### Labels Personalizadas
|
|
1009
|
+
|
|
1010
|
+
```tsx
|
|
1011
|
+
<Calendar
|
|
1012
|
+
mode="single"
|
|
1013
|
+
aria-label="Select appointment date"
|
|
1014
|
+
selected={date}
|
|
1015
|
+
onSelect={setDate}
|
|
1016
|
+
/>
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
## Troubleshooting
|
|
1020
|
+
|
|
1021
|
+
### Calendario No Se Ve (Invisible)
|
|
1022
|
+
|
|
1023
|
+
**Problema**: El calendario se renderiza pero no es visible.
|
|
1024
|
+
|
|
1025
|
+
**Solución**:
|
|
1026
|
+
|
|
1027
|
+
```tsx
|
|
1028
|
+
// ❌ Problema - Falta CSS de react-day-picker
|
|
1029
|
+
// Asegúrate de importar el CSS global
|
|
1030
|
+
|
|
1031
|
+
// ✅ Solución - Importa en tu layout/app
|
|
1032
|
+
import "react-day-picker/style.css";
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
### Meses Múltiples No Se Alinean
|
|
1036
|
+
|
|
1037
|
+
**Problema**: Con `numberOfMonths={2}`, los meses se apilan en desktop.
|
|
1038
|
+
|
|
1039
|
+
**Solución**:
|
|
1040
|
+
|
|
1041
|
+
```tsx
|
|
1042
|
+
// ✅ El componente usa md:flex-row automáticamente
|
|
1043
|
+
// Si sigue apilando, verifica que Tailwind tenga @media queries compiladas
|
|
1044
|
+
<Calendar numberOfMonths={2} className="rounded-md border" />
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
### Dropdowns No Funcionan
|
|
1048
|
+
|
|
1049
|
+
**Problema**: Los dropdowns de mes/año no responden.
|
|
1050
|
+
|
|
1051
|
+
**Solución**:
|
|
1052
|
+
|
|
1053
|
+
```tsx
|
|
1054
|
+
// ❌ Problema - Sin fromYear/toYear
|
|
1055
|
+
<Calendar captionLayout="dropdown" />
|
|
1056
|
+
|
|
1057
|
+
// ✅ Solución - Define rango de años
|
|
1058
|
+
<Calendar
|
|
1059
|
+
captionLayout="dropdown"
|
|
1060
|
+
fromYear={2020}
|
|
1061
|
+
toYear={2030}
|
|
1062
|
+
/>
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
### Rango No Se Completa
|
|
1066
|
+
|
|
1067
|
+
**Problema**: Al seleccionar segunda fecha, el rango se reinicia.
|
|
1068
|
+
|
|
1069
|
+
**Solución**:
|
|
1070
|
+
|
|
1071
|
+
```tsx
|
|
1072
|
+
// ✅ Verifica que el tipo sea DateRange
|
|
1073
|
+
const [range, setRange] = useState<DateRange | undefined>();
|
|
1074
|
+
|
|
1075
|
+
// ✅ Usa defaultMonth para estabilidad
|
|
1076
|
+
<Calendar
|
|
1077
|
+
mode="range"
|
|
1078
|
+
defaultMonth={range?.from}
|
|
1079
|
+
selected={range}
|
|
1080
|
+
onSelect={setRange}
|
|
1081
|
+
/>;
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
### Días Deshabilitados Siguen Seleccionables
|
|
1085
|
+
|
|
1086
|
+
**Problema**: Días con `disabled` aún se pueden clickear.
|
|
1087
|
+
|
|
1088
|
+
**Solución**:
|
|
1089
|
+
|
|
1090
|
+
```tsx
|
|
1091
|
+
// ❌ Problema - Sintaxis incorrecta
|
|
1092
|
+
<Calendar disabled={[new Date("2025-10-15")]} /> // String no funciona
|
|
1093
|
+
|
|
1094
|
+
// ✅ Solución - Objetos Date válidos
|
|
1095
|
+
<Calendar disabled={[new Date(2025, 9, 15)]} /> // Mes 9 = Octubre
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### Timezone Incorrecto
|
|
1099
|
+
|
|
1100
|
+
**Problema**: Fechas se guardan en timezone diferente.
|
|
1101
|
+
|
|
1102
|
+
**Solución**:
|
|
1103
|
+
|
|
1104
|
+
```tsx
|
|
1105
|
+
// ✅ Especifica timezone explícito
|
|
1106
|
+
<Calendar mode="single" timeZone="UTC" selected={date} onSelect={setDate} />;
|
|
1107
|
+
|
|
1108
|
+
// O convierte al guardar
|
|
1109
|
+
const handleSelect = (selectedDate: Date | undefined) => {
|
|
1110
|
+
if (selectedDate) {
|
|
1111
|
+
const utcDate = new Date(selectedDate.toISOString());
|
|
1112
|
+
setDate(utcDate);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
### Popover Se Cierra al Seleccionar
|
|
1118
|
+
|
|
1119
|
+
**Problema**: El popover se cierra inmediatamente al clickear un día.
|
|
1120
|
+
|
|
1121
|
+
**Solución**:
|
|
1122
|
+
|
|
1123
|
+
```tsx
|
|
1124
|
+
// ✅ Cierra manualmente después de selección
|
|
1125
|
+
function DatePickerWithControl() {
|
|
1126
|
+
const [open, setOpen] = useState(false);
|
|
1127
|
+
const [date, setDate] = useState<Date>();
|
|
1128
|
+
|
|
1129
|
+
return (
|
|
1130
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
1131
|
+
<PopoverTrigger asChild>
|
|
1132
|
+
<Button variant="outline">
|
|
1133
|
+
<Icon symbol="calendar_today" />
|
|
1134
|
+
{date ? format(date, "PPP") : "Pick a date"}
|
|
1135
|
+
</Button>
|
|
1136
|
+
</PopoverTrigger>
|
|
1137
|
+
<PopoverContent className="w-auto p-0">
|
|
1138
|
+
<Calendar
|
|
1139
|
+
mode="single"
|
|
1140
|
+
selected={date}
|
|
1141
|
+
onSelect={(newDate) => {
|
|
1142
|
+
setDate(newDate);
|
|
1143
|
+
setOpen(false); // Cierra manualmente
|
|
1144
|
+
}}
|
|
1145
|
+
initialFocus
|
|
1146
|
+
/>
|
|
1147
|
+
</PopoverContent>
|
|
1148
|
+
</Popover>
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
## Referencias
|
|
1154
|
+
|
|
1155
|
+
- **react-day-picker**: https://react-day-picker.js.org
|
|
1156
|
+
- **date-fns** (formateo): https://date-fns.org
|
|
1157
|
+
- **Radix UI Calendar** (inspiración): https://www.radix-ui.com/primitives/docs/components/calendar
|
|
1158
|
+
- **shadcn/ui Calendar**: https://ui.shadcn.com/docs/components/calendar
|
|
1159
|
+
- **ARIA Calendar Pattern**: https://www.w3.org/WAI/ARIA/apg/patterns/calendar/
|