The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { IconCalendar, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; |
| 6 | import { cn } from "../lib/utils"; |
| 7 | import { Button } from "./button"; |
| 8 | import { Popover, PopoverContent, PopoverTrigger } from "./popover"; |
| 9 |
| 10 | /** |
| 11 | * A month calendar with full keyboard support: arrows move by day/week, |
| 12 | * PageUp/PageDown by month, Home/End to the week's edges, Enter selects. |
| 13 | * Dates are plain `YYYY-MM-DD` strings, so there are no time-zone traps. |
| 14 | */ |
| 15 |
| 16 | function toIso(date: Date): string { |
| 17 | const year = date.getFullYear(); |
| 18 | const month = String(date.getMonth() + 1).padStart(2, "0"); |
| 19 | const day = String(date.getDate()).padStart(2, "0"); |
| 20 | return `${year}-${month}-${day}`; |
| 21 | } |
| 22 |
| 23 | function fromIso(value: string): Date { |
| 24 | const [year, month, day] = value.split("-").map(Number); |
| 25 | return new Date(year ?? 1970, (month ?? 1) - 1, day ?? 1); |
| 26 | } |
| 27 |
| 28 | function addDays(date: Date, days: number): Date { |
| 29 | const next = new Date(date); |
| 30 | next.setDate(next.getDate() + days); |
| 31 | return next; |
| 32 | } |
| 33 |
| 34 | function addMonths(date: Date, months: number): Date { |
| 35 | const next = new Date(date.getFullYear(), date.getMonth() + months, 1); |
| 36 | const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); |
| 37 | next.setDate(Math.min(date.getDate(), lastDay)); |
| 38 | return next; |
| 39 | } |
| 40 |
| 41 | export function Calendar({ |
| 42 | value, |
| 43 | onValueChange, |
| 44 | min, |
| 45 | max, |
| 46 | weekStartsOn = 1, |
| 47 | className, |
| 48 | }: { |
| 49 | value: string | null; |
| 50 | onValueChange: (value: string) => void; |
| 51 | min?: string; |
| 52 | max?: string; |
| 53 | weekStartsOn?: 0 | 1; |
| 54 | className?: string; |
| 55 | }) { |
| 56 | const today = toIso(new Date()); |
| 57 | const [focused, setFocused] = React.useState(() => fromIso(value ?? today)); |
| 58 | const [direction, setDirection] = React.useState(0); |
| 59 | const gridRef = React.useRef<HTMLDivElement | null>(null); |
| 60 | const month = focused.getMonth(); |
| 61 | const year = focused.getFullYear(); |
| 62 |
| 63 | const first = new Date(year, month, 1); |
| 64 | const offset = (first.getDay() - weekStartsOn + 7) % 7; |
| 65 | const start = addDays(first, -offset); |
| 66 | const days = Array.from({ length: 42 }, (_, index) => addDays(start, index)); |
| 67 | const weekdays = Array.from({ length: 7 }, (_, index) => |
| 68 | new Intl.DateTimeFormat(undefined, { weekday: "narrow" }).format(addDays(start, index)), |
| 69 | ); |
| 70 | const title = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(first); |
| 71 | const disabled = (iso: string) => (min !== undefined && iso < min) || (max !== undefined && iso > max); |
| 72 |
| 73 | const moveTo = (next: Date) => { |
| 74 | if (next.getMonth() !== month || next.getFullYear() !== year) { |
| 75 | setDirection(next > focused ? 1 : -1); |
| 76 | } |
| 77 | setFocused(next); |
| 78 | requestAnimationFrame(() => { |
| 79 | gridRef.current?.querySelector<HTMLButtonElement>(`[data-date="${toIso(next)}"]`)?.focus(); |
| 80 | }); |
| 81 | }; |
| 82 |
| 83 | return ( |
| 84 | <div className={cn("w-[17.5rem] select-none", className)}> |
| 85 | <div className="mb-2 flex items-center justify-between"> |
| 86 | <p className="pl-1 text-sm font-semibold text-foreground" aria-live="polite"> |
| 87 | {title} |
| 88 | </p> |
| 89 | <div className="flex items-center gap-0.5"> |
| 90 | <Button variant="ghost" size="icon-sm" aria-label="Previous month" onClick={() => moveTo(addMonths(focused, -1))}> |
| 91 | <IconChevronLeft /> |
| 92 | </Button> |
| 93 | <Button variant="ghost" size="icon-sm" aria-label="Next month" onClick={() => moveTo(addMonths(focused, 1))}> |
| 94 | <IconChevronRight /> |
| 95 | </Button> |
| 96 | </div> |
| 97 | </div> |
| 98 | <div className="grid grid-cols-7 pb-1"> |
| 99 | {weekdays.map((weekday, index) => ( |
| 100 | <span key={index} aria-hidden="true" className="text-center text-[11px] font-medium text-muted-foreground"> |
| 101 | {weekday} |
| 102 | </span> |
| 103 | ))} |
| 104 | </div> |
| 105 | <div className="relative overflow-hidden"> |
| 106 | <AnimatePresence initial={false} mode="popLayout" custom={direction}> |
| 107 | <motion.div |
| 108 | key={`${year}-${month}`} |
| 109 | ref={gridRef} |
| 110 | role="grid" |
| 111 | aria-label={title} |
| 112 | custom={direction} |
| 113 | initial={{ opacity: 0, x: direction * 28 }} |
| 114 | animate={{ opacity: 1, x: 0 }} |
| 115 | exit={{ opacity: 0, x: direction * -28 }} |
| 116 | transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }} |
| 117 | className="grid grid-cols-7 gap-y-0.5" |
| 118 | > |
| 119 | {days.map((day) => { |
| 120 | const iso = toIso(day); |
| 121 | const inMonth = day.getMonth() === month; |
| 122 | const selected = iso === value; |
| 123 | const isToday = iso === today; |
| 124 | const isFocused = iso === toIso(focused); |
| 125 | const off = disabled(iso); |
| 126 | return ( |
| 127 | <button |
| 128 | key={iso} |
| 129 | type="button" |
| 130 | role="gridcell" |
| 131 | data-date={iso} |
| 132 | aria-selected={selected} |
| 133 | aria-disabled={off || undefined} |
| 134 | tabIndex={isFocused ? 0 : -1} |
| 135 | onClick={() => !off && onValueChange(iso)} |
| 136 | onKeyDown={(event) => { |
| 137 | const keys: Record<string, () => Date> = { |
| 138 | ArrowLeft: () => addDays(focused, -1), |
| 139 | ArrowRight: () => addDays(focused, 1), |
| 140 | ArrowUp: () => addDays(focused, -7), |
| 141 | ArrowDown: () => addDays(focused, 7), |
| 142 | PageUp: () => addMonths(focused, -1), |
| 143 | PageDown: () => addMonths(focused, 1), |
| 144 | Home: () => addDays(focused, -((focused.getDay() - weekStartsOn + 7) % 7)), |
| 145 | End: () => addDays(focused, 6 - ((focused.getDay() - weekStartsOn + 7) % 7)), |
| 146 | }; |
| 147 | const handler = keys[event.key]; |
| 148 | if (handler) { |
| 149 | event.preventDefault(); |
| 150 | moveTo(handler()); |
| 151 | } else if (event.key === "Enter" || event.key === " ") { |
| 152 | event.preventDefault(); |
| 153 | if (!off) onValueChange(iso); |
| 154 | } |
| 155 | }} |
| 156 | className={cn( |
| 157 | "relative mx-auto flex size-9 items-center justify-center rounded-full text-sm tabular-nums outline-none transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-ring/60", |
| 158 | !inMonth && "text-muted-foreground/50", |
| 159 | off ? "cursor-not-allowed text-muted-foreground/40" : "hover:bg-muted", |
| 160 | selected && "bg-action font-semibold text-background hover:bg-action", |
| 161 | )} |
| 162 | > |
| 163 | {day.getDate()} |
| 164 | {isToday && !selected ? ( |
| 165 | <span aria-hidden="true" className="absolute bottom-1.5 size-1 rounded-full bg-action" /> |
| 166 | ) : null} |
| 167 | </button> |
| 168 | ); |
| 169 | })} |
| 170 | </motion.div> |
| 171 | </AnimatePresence> |
| 172 | </div> |
| 173 | </div> |
| 174 | ); |
| 175 | } |
| 176 |
| 177 | /** A button that opens a calendar; shows the chosen date. */ |
| 178 | export function DatePicker({ |
| 179 | value, |
| 180 | onValueChange, |
| 181 | placeholder = "Pick a date", |
| 182 | min, |
| 183 | max, |
| 184 | className, |
| 185 | }: { |
| 186 | value: string | null; |
| 187 | onValueChange: (value: string | null) => void; |
| 188 | placeholder?: string; |
| 189 | min?: string; |
| 190 | max?: string; |
| 191 | className?: string; |
| 192 | }) { |
| 193 | const [open, setOpen] = React.useState(false); |
| 194 | const label = value |
| 195 | ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(fromIso(value)) |
| 196 | : placeholder; |
| 197 | return ( |
| 198 | <Popover open={open} onOpenChange={setOpen}> |
| 199 | <PopoverTrigger asChild> |
| 200 | <Button variant="outline" className={cn("justify-start font-normal", !value && "text-muted-foreground", className)}> |
| 201 | <IconCalendar className="size-4 opacity-70" /> |
| 202 | {label} |
| 203 | </Button> |
| 204 | </PopoverTrigger> |
| 205 | <PopoverContent align="start" className="w-auto p-3"> |
| 206 | <Calendar |
| 207 | value={value} |
| 208 | {...(min ? { min } : {})} |
| 209 | {...(max ? { max } : {})} |
| 210 | onValueChange={(next) => { |
| 211 | onValueChange(next); |
| 212 | setOpen(false); |
| 213 | }} |
| 214 | /> |
| 215 | {value ? ( |
| 216 | <div className="mt-2 flex justify-end border-t border-border/60 pt-2"> |
| 217 | <Button |
| 218 | variant="ghost" |
| 219 | size="sm" |
| 220 | onClick={() => { |
| 221 | onValueChange(null); |
| 222 | setOpen(false); |
| 223 | }} |
| 224 | > |
| 225 | Clear |
| 226 | </Button> |
| 227 | </div> |
| 228 | ) : null} |
| 229 | </PopoverContent> |
| 230 | </Popover> |
| 231 | ); |
| 232 | } |
| 233 |