|
1 | | -import { ChevronLeftIcon } from 'lucide-react'; |
2 | | -import type { ComponentProps } from 'react'; |
3 | | -import { DayPicker } from 'react-day-picker'; |
| 1 | +'use client'; |
4 | 2 |
|
| 3 | +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; |
| 4 | +import type { ComponentProps, CSSProperties } from 'react'; |
| 5 | +import { useEffect, useRef } from 'react'; |
| 6 | +import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'; |
| 7 | + |
| 8 | +import { type ButtonProps } from '@/components/button'; |
5 | 9 | import { cn } from '@/lib'; |
6 | 10 |
|
7 | | -const components = { |
8 | | - Chevron: () => <ChevronLeftIcon className="h-5 w-5" strokeWidth={1} />, |
| 11 | +export type CalendarProps = ComponentProps<typeof DayPicker> & { |
| 12 | + buttonVariant?: ButtonProps['variant']; |
9 | 13 | }; |
10 | 14 |
|
11 | | -export type CalendarProps = ComponentProps<typeof DayPicker>; |
12 | | - |
13 | 15 | /** |
14 | 16 | * This component supports various CSS variables for theming. Here's a comprehensive list, along |
15 | 17 | * with their default values: |
16 | 18 | * |
17 | 19 | * ```css |
18 | | - * :root { |
| 20 | + * :root { |
19 | 21 | * --calendar-font-family: var(--font-family-body); |
20 | | - * --calendar-light-focus: var(--foreground); |
21 | | - * --calendar-light-border: var(--contrast-100); |
22 | | - * --calendar-light-text: var(--foreground); |
23 | | - * --calendar-light-background: var(--background); |
24 | | - * --calendar-light-button-border-hover: var(--contrast-200); |
25 | | - * --calendar-light-selected-button-background: var(--primary); |
26 | | - * --calendar-light-selected-button-text: var(--foreground); |
27 | | - * --calendar-light-selected-middle-button-background: transparent; |
28 | | - * --calendar-light-text-disabled: var(--contrast-300); |
29 | | - * --calendar-light-range-background: var(--primary-highlight); |
30 | | - * --calendar-dark-focus: var(--background); |
31 | | - * --calendar-dark-border: var(--contrast-500); |
32 | | - * --calendar-dark-text: var(--background); |
33 | | - * --calendar-dark-background: var(--foreground); |
34 | | - * --calendar-dark-button-border-hover: var(--contrast-400); |
35 | | - * --calendar-dark-selected-button-background: var(--primary); |
36 | | - * --calendar-dark-selected-button-text: var(--foreground); |
37 | | - * --calendar-dark-selected-middle-button-background: transparent; |
38 | | - * --calendar-dark-text-disabled: var(--contrast-300); |
39 | | - * --calendar-dark-range-background: color-mix(in oklab, var(--primary), white 60%); |
40 | | - * } |
| 22 | + * --calendar-focus: var(--foreground); |
| 23 | + * --calendar-text: var(--foreground); |
| 24 | + * --calendar-background: var(--background); |
| 25 | + * --calendar-selected-background: var(--primary); |
| 26 | + * --calendar-selected-text: var(--foreground); |
| 27 | + * --calendar-text-disabled: var(--contrast-300); |
| 28 | + * } |
41 | 29 | * ``` |
42 | 30 | */ |
43 | | -export function Calendar({ className, classNames, ...props }: CalendarProps) { |
| 31 | +export function Calendar({ |
| 32 | + className, |
| 33 | + classNames, |
| 34 | + showOutsideDays = true, |
| 35 | + captionLayout = 'label', |
| 36 | + formatters, |
| 37 | + components, |
| 38 | + ...props |
| 39 | +}: ComponentProps<typeof DayPicker> & { |
| 40 | + buttonVariant?: ButtonProps['variant']; |
| 41 | +}) { |
| 42 | + const defaultClassNames = getDefaultClassNames(); |
| 43 | + |
| 44 | + const cellSizeStyle: CSSProperties & { '--cell-size': string } = { |
| 45 | + '--cell-size': '40px', |
| 46 | + }; |
| 47 | + |
44 | 48 | return ( |
45 | 49 | <DayPicker |
| 50 | + captionLayout={captionLayout} |
46 | 51 | className={cn( |
47 | | - 'box-content w-[280px] rounded-lg border border-[var(--calendar-light-border,hsl(var(--contrast-100)))] bg-[var(--calendar-light-background,hsl(var(--background)))] p-3 font-[var(--calendar-font-family,var(--font-family-body))] text-[var(--calendar-light-text,hsl(var(--foreground)))]', |
| 52 | + 'group/calendar rounded-2xl bg-[var(--calendar-background,hsl(var(--background)))] p-3 font-[var(--calendar-font-family,var(--font-family-body))] text-[var(--calendar-text,hsl(var(--foreground)))] shadow-lg shadow-black/10 ring-1 ring-black/5', |
48 | 53 | className, |
49 | 54 | )} |
50 | 55 | classNames={{ |
51 | | - months: 'relative', |
52 | | - month_caption: 'flex justify-center w-full font-medium pb-0.5', |
53 | | - nav: 'absolute flex justify-between w-full', |
54 | | - button_next: cn( |
55 | | - 'rotate-180 rounded-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--calendar-light-focus,hsl(var(--foreground)))]', |
| 56 | + root: cn('w-fit', defaultClassNames.root), |
| 57 | + months: cn('relative flex flex-col gap-8 md:flex-row', defaultClassNames.months), |
| 58 | + month: cn('flex w-full flex-col gap-1', defaultClassNames.month), |
| 59 | + nav: cn( |
| 60 | + 'absolute -inset-x-0.5 top-0 flex w-full items-center justify-between gap-1 text-foreground', |
| 61 | + defaultClassNames.nav, |
56 | 62 | ), |
57 | 63 | button_previous: cn( |
58 | | - 'rounded-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--calendar-light-focus,hsl(var(--foreground)))]', |
| 64 | + 'inline-flex size-[var(--cell-size)] cursor-default items-center justify-center rounded-full transition-colors duration-75 ease-linear hover:bg-contrast-100 focus-visible:ring-[var(--calendar-focus,hsl(var(--primary)))] aria-disabled:cursor-not-allowed aria-disabled:opacity-50', |
| 65 | + defaultClassNames.button_previous, |
| 66 | + ), |
| 67 | + button_next: cn( |
| 68 | + 'inline-flex size-[var(--cell-size)] cursor-default items-center justify-center rounded-full transition-colors duration-75 ease-linear hover:bg-contrast-100 focus-visible:ring-[var(--calendar-focus,hsl(var(--primary)))] aria-disabled:cursor-not-allowed aria-disabled:opacity-25', |
| 69 | + defaultClassNames.button_next, |
| 70 | + ), |
| 71 | + month_caption: cn( |
| 72 | + 'flex h-[var(--cell-size)] w-full items-center justify-center px-[var(--cell-size)]', |
| 73 | + defaultClassNames.month_caption, |
| 74 | + ), |
| 75 | + dropdowns: cn( |
| 76 | + 'flex h-[var(--cell-size)] w-full items-center justify-center gap-1.5 text-sm font-medium', |
| 77 | + defaultClassNames.dropdowns, |
| 78 | + ), |
| 79 | + dropdown_root: cn( |
| 80 | + 'relative rounded-lg border border-contrast-200 transition-colors duration-75 ease-linear hover:bg-foreground/5 focus:border-foreground', |
| 81 | + defaultClassNames.dropdown_root, |
| 82 | + ), |
| 83 | + dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown), |
| 84 | + caption_label: cn( |
| 85 | + 'flex h-8 select-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-sm font-medium', |
| 86 | + defaultClassNames.caption_label, |
| 87 | + ), |
| 88 | + table: 'w-full border-collapse', |
| 89 | + weekdays: cn('flex', defaultClassNames.weekdays), |
| 90 | + weekday: cn( |
| 91 | + 'inline-flex size-[var(--cell-size)] flex-1 select-none items-center justify-center text-xs font-semibold', |
| 92 | + defaultClassNames.weekday, |
| 93 | + ), |
| 94 | + week: cn('mt-1 flex w-full', defaultClassNames.week), |
| 95 | + week_number_header: cn( |
| 96 | + 'size-[var(--cell-size)] select-none', |
| 97 | + defaultClassNames.week_number_header, |
| 98 | + ), |
| 99 | + week_number: cn('select-none text-xs', defaultClassNames.week_number), |
| 100 | + day: cn( |
| 101 | + 'group/day relative z-0 size-[var(--cell-size)] select-none p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-full', |
| 102 | + props.showWeekNumber === true |
| 103 | + ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-full' |
| 104 | + : '[&:first-child[data-selected=true]_button]:rounded-l-full', |
| 105 | + defaultClassNames.day, |
| 106 | + ), |
| 107 | + range_start: cn( |
| 108 | + 'rounded-l-full bg-[var(--calendar-selected-background,hsl(var(--primary)/.25))]', |
| 109 | + defaultClassNames.range_start, |
| 110 | + ), |
| 111 | + range_middle: cn('rounded-none', defaultClassNames.range_middle), |
| 112 | + range_end: cn( |
| 113 | + 'rounded-r-full bg-[var(--calendar-selected-background,hsl(var(--primary)/.25))]', |
| 114 | + defaultClassNames.range_end, |
59 | 115 | ), |
60 | | - month_grid: 'flex flex-col gap-0.5', |
61 | | - weeks: 'flex flex-col gap-0.5', |
62 | | - weekdays: 'flex', |
63 | | - weekday: 'flex h-10 w-10 items-center justify-center text-xs font-medium', |
64 | | - week: 'flex', |
65 | | - day: 'h-10 w-10 flex text-xs font-medium group p-0', |
66 | | - day_button: cn( |
67 | | - 'flex h-full w-full items-center justify-center rounded-full hover:border hover:border-[var(--calendar-light-button-border-hover,hsl(var(--contrast-200)))] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--calendar-light-focus,hsl(var(--foreground)))] disabled:hover:border-none group-data-[selected=true]/middle:bg-[var(--calendar-light-selected-middle-button-background,transparent)] group-data-[selected=true]:bg-[var(--calendar-light-selected-button-background,hsl(var(--primary)))] group-data-[selected=true]:text-[var(--calendar-light-selected-button-text,hsl(var(--foreground)))]', |
| 116 | + today: cn( |
| 117 | + 'rounded-full ring-1 ring-inset ring-contrast-200 data-[selected=true]:rounded-full data-[selected=true]:ring-0', |
| 118 | + defaultClassNames.today, |
68 | 119 | ), |
69 | | - disabled: 'text-[var(--calendar-light-text-disabled,hsl(var(--contrast-300)))]', |
70 | | - outside: 'text-[var(--calendar-light-text-disabled,hsl(var(--contrast-300)))]', |
71 | | - range_start: |
72 | | - 'bg-gradient-to-l from-[var(--calendar-light-range-background,color-mix(in_oklab,hsl(var(--primary)),white_75%))]', |
73 | | - range_middle: |
74 | | - 'group/middle bg-[var(--calendar-light-range-background,color-mix(in_oklab,hsl(var(--primary)),white_75%))]', |
75 | | - range_end: |
76 | | - 'bg-gradient-to-r from-[var(--calendar-light-range-background,color-mix(in_oklab,hsl(var(--primary)),white_75%))]', |
| 120 | + outside: cn( |
| 121 | + 'text-opacity-50 hover:text-opacity-100 aria-selected:text-opacity-50', |
| 122 | + defaultClassNames.outside, |
| 123 | + ), |
| 124 | + disabled: cn('cursor-not-allowed text-opacity-50', defaultClassNames.disabled), |
| 125 | + hidden: cn('invisible', defaultClassNames.hidden), |
77 | 126 | ...classNames, |
78 | 127 | }} |
79 | | - components={components} |
| 128 | + components={{ |
| 129 | + Root: ({ className: rootClassName, rootRef, ...rootProps }) => { |
| 130 | + return ( |
| 131 | + <div className={cn(rootClassName)} data-slot="calendar" ref={rootRef} {...rootProps} /> |
| 132 | + ); |
| 133 | + }, |
| 134 | + Chevron: ({ className: chevronClassName, orientation, ...chevronProps }) => { |
| 135 | + if (orientation === 'left') { |
| 136 | + return ( |
| 137 | + <ChevronLeftIcon |
| 138 | + absoluteStrokeWidth |
| 139 | + className={cn('size-5 -translate-x-px', chevronClassName)} |
| 140 | + {...chevronProps} |
| 141 | + /> |
| 142 | + ); |
| 143 | + } |
| 144 | + |
| 145 | + if (orientation === 'right') { |
| 146 | + return ( |
| 147 | + <ChevronRightIcon |
| 148 | + absoluteStrokeWidth |
| 149 | + className={cn('size-5 translate-x-px', chevronClassName)} |
| 150 | + {...chevronProps} |
| 151 | + /> |
| 152 | + ); |
| 153 | + } |
| 154 | + |
| 155 | + return ( |
| 156 | + <ChevronDownIcon |
| 157 | + absoluteStrokeWidth |
| 158 | + className={cn('size-3.5', chevronClassName)} |
| 159 | + {...chevronProps} |
| 160 | + /> |
| 161 | + ); |
| 162 | + }, |
| 163 | + DayButton: CalendarDayButton, |
| 164 | + WeekNumber: ({ children, ...weekNumberProps }) => { |
| 165 | + return ( |
| 166 | + <td {...weekNumberProps}> |
| 167 | + <div className="flex size-[var(--cell-size)] items-center justify-center text-center"> |
| 168 | + {children} |
| 169 | + </div> |
| 170 | + </td> |
| 171 | + ); |
| 172 | + }, |
| 173 | + ...components, |
| 174 | + }} |
| 175 | + formatters={{ |
| 176 | + formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }), |
| 177 | + ...formatters, |
| 178 | + }} |
| 179 | + showOutsideDays={showOutsideDays} |
| 180 | + style={cellSizeStyle} |
80 | 181 | {...props} |
81 | 182 | /> |
82 | 183 | ); |
83 | 184 | } |
| 185 | + |
| 186 | +type CalendarDayButtonProps = ComponentProps<typeof DayButton>; |
| 187 | + |
| 188 | +function CalendarDayButton({ |
| 189 | + className: dayButtonClassName, |
| 190 | + day, |
| 191 | + modifiers, |
| 192 | + ...dayButtonProps |
| 193 | +}: CalendarDayButtonProps) { |
| 194 | + const defaultClassNames = getDefaultClassNames(); |
| 195 | + |
| 196 | + const ref = useRef<HTMLButtonElement>(null); |
| 197 | + |
| 198 | + useEffect(() => { |
| 199 | + if (modifiers.focused === true) ref.current?.focus(); |
| 200 | + }, [modifiers.focused]); |
| 201 | + |
| 202 | + return ( |
| 203 | + <button |
| 204 | + className={cn( |
| 205 | + // Base styles |
| 206 | + 'size-[var(--cell-size)] rounded-full p-0 text-xs font-normal', |
| 207 | + 'transition-colors duration-75 ease-linear', |
| 208 | + 'hover:bg-contrast-100', |
| 209 | + // Range end |
| 210 | + 'data-[range-end=true]:rounded-full', |
| 211 | + 'data-[range-end=true]:bg-[var(--calendar-selected-button-background,hsl(var(--primary)))]', |
| 212 | + 'data-[range-end=true]:text-[var(--calendar-selected-text,hsl(var(--foreground)))]', |
| 213 | + // Range middle |
| 214 | + 'data-[range-middle=true]:rounded-none', |
| 215 | + 'data-[range-middle=true]:bg-[var(--calendar-selected-background,hsl(var(--primary)/.25))]', |
| 216 | + 'data-[range-middle=true]:before:content-[""]', |
| 217 | + 'data-[range-middle=true]:before:absolute', |
| 218 | + 'data-[range-middle=true]:before:-z-10', |
| 219 | + 'data-[range-middle=true]:before:inset-0', |
| 220 | + 'data-[range-middle=true]:before:bg-[var(--calendar-selected-background,hsl(var(--primary)))]', |
| 221 | + 'data-[range-middle=true]:before:rounded-full', |
| 222 | + 'data-[range-middle=true]:before:opacity-0', |
| 223 | + 'data-[range-middle=true]:before:transition-opacity', |
| 224 | + 'data-[range-middle=true]:before:duration-75', |
| 225 | + 'data-[range-middle=true]:before:ease-linear', |
| 226 | + 'data-[range-middle=true]:hover:before:opacity-100', |
| 227 | + // Range start |
| 228 | + 'data-[range-start=true]:rounded-full', |
| 229 | + 'data-[range-start=true]:bg-[var(--calendar-selected-background,hsl(var(--primary)))]', |
| 230 | + 'data-[range-start=true]:text-[var(--calendar-selected-text,hsl(var(--foreground)))]', |
| 231 | + // Selected single |
| 232 | + 'data-[selected-single=true]:bg-[var(--calendar-selected-background,hsl(var(--primary)))]', |
| 233 | + 'data-[selected-single=true]:text-[var(--calendar-selected-text,hsl(var(--foreground)))]', |
| 234 | + // Focused day (group) |
| 235 | + 'group-data-[focused=true]/day:relative', |
| 236 | + 'group-data-[focused=true]/day:z-10', |
| 237 | + 'group-data-[focused=true]/day:outline-1', |
| 238 | + 'group-data-[focused=true]/day:outline-foreground', |
| 239 | + 'group-data-[focused=true]/day:ring-0', |
| 240 | + // Outside day (group) |
| 241 | + 'group-data-[outside=true]/day:text-[var(--calendar-text,hsl(var(--foreground)/0.4))]', |
| 242 | + 'group-data-[outside=true]/day:hover:text-[var(--calendar-text,hsl(var(--foreground)/1))]', |
| 243 | + // Selected day (group) |
| 244 | + 'group-data-[selected=true]/day:text-foreground', |
| 245 | + 'group-data-[selected=true]/day:hover:text-foreground', |
| 246 | + // Disabled day (group) |
| 247 | + 'group-data-[disabled=true]/day:opacity-40', |
| 248 | + 'group-data-[disabled=true]/day:pointer-events-none', |
| 249 | + 'group-data-[disabled=true]/day:cursor-not-allowed', |
| 250 | + defaultClassNames.day, |
| 251 | + dayButtonClassName, |
| 252 | + )} |
| 253 | + data-day={day.date.toLocaleDateString()} |
| 254 | + data-range-end={modifiers.range_end === true} |
| 255 | + data-range-middle={modifiers.range_middle === true} |
| 256 | + data-range-start={modifiers.range_start === true} |
| 257 | + data-selected-single={ |
| 258 | + modifiers.selected === true && |
| 259 | + modifiers.range_start !== true && |
| 260 | + modifiers.range_end !== true && |
| 261 | + modifiers.range_middle !== true |
| 262 | + } |
| 263 | + ref={ref} |
| 264 | + {...dayButtonProps} |
| 265 | + /> |
| 266 | + ); |
| 267 | +} |
0 commit comments