Przejdź do treści

Kalendarz

Wybór daty w układzie miesięcznym do rezerwacji, planowania i zaznaczania przedziałów czasowych — z neobrutalistycznymi ramkami i cieniami.

"use client"

import * as React from "react"

Kalendarz renderuje siatkę miesiąca do wyboru pojedynczej daty, wielu dat lub zakresu. Zbudowany na React DayPicker — oba warianty backendów opakowują tę samą bibliotekę — i ostylowany zgodnie z neobrutalistyczną recepturą: grube obramowania, twarde cienie i wyrazista typografia.

Sięgnij po ten komponent, gdy:

  • Pola dat w formularzach — połącz go z popoverem, aby zbudować Date Picker do dat urodzenia, terminów i dostaw.
  • Procesy rezerwacyjnemode="range" dla zameldowania i wymeldowania, disabled dla dni już zajętych.
  • Filtry na pulpitach — zakresy dat raportów, widoki dostępności i gotowe skróty w rodzaju „ostatnie 7 dni”.

Instalacja

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

Zainstaluj następujące zależności:

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

Dodaj komponent Button do swojego projektu.

Komponent Calendar używa komponentu Button. Upewnij się, że masz go zainstalowanego w swoim projekcie.

Skopiuj i wklej poniższy kod do swojego projektu.

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 }

Zaktualizuj ścieżki importów, aby pasowały do struktury twojego projektu.

Użycie

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"
  />
)

Więcej informacji znajdziesz w dokumentacji React DayPicker.

Informacje

Komponent Calendar jest zbudowany na React DayPicker.

Date Picker

Możesz użyć komponentu <Calendar>, aby zbudować date picker. Więcej informacji znajdziesz na stronie Date Picker.

Kalendarz perski / hidżri / dżalali

Aby użyć kalendarza perskiego, edytuj components/ui/calendar.tsx i zamień react-day-picker na react-day-picker/persian.

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

import * as React from "react"

Wybrana data (ze strefą czasową)

Komponent Calendar przyjmuje prop timeZone, aby zapewnić, że daty są wyświetlane i wybierane w lokalnej strefie czasowej użytkownika.

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}
    />
  )
}

Uwaga: Jeśli zauważysz przesunięcie wybranej daty (na przykład wybranie 20. podświetla 19.), upewnij się, że prop timeZone jest ustawiony na lokalną strefę czasową użytkownika.

Dlaczego po stronie klienta? Strefa czasowa jest wykrywana za pomocą Intl.DateTimeFormat().resolvedOptions().timeZone wewnątrz useEffect, aby zapewnić zgodność z renderowaniem po stronie serwera. Wykrywanie strefy czasowej podczas renderowania powodowałoby niezgodności hydracji, ponieważ serwer i klient mogą znajdować się w różnych strefach czasowych.

Przykłady

Podstawowy

Podstawowy komponent kalendarza. Użyliśmy className="rounded border-2 shadow-md", aby ostylować kalendarz.

"use client"

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

Kalendarz zakresu

Użyj propa mode="range", aby włączyć wybór zakresu.

"use client"

import * as React from "react"

Wybór miesiąca i roku

Użyj captionLayout="dropdown", aby pokazać rozwijane listy miesiąca i roku.

"use client"

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

Presety

"use client"

import * as React from "react"

Wybór daty i godziny

"use client"

import * as React from "react"

Zarezerwowane daty

"use client"

import * as React from "react"

Niestandardowy rozmiar komórki

"use client"

import * as React from "react"

Możesz dostosować rozmiar komórek kalendarza za pomocą zmiennej CSS --cell-size. Możesz również sprawić, by był responsywny, używając wartości specyficznych dla poszczególnych breakpointów:

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

Albo użyj wartości stałych:

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

Numery tygodni

Użyj showWeekNumber, aby pokazać numery tygodni.

"use client"

import * as React from "react"

RTL

Aby włączyć obsługę RTL w Neobrutalism, zobacz przewodnik konfiguracji RTL.

Zobacz też przewodnik hidżri, aby włączyć kalendarz perski / hidżri / dżalali.

"use client"

import * as React from "react"

Podczas używania RTL zaimportuj locale z react-day-picker/locale i przekaż komponentowi Calendar zarówno prop locale, jak i prop dir:

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

Dostępność

Kalendarz realizuje model nawigacji po siatce ze wzorca WAI-ARIA Date Picker Dialog: React DayPicker renderuje miesiąc jako role="grid", w którym każdy dzień jest prawdziwym przyciskiem, i ogłasza czytnikom ekranu dni wybrane oraz wyłączone. Oba warianty backendów dzielą to zachowanie, bo opakowują tę samą bibliotekę.

Obsługa klawiatury:

KlawiszDziałanie
Tab / Shift + TabPrzenosi fokus między przyciskami nawigacji a siatką dni
ArrowLeft / ArrowRightPrzenosi fokus na poprzedni / następny dzień
ArrowUp / ArrowDownPrzenosi fokus na ten sam dzień tygodnia w poprzednim / następnym tygodniu
Home / EndPrzenosi fokus na pierwszy / ostatni dzień tygodnia
PageUp / PageDownPrzechodzi do poprzedniego / następnego miesiąca
Shift + PageUp / Shift + PageDownPrzechodzi do poprzedniego / następnego roku
Space / EnterWybiera dzień z fokusem

Referencja API

Więcej informacji o komponencie Calendar znajdziesz w dokumentacji React DayPicker.

Lista zmian

Obsługa RTL

Jeśli aktualizujesz z wcześniejszej wersji komponentu Calendar, musisz zastosować następujące zmiany, aby dodać obsługę locale:

Zaimportuj typ Locale.

Dodaj Locale do swoich importów z react-day-picker:

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

Dodaj prop locale do komponentu Calendar.

Dodaj prop locale do propów komponentu:

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

Przekaż locale do DayPicker.

Przekaż prop locale do komponentu 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,
      }}

Zaktualizuj CalendarDayButton, aby przyjmował locale.

Zaktualizuj sygnaturę komponentu CalendarDayButton i przekaż locale:

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

Zaktualizuj formatowanie dat w CalendarDayButton.

Użyj locale?.code w formatowaniu dat:

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

Przekaż locale do komponentu DayButton.

Zaktualizuj użycie komponentu DayButton, aby przekazać prop locale:

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

Zaktualizuj klasy CSS uwzględniające RTL.

Zastąp klasy kierunkowe właściwościami logicznymi, aby uzyskać lepszą obsługę 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)

Po zastosowaniu tych zmian możesz użyć propa locale, aby zapewnić formatowanie specyficzne dla danego locale:

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