calendar.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import * as React from 'react'
  2. import {
  3. ChevronDownIcon,
  4. ChevronLeftIcon,
  5. ChevronRightIcon,
  6. } from 'lucide-react'
  7. import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
  8. import { cn } from '@/lib/utils'
  9. import { Button, buttonVariants } from '@/components/ui/button'
  10. function Calendar({
  11. className,
  12. classNames,
  13. showOutsideDays = true,
  14. captionLayout = 'label',
  15. buttonVariant = 'ghost',
  16. formatters,
  17. components,
  18. ...props
  19. }: React.ComponentProps<typeof DayPicker> & {
  20. buttonVariant?: React.ComponentProps<typeof Button>['variant']
  21. }) {
  22. const defaultClassNames = getDefaultClassNames()
  23. return (
  24. <DayPicker
  25. showOutsideDays={showOutsideDays}
  26. className={cn(
  27. 'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
  28. String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
  29. String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
  30. className
  31. )}
  32. captionLayout={captionLayout}
  33. formatters={{
  34. formatMonthDropdown: (date) =>
  35. date.toLocaleString('default', { month: 'short' }),
  36. ...formatters,
  37. }}
  38. classNames={{
  39. root: cn('w-fit', defaultClassNames.root),
  40. months: cn(
  41. 'flex gap-4 flex-col md:flex-row relative',
  42. defaultClassNames.months
  43. ),
  44. month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
  45. nav: cn(
  46. 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
  47. defaultClassNames.nav
  48. ),
  49. button_previous: cn(
  50. buttonVariants({ variant: buttonVariant }),
  51. 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
  52. defaultClassNames.button_previous
  53. ),
  54. button_next: cn(
  55. buttonVariants({ variant: buttonVariant }),
  56. 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
  57. defaultClassNames.button_next
  58. ),
  59. month_caption: cn(
  60. 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
  61. defaultClassNames.month_caption
  62. ),
  63. dropdowns: cn(
  64. 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
  65. defaultClassNames.dropdowns
  66. ),
  67. dropdown_root: cn(
  68. 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
  69. defaultClassNames.dropdown_root
  70. ),
  71. dropdown: cn(
  72. 'absolute bg-popover inset-0 opacity-0',
  73. defaultClassNames.dropdown
  74. ),
  75. caption_label: cn(
  76. 'select-none font-medium',
  77. captionLayout === 'label'
  78. ? 'text-sm'
  79. : 'rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
  80. defaultClassNames.caption_label
  81. ),
  82. table: 'w-full border-collapse',
  83. weekdays: cn('flex', defaultClassNames.weekdays),
  84. weekday: cn(
  85. 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
  86. defaultClassNames.weekday
  87. ),
  88. week: cn('flex w-full mt-2', defaultClassNames.week),
  89. week_number_header: cn(
  90. 'select-none w-(--cell-size)',
  91. defaultClassNames.week_number_header
  92. ),
  93. week_number: cn(
  94. 'text-[0.8rem] select-none text-muted-foreground',
  95. defaultClassNames.week_number
  96. ),
  97. day: cn(
  98. 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
  99. defaultClassNames.day
  100. ),
  101. range_start: cn(
  102. 'rounded-l-md bg-accent',
  103. defaultClassNames.range_start
  104. ),
  105. range_middle: cn('rounded-none', defaultClassNames.range_middle),
  106. range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
  107. today: cn(
  108. 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
  109. defaultClassNames.today
  110. ),
  111. outside: cn(
  112. 'text-muted-foreground aria-selected:text-muted-foreground',
  113. defaultClassNames.outside
  114. ),
  115. disabled: cn(
  116. 'text-muted-foreground opacity-50',
  117. defaultClassNames.disabled
  118. ),
  119. hidden: cn('invisible', defaultClassNames.hidden),
  120. ...classNames,
  121. }}
  122. components={{
  123. Root: ({ className, rootRef, ...props }) => {
  124. return (
  125. <div
  126. data-slot='calendar'
  127. ref={rootRef}
  128. className={cn(className)}
  129. {...props}
  130. />
  131. )
  132. },
  133. Chevron: ({ className, orientation, ...props }) => {
  134. if (orientation === 'left') {
  135. return (
  136. <ChevronLeftIcon className={cn('size-4', className)} {...props} />
  137. )
  138. }
  139. if (orientation === 'right') {
  140. return (
  141. <ChevronRightIcon
  142. className={cn('size-4', className)}
  143. {...props}
  144. />
  145. )
  146. }
  147. return (
  148. <ChevronDownIcon className={cn('size-4', className)} {...props} />
  149. )
  150. },
  151. DayButton: CalendarDayButton,
  152. WeekNumber: ({ children, ...props }) => {
  153. return (
  154. <td {...props}>
  155. <div className='flex size-(--cell-size) items-center justify-center text-center'>
  156. {children}
  157. </div>
  158. </td>
  159. )
  160. },
  161. ...components,
  162. }}
  163. {...props}
  164. />
  165. )
  166. }
  167. function CalendarDayButton({
  168. className,
  169. day,
  170. modifiers,
  171. ...props
  172. }: React.ComponentProps<typeof DayButton>) {
  173. const defaultClassNames = getDefaultClassNames()
  174. const ref = React.useRef<HTMLButtonElement>(null)
  175. React.useEffect(() => {
  176. if (modifiers.focused) ref.current?.focus()
  177. }, [modifiers.focused])
  178. return (
  179. <Button
  180. ref={ref}
  181. variant='ghost'
  182. size='icon'
  183. data-day={day.date.toLocaleDateString()}
  184. data-selected-single={
  185. modifiers.selected &&
  186. !modifiers.range_start &&
  187. !modifiers.range_end &&
  188. !modifiers.range_middle
  189. }
  190. data-range-start={modifiers.range_start}
  191. data-range-end={modifiers.range_end}
  192. data-range-middle={modifiers.range_middle}
  193. className={cn(
  194. 'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
  195. defaultClassNames.day,
  196. className
  197. )}
  198. {...props}
  199. />
  200. )
  201. }
  202. export { Calendar, CalendarDayButton }