From 2b9028492b3014cd92daccb138fa2328b5336595 Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 27 Jun 2025 17:45:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Calendar=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=97=B0=EB=8F=84=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/schedule/ui/date-time-picker.tsx | 7 +- packages/common/components/package.json | 2 +- .../components/src/ui/calendar/button.tsx | 56 +++++ .../components/src/ui/calendar/index.tsx | 234 ++++++++++++++---- .../components/src/ui/date-picker/index.tsx | 7 +- .../src/ui/date-range-picker/index.tsx | 18 +- pnpm-lock.yaml | 30 ++- 7 files changed, 287 insertions(+), 67 deletions(-) create mode 100644 packages/common/components/src/ui/calendar/button.tsx diff --git a/apps/cs/src/pages/schedule/ui/date-time-picker.tsx b/apps/cs/src/pages/schedule/ui/date-time-picker.tsx index 0302253b9..8906585a0 100644 --- a/apps/cs/src/pages/schedule/ui/date-time-picker.tsx +++ b/apps/cs/src/pages/schedule/ui/date-time-picker.tsx @@ -64,12 +64,7 @@ function DateTimePicker({ date, classes, onDateChange }: DateTimePickerProps) {
- +
diff --git a/packages/common/components/package.json b/packages/common/components/package.json index 925699de0..0edd7211b 100644 --- a/packages/common/components/package.json +++ b/packages/common/components/package.json @@ -61,7 +61,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "react-chartjs-2": "^5.3.0", - "react-day-picker": "^8.10.1", + "react-day-picker": "^9.7.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "tailwind-merge": "^3.0.1", diff --git a/packages/common/components/src/ui/calendar/button.tsx b/packages/common/components/src/ui/calendar/button.tsx new file mode 100644 index 000000000..add67a757 --- /dev/null +++ b/packages/common/components/src/ui/calendar/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "../../lib"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/common/components/src/ui/calendar/index.tsx b/packages/common/components/src/ui/calendar/index.tsx index c1a6d5204..f2af829e5 100644 --- a/packages/common/components/src/ui/calendar/index.tsx +++ b/packages/common/components/src/ui/calendar/index.tsx @@ -1,69 +1,211 @@ import * as React from "react"; -import { ko } from "date-fns/locale"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { DayPicker } from "react-day-picker"; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react"; +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; +import { ko } from "react-day-picker/locale"; -import { cn } from "../../lib/utils"; -import { buttonVariants } from "../button"; +import { cn } from "../../lib"; +import { Button, buttonVariants } from "./button"; -export type CalendarProps = React.ComponentProps; - -export function Calendar({ +function Calendar({ className, classNames, showOutsideDays = true, + captionLayout = "dropdown", + buttonVariant = "ghost", + formatters, + components, ...props -}: CalendarProps) { +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"]; +}) { + const defaultClassNames = getDefaultClassNames(); + return ( svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} classNames={{ - months: - "flex flex-col sm:flex-row space-y-[1.6rem] sm:space-x-[1.6rem] sm:space-y-0", - month: "space-y-[1.6rem]", - caption: "flex justify-center pt-[0.4rem] relative items-center", - caption_label: "text-[1.4rem] font-medium", - nav: "space-x-[0.4rem] flex items-center", - nav_button: cn( - buttonVariants({ variant: "outline" }), - "size-[2.8rem] bg-transparent p-0 opacity-50 hover:opacity-100", + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months, + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav, + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50 [&_svg]:size-6", + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50 [&_svg]:size-6", + defaultClassNames.button_next, + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption, + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-xl font-medium", + captionLayout === "dropdown" && "flex-row-reverse", + defaultClassNames.dropdowns, + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root, + ), + dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-xl" + : "[&>svg]:text-muted-foreground flex h-10 items-center gap-1 rounded-md pl-4 pr-3 text-xl [&>svg]:size-3.5", + defaultClassNames.caption_label, + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-xl font-normal", + defaultClassNames.weekday, + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header, + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number, ), - nav_button_previous: "absolute left-[0.4rem]", - nav_button_next: "absolute right-[0.4rem]", - table: "w-full border-collapse space-y-[0.4rem]", - head_row: "flex", - head_cell: - "text-muted-foreground rounded-md w-[3.6rem] font-normal text-[1.28rem]", - row: "flex w-full mt-[0.8rem]", - cell: "size-[3.6rem] text-center text-[1.4rem] p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", day: cn( - buttonVariants({ variant: "ghost" }), - "size-[3.6rem] p-0 font-normal aria-selected:opacity-100 text-[1.3rem]", + "group/day relative aspect-square h-full w-full size-12 select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day, ), - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start, + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today, + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside, + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled, + ), + hidden: cn("invisible", defaultClassNames.hidden), ...classNames, }} components={{ - IconLeft: ({ className, ...props }) => ( - - ), - IconRight: ({ className, ...props }) => ( - - ), + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ); + } + + if (orientation === "right") { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, }} {...props} /> ); } -Calendar.displayName = "Calendar"; + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + - + ); diff --git a/packages/common/components/src/ui/date-range-picker/index.tsx b/packages/common/components/src/ui/date-range-picker/index.tsx index 3572ffb7b..1e8cdfe4c 100644 --- a/packages/common/components/src/ui/date-range-picker/index.tsx +++ b/packages/common/components/src/ui/date-range-picker/index.tsx @@ -3,19 +3,20 @@ import { useEffect, useState } from "react"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; -import type { DateRange } from "react-day-picker"; +import type { DateRange, DayPicker } from "react-day-picker"; import { cn } from "../../lib/utils"; import { Button } from "../button"; import { Calendar } from "../calendar"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; -type DateRangePickerProps = React.HTMLAttributes & { - value?: DateRange; - defaultValue?: DateRange; - disabled?: boolean; - onValueChange?: (date: DateRange | undefined) => void; -}; +type DateRangePickerProps = React.HTMLAttributes & + React.ComponentProps & { + value?: DateRange; + defaultValue?: DateRange; + disabled?: boolean; + onValueChange?: (date: DateRange | undefined) => void; + }; export function DateRangePicker({ value, @@ -24,6 +25,7 @@ export function DateRangePicker({ className, children, onValueChange, + ...props }: DateRangePickerProps) { const [dateState, setDateState] = useState( defaultValue, @@ -77,7 +79,7 @@ export function DateRangePicker({
{children} =18'} @@ -2481,7 +2485,7 @@ packages: dnd-multi-backend: 8.1.2(dnd-core@16.0.1) framer-motion: 11.18.2(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 - react-dnd: 16.0.1(@types/node@22.13.9)(@types/react@18.3.18)(react@18.3.1) + react-dnd: 16.0.1(@types/node@22.13.5)(@types/react@18.3.18)(react@18.3.1) react-dnd-html5-backend: 16.0.1 react-dnd-touch-backend: 16.0.1 react-dom: 18.3.1(react@18.3.1) @@ -5526,10 +5530,18 @@ packages: is-data-view: 1.0.2 dev: true + /date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + dev: false + /date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} dev: false + /date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dev: false + /dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} dev: false @@ -8257,6 +8269,18 @@ packages: react: 18.3.1 dev: false + /react-day-picker@9.7.0(react@18.3.1): + resolution: {integrity: sha512-urlK4C9XJZVpQ81tmVgd2O7lZ0VQldZeHzNejbwLWZSkzHH498KnArT0EHNfKBOWwKc935iMLGZdxXPRISzUxQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^18.3.1 + dependencies: + '@date-fns/tz': 1.2.0 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + dev: false + /react-dnd-html5-backend@16.0.1: resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} dependencies: