Calendar
A month grid to pick a date (or range) with keyboard support. Scheduling and date filters.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Single date pick:
```tsx import { Calendar } from '@/components/atoms/Calendar';
<Calendar mode="single" selected={day} onSelect={setDay} /> ```
Range for filters:
```tsx <Calendar mode="range" rangeFrom={from} rangeTo={to} onRangeSelect={(a, b) => { setFrom(a); setTo(b); }} /> ```
## Accesibilidad
- Wire `onSelect` vs `onRangeSelect` to match `mode`. `disabledDates` is a predicate, not a list. - Pair with a labeled field when the calendar is the control for a form value.
## Cuándo no usar
- Typing a known ISO date quickly → `Input` with validation. - Time-of-day without a date → a time control, not the month grid alone.
## Criterio de uso
- Usa modo `single` para una fecha y `range` para periodos; el label del campo debe explicar qué representa la selección. - Deshabilita fechas inválidas con una regla de negocio estable y comunica restricciones antes de que el usuario explore todo el mes. - Conserva el contexto de mes y año mientras se navega; la selección no debe depender sólo del color del día.
## Gotchas
- `disabledDates` es un predicate; no pases una lista esperando que el componente la interprete automáticamente. - Los controles de navegación del mes necesitan nombres accesibles y el calendario debe seguir siendo usable con teclado.
Uso
import { Calendar } from '@/components/atoms/Calendar';
<Calendar mode="single" selected={day} onSelect={setDay} />Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| mode | select | single | `single`, `range` | Selection model: one day or a from–to range. | single for pickers; range for booking/filters. Wire onSelect vs onRangeSelect accordingly. Default: single. |
Gotchas
- react
Controlled via selected / rangeFrom+rangeTo. disabledDates is a predicate (date)=>boolean — not a list prop.
- a11y
Month nav uses IconButtons; keep adjacent text labels for the field that owns the calendar.
Anatomía CSS
<div class="calendar"> <span class="calendar__day"></span> <span class="calendar__day--disabled"></span> <span class="calendar__day--in-range"></span> <span class="calendar__day--outside"></span> <span class="calendar__day--range-end"></span> <span class="calendar__day--range-start"></span> <span class="calendar__day--selected"></span> <span class="calendar__day--today"></span> <span class="calendar__grid"></span> <span class="calendar__head"></span> <span class="calendar__nav"></span> <span class="calendar__nav-btn"></span> <span class="calendar__row"></span> <span class="calendar__title"></span> </div>
| Clase | Propósito |
|---|---|
calendar | root |
calendar__day | element |
calendar__day--disabled | element |
calendar__day--in-range | element |
calendar__day--outside | element |
calendar__day--range-end | element |
calendar__day--range-start | element |
calendar__day--selected | element |
calendar__day--today | element |
calendar__grid | element |
calendar__head | element |
calendar__nav | element |
calendar__nav-btn | element |
calendar__row | element |
calendar__title | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | fg | #525252 | muted.foreground |
| all | bg | #f5f5f5 | accent |
| all | border | none | (unparsed) |
| all | hover-bg | #262626 | primary.hover |
| all | hover-fg | #171717 | accent.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-100) | var(--easing-in-out) |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Calendar
Date picker grid. Month navigation, day selection.
Modes: single, range.
Parts: .calendar, .calendar__nav, .calendar__nav-btn,
.calendar__title, .calendar__grid, .calendar__head,
.calendar__row, .calendar__day
------------------------------------------------------------------------- */
/* ---- Container ---- */
.calendar {
display: inline-flex;
flex-direction: column;
gap: 8px;
padding: 12px;
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
font-size: 12.8px;
}
/* ---- Navigation ---- */
.calendar__nav {
display: flex;
align-items: center;
justify-content: space-between;
}
.calendar__title {
font-size: 12.8px;
font-weight: 600;
color: #0a0a0a;
}
/* ---- Grid ---- */
.calendar__grid {
border-collapse: collapse;
}
.calendar__head {
font-size: 10.24px;
font-weight: 500;
color: #525252;
text-align: center;
padding-bottom: 8px;
width: 36px;
}
/* ---- Day cell ---- */
.calendar__day {
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: #0a0a0a;
font-family: inherit;
font-size: 12.8px;
font-weight: 400;
line-height: 1;
text-align: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.calendar__day:hover:not(.calendar__day--disabled):not(.calendar__day--selected) {
background-color: #f5f5f5;
color: #171717;
}
.calendar__day:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
/* Outside month */
.calendar__day--outside {
color: #525252;
opacity: 0.5;
}
/* Today */
.calendar__day--today {
font-weight: 600;
color: #0a0a0a;
box-shadow: inset 0 -2px 0 #0a0a0a;
}
/* Selected */
.calendar__day--selected {
background-color: #0a0a0a;
color: #fafafa;
font-weight: 500;
}
.calendar__day--selected:hover {
background-color: #262626;
}
/* Range: start + end */
.calendar__day--range-start {
border-radius: 8px 0 0 8px;
}
.calendar__day--range-end {
border-radius: 0 8px 8px 0;
}
.calendar__day--range-start.calendar__day--range-end {
border-radius: 8px;
}
/* Range: middle */
.calendar__day--in-range {
background-color: #f5f5f5;
color: #171717;
border-radius: 0;
}
/* Disabled */
.calendar__day--disabled {
color: #525252;
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.calendar__day {
transition-duration: 0ms;
}
}
Codigo fuente
import { useState, useMemo, useCallback } from 'react'; import { IconButton } from './IconButton'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } const ChevronLeft = () => ( <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M15 18l-6-6 6-6" /> </svg> ); const ChevronRight = () => ( <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M9 18l6-6-6-6" /> </svg> ); /* ---- Helpers ---- */ const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; function isSameDay(a: Date, b: Date) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } function isInRange(day: Date, from: Date | null, to: Date | null) { if (!from || !to) return false; const t = day.getTime(); return t > from.getTime() && t < to.getTime(); } function getDaysGrid(year: number, month: number): Date[] { const first = new Date(year, month, 1); const startDay = first.getDay(); const days: Date[] = []; for (let i = -startDay; i < 42 - startDay; i++) { days.push(new Date(year, month, i + 1)); } return days; } /* ---- Component ---- */ export type CalendarProps = { mode?: 'single' | 'range'; selected?: Date | null; rangeFrom?: Date | null; rangeTo?: Date | null; onSelect?: (date: Date) => void; onRangeSelect?: (from: Date | null, to: Date | null) => void; disabledDates?: (date: Date) => boolean; className?: string; }; export function Calendar({ mode = 'single', selected = null, rangeFrom = null, rangeTo = null, onSelect, onRangeSelect, disabledDates, className, }: CalendarProps) { const today = useMemo(() => new Date(), []); const initial = selected || rangeFrom || today; const [viewYear, setViewYear] = useState(initial.getFullYear()); const [viewMonth, setViewMonth] = useState(initial.getMonth()); const days = useMemo(() => getDaysGrid(viewYear, viewMonth), [viewYear, viewMonth]); const prevMonth = useCallback(() => { if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } else setViewMonth((m) => m - 1); }, [viewMonth]); const nextMonth = useCallback(() => { if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } else setViewMonth((m) => m + 1); }, [viewMonth]); // Range selection state const [rangeStep, setRangeStep] = useState<'from' | 'to'>(rangeFrom && !rangeTo ? 'to' : 'from'); const handleDayClick = (day: Date) => { if (mode === 'single') { onSelect?.(day); } else { if (rangeStep === 'from') { onRangeSelect?.(day, null); setRangeStep('to'); } else { if (rangeFrom && day.getTime() < rangeFrom.getTime()) { onRangeSelect?.(day, null); setRangeStep('to'); } else { onRangeSelect?.(rangeFrom, day); setRangeStep('from'); } } } }; const weeks: Date[][] = []; for (let i = 0; i < days.length; i += 7) { weeks.push(days.slice(i, i + 7)); } return ( <div className={cn('calendar', className)}> <div className="calendar__nav"> <IconButton variant="tertiary" size="xs" icon={<ChevronLeft />} aria-label="Previous month" onClick={prevMonth} /> <span className="calendar__title"> {MONTHS[viewMonth]} {viewYear} </span> <IconButton variant="tertiary" size="xs" icon={<ChevronRight />} aria-label="Next month" onClick={nextMonth} /> </div> <table className="calendar__grid" role="grid"> <thead> <tr> {DAYS.map((d) => ( <th key={d} className="calendar__head">{d}</th> ))} </tr> </thead> <tbody> {weeks.map((week, wi) => ( <tr key={wi}> {week.map((day, di) => { const isOutside = day.getMonth() !== viewMonth; const isToday = isSameDay(day, today); const isDisabled = disabledDates?.(day) ?? false; let isSelected = false; let isRangeStart = false; let isRangeEnd = false; let isInRangeDay = false; if (mode === 'single' && selected) { isSelected = isSameDay(day, selected); } else if (mode === 'range') { if (rangeFrom) isRangeStart = isSameDay(day, rangeFrom); if (rangeTo) isRangeEnd = isSameDay(day, rangeTo); isSelected = isRangeStart || isRangeEnd; isInRangeDay = isInRange(day, rangeFrom, rangeTo); } return ( <td key={di} style={{ padding: 0 }}> <button type="button" className={cn( 'calendar__day', isOutside && 'calendar__day--outside', isToday && 'calendar__day--today', isSelected && 'calendar__day--selected', isRangeStart && 'calendar__day--range-start', isRangeEnd && 'calendar__day--range-end', isInRangeDay && 'calendar__day--in-range', isDisabled && 'calendar__day--disabled', )} disabled={isDisabled} onClick={() => handleDayClick(day)} tabIndex={isOutside ? -1 : 0} aria-selected={isSelected || undefined} > {day.getDate()} </button> </td> ); })} </tr> ))} </tbody> </table> </div> ); }