calendar.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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 "@/client/lib/utils"
  9. import { Button, buttonVariants } from "@/client/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. "bg-background group/calendar 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 pl-2 pr-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. "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground 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:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
  195. defaultClassNames.day,
  196. className
  197. )}
  198. {...props}
  199. />
  200. )
  201. }
  202. export { Calendar, CalendarDayButton }