דילוג לתוכן

לוח שנה

בוחר תאריכים בתצוגת לוח חודשי להזמנות, תזמון ובחירת טווח — עם עיצוב ניאו-ברוטליסטי הכולל מסגרות וצללים.

"use client"

import * as React from "react"

ה-calendar מצייר רשת חודש לבחירת תאריך בודד, כמה תאריכים או טווח. נבנה על React DayPicker — שני הווריאנטים של ה-backend עוטפים את אותה ספרייה — ומעוצב לפי המתכון הנאו-ברוטליסטי: מסגרות עבות, צללים חדים וטיפוגרפיה מודגשת.

השתמשו בו כש:

  • שדות תאריך בטפסים — חברו אותו ל-popover כדי לבנות Date Picker לימי הולדת, דדליינים ותאריכי משלוח.
  • זרימות הזמנהmode="range" ל-check-in/check-out, 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 { 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 והעבירו את שני המאפיינים locale ו-dir לרכיב Calendar:

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

נגישות

ה-calendar עוקב אחרי מודל ניווט הרשת מ-דוגמת Date Picker Dialog של WAI-ARIA: React DayPicker מרנדר את החודש כ-role="grid" עם כל יום ככפתור אמיתי, ומכריז על ימים נבחרים ומושבתים לקוראי מסך. שני הווריאנטים של ה-backend חולקים את ההתנהגות הזו כי הם עוטפים את אותה ספרייה.

אינטראקציות מקלדת:

מקשפעולה
Tab / Shift + Tabמעבירים מוקד בין כפתורי הניווט לרשת הימים
ArrowLeft / ArrowRightמעבירים מוקד ליום הקודם / הבא
ArrowUp / ArrowDownמעבירים מוקד לאותו יום בשבוע בשבוע הקודם / הבא
Home / Endמעבירים מוקד ליום הראשון / האחרון בשבוע
PageUp / PageDownעוברים לחודש הקודם / הבא
Shift + PageUp / Shift + PageDownעוברים לשנה הקודמת / הבאה
Space / Enterבוחרים את היום שבפוקוס

תיעוד ה-API

למידע נוסף על הרכיב Calendar, עיינו בתיעוד של React DayPicker.

Changelog

תמיכה ב-RTL

אם אתם משדרגים מגרסה קודמת של רכיב Calendar, יהיה עליכם להחיל את העדכונים הבאים כדי להוסיף תמיכה ב-locale:

ייבאו את הטיפוס Locale.

הוסיפו את Locale לייבוא שלכם מ-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} />