From e1a8093b4273213f3963d74c2060c923f2c2094b Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Fri, 7 Nov 2025 19:49:37 +0800 Subject: [PATCH] feat: add calendar ui component --- web/components/ui/calendar/index.spec.tsx | 238 ++++++++++++ web/components/ui/calendar/index.stories.tsx | 365 +++++++++++++++++++ web/components/ui/calendar/index.tsx | 215 +++++++++++ web/package.json | 8 +- web/pnpm-lock.yaml | 18 +- 5 files changed, 831 insertions(+), 13 deletions(-) create mode 100644 web/components/ui/calendar/index.spec.tsx create mode 100644 web/components/ui/calendar/index.stories.tsx create mode 100644 web/components/ui/calendar/index.tsx diff --git a/web/components/ui/calendar/index.spec.tsx b/web/components/ui/calendar/index.spec.tsx new file mode 100644 index 0000000..1d5a0d3 --- /dev/null +++ b/web/components/ui/calendar/index.spec.tsx @@ -0,0 +1,238 @@ +import { render, screen, waitFor } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { DayPicker as MockedDayPicker } from "react-day-picker"; + +import { Calendar, CalendarDayButton } from "./index"; + +jest.mock("react-day-picker", () => { + const React = jest.requireActual("react"); + const mock = jest.fn(); + + const MockDayPicker = React.forwardRef< + HTMLDivElement, + Record + >((props, ref) => { + mock(props); + return null; + }); + MockDayPicker.displayName = "MockDayPicker"; + + (MockDayPicker as unknown as { mock: jest.Mock }).mock = mock; + + const DayButton = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes + >((props, ref) => ( + + )); + DayButton.displayName = "MockDayButton"; + + const defaultClassNames = { + root: "rdp-root", + months: "rdp-months", + month: "rdp-month", + nav: "rdp-nav", + button_previous: "rdp-prev", + button_next: "rdp-next", + month_caption: "rdp-caption", + dropdowns: "rdp-dropdowns", + dropdown_root: "rdp-dropdown-root", + dropdown: "rdp-dropdown", + caption_label: "rdp-caption-label", + weekdays: "rdp-weekdays", + weekday: "rdp-weekday", + week: "rdp-week", + week_number_header: "rdp-week-number-header", + week_number: "rdp-week-number", + day: "rdp-day", + range_start: "rdp-range-start", + range_middle: "rdp-range-middle", + range_end: "rdp-range-end", + today: "rdp-today", + outside: "rdp-outside", + disabled: "rdp-disabled", + hidden: "rdp-hidden", + }; + + return { + DayPicker: MockDayPicker, + DayButton, + getDefaultClassNames: () => ({ ...defaultClassNames }), + }; +}); + +const dayPickerMock = (MockedDayPicker as unknown as { mock: jest.Mock }).mock; + +describe("Calendar", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("applies global design token classes to structural slots", () => { + render(); + + expect(dayPickerMock).toHaveBeenCalledTimes(1); + const props = dayPickerMock.mock.calls[0][0] as { + className: string; + classNames: Record; + }; + + expect(props.className).toContain("bg-card"); + expect(props.className).toContain("group/calendar"); + expect(props.classNames.today).toContain("text-primary"); + expect(props.classNames.range_start).toContain("bg-primary"); + expect(props.classNames.day).toContain("rdp-day"); + }); + + test("merges consumer className overrides and custom slots", () => { + const CustomWeekNumber = () => null; + + render( + + ); + + const props = dayPickerMock.mock.calls[0][0] as { + className: string; + classNames: Record; + components: Record; + }; + + expect(props.className).toContain("custom-shell"); + expect(props.classNames.day).toContain("custom-day"); + expect(props.components.WeekNumber).toBe(CustomWeekNumber); + expect(props.components.DayButton).toBe(CalendarDayButton); + }); + + test("respects formatter overrides and button variants", () => { + const formatMonthDropdown = jest.fn().mockReturnValue("Mon"); + + render( + + ); + + const props = dayPickerMock.mock.calls[0][0] as { + captionLayout?: string; + formatters?: { formatMonthDropdown?: (date: Date) => string }; + classNames: Record; + }; + + expect(props.captionLayout).toBe("dropdown"); + expect(props.formatters?.formatMonthDropdown).toBe(formatMonthDropdown); + expect(props.classNames.button_next).toContain("border-input"); + expect(props.classNames.button_next).toContain("bg-background"); + }); + + test("forwards DayPicker props like range mode and outside days", () => { + render( + + ); + + const props = dayPickerMock.mock.calls[0][0] as { + showOutsideDays: boolean; + mode?: string; + numberOfMonths?: number; + }; + + expect(props.showOutsideDays).toBe(false); + expect(props.mode).toBe("range"); + expect(props.numberOfMonths).toBe(2); + }); + + test("exposes token-aware root slot for custom wrappers", () => { + render(); + + const props = dayPickerMock.mock.calls[0][0] as { + components: { + Root: React.ComponentType<{ + className?: string; + rootRef?: React.Ref; + }>; + }; + }; + + const RootComponent = props.components.Root; + const { getByTestId } = render( + undefined} + className="token-shell" + data-testid="calendar-root" + /> + ); + + const root = getByTestId("calendar-root"); + expect(root).toHaveAttribute("data-slot", "calendar"); + expect(root).toHaveClass("token-shell"); + }); +}); + +describe("CalendarDayButton", () => { + test("applies token classes and datasets for single selections", () => { + const day = { date: new Date(2024, 5, 15) }; + + render( + + 15 + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-day", day.date.toLocaleDateString()); + expect(button).toHaveAttribute("data-selected-single", "true"); + expect(button).toHaveClass("rdp-day"); + expect(button.className).toContain( + "data-[selected-single=true]:bg-primary" + ); + expect(button.className).toContain( + "data-[selected-single=true]:text-primary-foreground" + ); + }); + + test("focuses itself when the focused modifier is present", async () => { + render( + + 16 + + ); + + const button = screen.getByRole("button"); + await waitFor(() => expect(button).toHaveFocus()); + expect(button).toHaveAttribute("data-range-start", "true"); + expect(button).toHaveAttribute("data-range-end", "true"); + expect(button.className).toContain( + "group-data-[focused=true]/day:ring-ring/40" + ); + }); +}); diff --git a/web/components/ui/calendar/index.stories.tsx b/web/components/ui/calendar/index.stories.tsx new file mode 100644 index 0000000..28590a0 --- /dev/null +++ b/web/components/ui/calendar/index.stories.tsx @@ -0,0 +1,365 @@ +import { useMemo, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { addDays, format, startOfDay } from "date-fns"; +import { ar } from "date-fns/locale"; +import type { DateRange } from "react-day-picker"; + +import { cn } from "@/lib/utils"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +import { Calendar } from "./index"; + +const meta = { + title: "UI/Calendar", + component: Calendar, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Calendar builds on react-day-picker and ships global design tokens (background, accent, primary, border) so that date selection feels consistent in cards, popovers, and dashboards.", + }, + }, + }, + args: { + captionLayout: "label", + buttonVariant: "ghost", + showOutsideDays: true, + }, + argTypes: { + captionLayout: { + control: "select", + options: ["label", "dropdown", "dropdown-months", "dropdown-years"], + }, + buttonVariant: { + control: "select", + options: ["ghost", "outline", "secondary", "default"], + }, + showOutsideDays: { + control: "boolean", + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const Showcase = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +const MetricGrid = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + +interface MetricTileProps { + label: string; + value: string; + secondary?: string; + accent?: boolean; +} + +const MetricTile = ({ label, value, secondary, accent }: MetricTileProps) => ( +
+

+ {label} +

+

{value}

+ {secondary ? ( +

{secondary}

+ ) : null} +
+); + +const StorySection = ({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) => ( + + + {title} + {description} + + {children} + +); + +export const Playground: Story = { + render: args => { + const [selectedDate, setSelectedDate] = useState( + startOfDay(new Date()) + ); + + const nextRetro = selectedDate ? addDays(selectedDate, 7) : undefined; + + return ( + + +
+
+

+ Summary metrics +

+ + + + + + +
+
+ { + setSelectedDate(value); + }} + /> +

+ Every interactive element inherits `bg-card`, + `border-card-border` and `text-foreground`, so the picker feels + integrated with the rest of the dashboard surface. +

+
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: + "The playground keeps the calendar controlled so selecting a date updates localized summaries without breaking the tokenized shell.", + }, + }, + }, +}; + +export const RangeSelection: Story = { + args: { + buttonVariant: "outline", + showOutsideDays: false, + captionLayout: "dropdown", + numberOfMonths: 2, + fixedWeeks: true, + fromYear: new Date().getFullYear() - 1, + toYear: new Date().getFullYear() + 2, + }, + render: args => { + const today = useMemo(() => startOfDay(new Date()), []); + const [range, setRange] = useState({ + from: today, + to: addDays(today, 4), + }); + + return ( + + +
+
+ + + + + + +
+

Why fixed weeks?

+

+ Fixed weeks keep the component height consistent, so the modal + never jumps even when ranged dates fall at the end of the + month. +

+
+
+
+ { + setRange(value); + }} + disabled={{ before: today }} + /> +
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: + "Pair two months with the outlined navigation buttons when you need parity with panel borders. Fixed weeks keep the layout height predictable for modals.", + }, + }, + }, +}; + +export const LocalizedRtl: Story = { + args: { + captionLayout: "dropdown-years", + buttonVariant: "secondary", + showOutsideDays: true, + fromYear: 2020, + toYear: 2030, + }, + render: args => { + const [preferredDate, setPreferredDate] = useState( + startOfDay(new Date()) + ); + + return ( + + +
+
+

+ Arabic launch details +

+ + + +

+ Wrap the calendar in an RTL container to automatically flip day + grids and icon chevrons. Tokens continue to read from globals. +

+
+
+ { + setPreferredDate(value); + }} + /> +
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Use the same component for localized dashboards by passing the Date-Fns locale and wrapping the slot with `dir="rtl"`.', + }, + }, + }, +}; diff --git a/web/components/ui/calendar/index.tsx b/web/components/ui/calendar/index.tsx new file mode 100644 index 0000000..b9c2e1a --- /dev/null +++ b/web/components/ui/calendar/index.tsx @@ -0,0 +1,215 @@ +"use client"; + +import * as React from "react"; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react"; +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; + +import { cn } from "@/lib/utils"; +import { Button, buttonVariants } from "@/components/ui/button"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: 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={{ + root: cn("w-fit text-sm", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-8 md:flex-row md:gap-10", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-5", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-2 flex w-full items-center justify-between gap-2 px-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size] text-base font-semibold tracking-tight", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + 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( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex gap-2 px-1", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-lg px-1 py-1 text-[0.75rem] font-semibold uppercase tracking-[0.2em]", + defaultClassNames.weekday + ), + week: cn("mt-4 grid w-full grid-cols-7 gap-2", 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 + ), + day: cn("group/day relative select-none", defaultClassNames.day), + range_start: cn( + "bg-primary rounded-l-full", + defaultClassNames.range_start + ), + range_middle: cn( + "bg-accent/60 rounded-none", + defaultClassNames.range_middle + ), + range_end: cn("bg-primary rounded-r-full", defaultClassNames.range_end), + today: cn("text-primary font-semibold", defaultClassNames.today), + outside: cn("text-muted-foreground/60", defaultClassNames.outside), + disabled: cn("text-muted-foreground/40", defaultClassNames.disabled), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + 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} + /> + ); +} + +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 ( +