پرش به محتوا

تقویم

یک انتخابگر تاریخ در قالب جدول ماهانه برای رزرو، زمان‌بندی و انتخاب بازه — با جلوه‌ی نئوبروتالیستی مرزها و سایه‌ها.

"use client"

import * as React from "react"

تقویم یک شبکهٔ ماه برای انتخاب یک تاریخ، چند تاریخ یا یک بازه رندر می‌کند. بر پایهٔ React DayPicker — هر دو variant بک‌اند همین کتابخانه را می‌پیچند — و با دستور پخت نئوبروتالیستی استایل شده: حاشیه‌های ضخیم، سایه‌های سخت و تایپوگرافی پررنگ.

برای این موارد از آن استفاده کنید:

  • فیلدهای تاریخ فرم — با popover جفتش کنید تا Date Picker برای تولد، موعد و تاریخ تحویل بسازید.
  • جریان‌های رزروmode="range" برای ورود/خروج، disabled برای روزهای قبلاً رزروشده.
  • فیلتر داشبورد — بازهٔ گزارش، نمای موجودی و میانبرهای ازپیش‌تعیین مثل «7 روز گذشته».

نصب

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
npx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/calendar.json

وابستگی‌های زیر را نصب کنید:

pnpm add react-day-picker date-fns
npm install react-day-picker date-fns
yarn add react-day-picker date-fns
bun add react-day-picker date-fns

کامپوننت Button را به پروژهٔ خود اضافه کنید.

کامپوننت Calendar از کامپوننت Button استفاده می‌کند. مطمئن شوید که آن را در پروژهٔ خود نصب کرده‌اید.

کد زیر را کپی کنید و در پروژهٔ خود بچسبانید.

components/ui/calendar.tsx
"use client"

import * as React from "react"
import {
  ChevronDownIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
} from "lucide-react"
import {
  DayPicker,
  getDefaultClassNames,
  type DayButton,
  type Locale,
} from "react-day-picker"

import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  fixedWeeks = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  locale,
  formatters,
  components,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
  const defaultClassNames = getDefaultClassNames()

  return (
    <DayPicker
      showOutsideDays={showOutsideDays}
      fixedWeeks={fixedWeeks}
      className={cn(
        "group/calendar bg-card p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
        className
      )}
      captionLayout={captionLayout}
      locale={locale}
      formatters={{
        formatMonthDropdown: (date, dateLib) =>
          dateLib
            ? dateLib.format(date, "LLL")
            : date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}
      classNames={{
        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 }),
          "size-(--cell-size) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50",
          defaultClassNames.button_previous
        ),
        button_next: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50",
          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-sm font-medium",
          defaultClassNames.dropdowns
        ),
        dropdown_root: cn(
          "cn-calendar-dropdown-root relative rounded-(--cell-radius)",
          defaultClassNames.dropdown_root
        ),
        dropdown: cn(
          "absolute inset-0 bg-popover opacity-0",
          defaultClassNames.dropdown
        ),
        caption_label: cn(
          "font-head font-medium select-none",
          captionLayout === "label"
            ? "cn-calendar-caption text-sm"
            : "cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
          defaultClassNames.caption_label
        ),
        month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
        weekdays: cn("flex", defaultClassNames.weekdays),
        weekday: cn(
          "flex w-(--cell-size) items-center justify-center rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
          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-[0.8rem] text-muted-foreground select-none",
          defaultClassNames.week_number
        ),
        day: cn(
          "group/day relative size-(--cell-size) rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
          props.showWeekNumber
            ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
            : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
          defaultClassNames.day
        ),
        range_start: cn(
          "relative isolate z-0 rounded-l-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-primary/30",
          defaultClassNames.range_start
        ),
        range_middle: cn("rounded-none", defaultClassNames.range_middle),
        range_end: cn(
          "relative isolate z-0 rounded-r-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-primary/30",
          defaultClassNames.range_end
        ),
        today: cn(
          "rounded-(--cell-radius) border-2 border-primary text-foreground 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={{
        Root: ({ className, rootRef, ...props }) => {
          return (
            <div
              data-slot="calendar"
              ref={rootRef}
              className={cn(className)}
              {...props}
            />
          )
        },
        Chevron: ({ className, orientation, ...props }) => {
          if (orientation === "left") {
            return (
              <ChevronLeftIcon
                className={cn("cn-rtl-flip size-4", className)}
                {...props}
              />
            )
          }

          if (orientation === "right") {
            return (
              <ChevronRightIcon
                className={cn("cn-rtl-flip size-4", className)}
                {...props}
              />
            )
          }

          return (
            <ChevronDownIcon className={cn("size-4", className)} {...props} />
          )
        },
        DayButton: ({ ...props }) => (
          <CalendarDayButton locale={locale} {...props} />
        ),
        WeekNumber: ({ children, ...props }) => {
          return (
            <td {...props}>
              <div className="flex size-(--cell-size) items-center justify-center text-center">
                {children}
              </div>
            </td>
          )
        },
        ...components,
      }}
      {...props}
    />
  )
}

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
  const defaultClassNames = getDefaultClassNames()

  const ref = React.useRef<HTMLButtonElement>(null)
  React.useEffect(() => {
    if (modifiers.focused) ref.current?.focus()
  }, [modifiers.focused])

  return (
    <Button
      ref={ref}
      variant="ghost"
      size="icon"
      data-day={`${day.date.getFullYear()}-${String(
        day.date.getMonth() + 1
      ).padStart(2, "0")}-${String(day.date.getDate()).padStart(2, "0")}`}
      data-selected-single={
        modifiers.selected &&
        !modifiers.range_start &&
        !modifiers.range_end &&
        !modifiers.range_middle
      }
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      className={cn(
        "relative isolate z-10 flex size-(--cell-size) flex-col items-center justify-center gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:outline-2 group-data-[focused=true]/day:outline-offset-2 group-data-[focused=true]/day:outline-primary data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:border-2 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-primary/30 data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:border-2 data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:border-2 data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
        defaultClassNames.day,
        className
      )}
      {...props}
    />
  )
}

export { Calendar, CalendarDayButton }

مسیرهای import را متناسب با ساختار پروژهٔ خود به‌روزرسانی کنید.

استفاده

import { Calendar } from "@/components/ui/calendar"
const [date, setDate] = React.useState<Date | undefined>(new Date())
 
return (
  <Calendar
    mode="single"
    selected={date}
    onSelect={setDate}
    className="rounded border-2 shadow-md"
  />
)

برای اطلاعات بیشتر، مستندات React DayPicker را ببینید.

درباره

کامپوننت Calendar بر پایهٔ React DayPicker ساخته شده است.

Date Picker

می‌توانید از کامپوننت <Calendar> برای ساخت یک انتخابگر تاریخ استفاده کنید. برای اطلاعات بیشتر صفحهٔ Date Picker را ببینید.

تقویم فارسی / هجری / جلالی

برای استفاده از تقویم فارسی، components/ui/calendar.tsx را ویرایش کنید و react-day-picker را با react-day-picker/persian جایگزین کنید.

- import { DayPicker } from "react-day-picker"
+ import { DayPicker } from "react-day-picker/persian"
"use client"

import * as React from "react"

تاریخ انتخاب‌شده (با منطقهٔ زمانی)

کامپوننت Calendar یک پراپ timeZone می‌پذیرد تا اطمینان حاصل شود که تاریخ‌ها در منطقهٔ زمانی محلی کاربر نمایش داده و انتخاب می‌شوند.

export function CalendarWithTimezone() {
  const [date, setDate] = React.useState<Date | undefined>(undefined)
  const [timeZone, setTimeZone] = React.useState<string | undefined>(undefined)
 
  React.useEffect(() => {
    setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
  }, [])
 
  return (
    <Calendar
      mode="single"
      selected={date}
      onSelect={setDate}
      timeZone={timeZone}
    />
  )
}

توجه: اگر جابه‌جایی در تاریخ انتخاب‌شده می‌بینید (برای مثال، انتخاب روز 20 روز 19 را برجسته می‌کند)، مطمئن شوید که پراپ timeZone روی منطقهٔ زمانی محلی کاربر تنظیم شده است.

چرا سمت کلاینت؟ منطقهٔ زمانی با Intl.DateTimeFormat().resolvedOptions().timeZone داخل یک useEffect تشخیص داده می‌شود تا سازگاری با رندر سمت سرور تضمین شود. تشخیص منطقهٔ زمانی هنگام رندر باعث ناهماهنگی هیدراسیون می‌شود، زیرا سرور و کلاینت ممکن است در مناطق زمانی متفاوتی باشند.

نمونه‌ها

پایه

یک کامپوننت تقویم پایه. ما از className="rounded border-2 shadow-md" برای استایل‌دهی به تقویم استفاده کردیم.

"use client"

import { Calendar } from "@/components/ui/calendar"

تقویم بازه

از پراپ mode="range" برای فعال کردن انتخاب بازه استفاده کنید.

"use client"

import * as React from "react"

انتخابگر ماه و سال

از captionLayout="dropdown" برای نمایش دراپ‌داون‌های ماه و سال استفاده کنید.

"use client"

import { Calendar } from "@/components/ui/calendar"

پیش‌تنظیم‌ها

"use client"

import * as React from "react"

انتخابگر تاریخ و زمان

"use client"

import * as React from "react"

تاریخ‌های رزرو‌شده

"use client"

import * as React from "react"

اندازهٔ سلول سفارشی

"use client"

import * as React from "react"

می‌توانید اندازهٔ سلول‌های تقویم را با متغیر CSS --cell-size سفارشی کنید. همچنین می‌توانید با استفاده از مقادیر مخصوص هر نقطهٔ شکست، آن را واکنش‌گرا کنید:

<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  className="rounded border-2 shadow-md [--cell-size:--spacing(11)] md:[--cell-size:--spacing(12)]"
/>

یا از مقادیر ثابت استفاده کنید:

<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  className="rounded border-2 shadow-md [--cell-size:2.75rem] md:[--cell-size:3rem]"
/>

شماره‌های هفته

از showWeekNumber برای نمایش شماره‌های هفته استفاده کنید.

"use client"

import * as React from "react"

RTL

برای فعال‌سازی پشتیبانی RTL در Neobrutalism، راهنمای پیکربندی RTL را ببینید.

همچنین راهنمای هجری را ببینید تا تقویم فارسی / هجری / جلالی را فعال کنید.

"use client"

import * as React from "react"

هنگام استفاده از RTL، locale را از react-day-picker/locale import کنید و هر دو پراپ locale و dir را به کامپوننت Calendar پاس دهید:

import { arSA } from "react-day-picker/locale"
 
;<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  locale={arSA}
  dir="rtl"
/>

دسترسی‌پذیری

تقویم از مدل ناوبری شبکه‌ای الگوی WAI-ARIA Date Picker Dialog پیروی می‌کند: React DayPicker ماه را به‌صورت role="grid" رندر می‌کند، هر روز یک دکمهٔ واقعی است، و روزهای انتخاب‌شده و غیرفعال را به صفحه‌خوان اعلام می‌کند. هر دو variant بک‌اند همین رفتار را دارند چون همان کتابخانه را می‌پیچند.

تعاملات صفحه‌کلید:

کلیدعمل
Tab / Shift + Tabجابه‌جایی فوکوس بین دکمه‌های nav و شبکهٔ روزها
ArrowLeft / ArrowRightانتقال فوکوس به روز قبلی / بعدی
ArrowUp / ArrowDownانتقال فوکوس به همان روز هفته در هفتهٔ قبلی / بعدی
Home / Endانتقال فوکوس به اولین / آخرین روز هفته
PageUp / PageDownرفتن به ماه قبلی / بعدی
Shift + PageUp / Shift + PageDownرفتن به سال قبلی / بعدی
Space / Enterانتخاب روز فوکوس‌شده

مرجع API

برای اطلاعات بیشتر دربارهٔ کامپوننت Calendar، مستندات React DayPicker را ببینید.

تاریخچهٔ تغییرات

پشتیبانی RTL

اگر از نسخهٔ قبلی کامپوننت Calendar ارتقا می‌دهید، باید تغییرات زیر را برای افزودن پشتیبانی locale اعمال کنید:

تایپ Locale را import کنید.

Locale را به importهای خود از react-day-picker اضافه کنید:

  import {
    DayPicker,
    getDefaultClassNames,
    type DayButton,
+   type Locale,
  } from "react-day-picker"

پراپ locale را به کامپوننت Calendar اضافه کنید.

پراپ locale را به پراپ‌های کامپوننت اضافه کنید:

  function Calendar({
    className,
    classNames,
    showOutsideDays = true,
    captionLayout = "label",
    buttonVariant = "ghost",
+   locale,
    formatters,
    components,
    ...props
  }: React.ComponentProps<typeof DayPicker> & {
    buttonVariant?: React.ComponentProps<typeof Button>["variant"]
  }) {

locale را به DayPicker پاس دهید.

پراپ locale را به کامپوننت DayPicker پاس دهید:

    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn(...)}
      captionLayout={captionLayout}
+     locale={locale}
      formatters={{
        formatMonthDropdown: (date) =>
-         date.toLocaleString("default", { month: "short" }),
+         date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}

CalendarDayButton را به‌روزرسانی کنید تا locale را بپذیرد.

امضای کامپوننت CalendarDayButton را به‌روزرسانی کنید و locale را پاس دهید:

  function CalendarDayButton({
    className,
    day,
    modifiers,
+   locale,
    ...props
- }: React.ComponentProps<typeof DayButton>) {
+ }: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {

قالب‌بندی تاریخ در CalendarDayButton را به‌روزرسانی کنید.

از locale?.code در قالب‌بندی تاریخ استفاده کنید:

    <Button
      variant="ghost"
      size="icon"
-     data-day={day.date.toLocaleDateString()}
+     data-day={day.date.toLocaleDateString(locale?.code)}
      ...
    />

locale را به کامپوننت DayButton پاس دهید.

استفادهٔ کامپوننت DayButton را به‌روزرسانی کنید تا پراپ locale را پاس دهد:

      components={{
        ...
-       DayButton: CalendarDayButton,
+       DayButton: ({ ...props }) => (
+         <CalendarDayButton locale={locale} {...props} />
+       ),
        ...
      }}

کلاس‌های CSS آگاه از RTL را به‌روزرسانی کنید.

کلاس‌های جهت‌دار را با ویژگی‌های منطقی جایگزین کنید تا پشتیبانی RTL بهتر شود:

  // In the day classNames:
- [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)
+ [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)
- [&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)
- [&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)
 
  // In range_start classNames:
- rounded-l-(--cell-radius) ... after:right-0
+ rounded-s-(--cell-radius) ... after:end-0
 
  // In range_end classNames:
- rounded-r-(--cell-radius) ... after:left-0
+ rounded-e-(--cell-radius) ... after:start-0
 
  // In CalendarDayButton className:
- data-[range-end=true]:rounded-r-(--cell-radius)
+ data-[range-end=true]:rounded-e-(--cell-radius)
- data-[range-start=true]:rounded-l-(--cell-radius)
+ data-[range-start=true]:rounded-s-(--cell-radius)

پس از اعمال این تغییرات، می‌توانید از پراپ locale برای ارائهٔ قالب‌بندی مخصوص هر locale استفاده کنید:

import { enUS } from "react-day-picker/locale"
 
;<Calendar mode="single" selected={date} onSelect={setDate} locale={enUS} />