Календар
Вибір дати у вигляді місячної сітки для бронювання, планування та вибору діапазону — з необруталістичним оформленням у вигляді рамок і тіней.
"use client"
import * as React from "react"Календар рендерить сітку місяця для вибору однієї дати, кількох дат або діапазону. Побудований на React DayPicker — обидва варіанти бекенду обгортають ту саму бібліотеку — і оформлений за рецептом необруталізму: товсті рамки, різкі тіні та масивний шрифт.
Коли це стане в пригоді:
- Поля дати у формах — поєднайте з popover, щоб зібрати Date Picker для днів народження, дедлайнів і дат доставки.
- Сценарії бронювання —
mode="range"для check-in/check-out,disabledдля уже зайнятих днів. - Фільтри дашборда — діапазони дат звітів, перегляди доступності та пресети на кшталт «останні 7 днів».
Встановлення#
pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.jsonnpx shadcn@latest add https://neobrutalism.com/r/base/calendar.jsonyarn dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.jsonbunx --bun shadcn@latest add https://neobrutalism.com/r/base/calendar.json
Встановіть такі залежності:
pnpm add react-day-picker date-fnsnpm install react-day-picker date-fnsyarn add react-day-picker date-fnsbun add react-day-picker date-fns
Додайте компонент Button до свого проєкту.
Компонент Calendar використовує компонент Button. Переконайтеся, що він встановлений у вашому проєкті.
Скопіюйте та вставте наведений нижче код у свій проєкт.
"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, імпортуйте локаль із react-day-picker/locale і передайте компоненту Calendar як проп locale, так і проп dir:
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" із кожним днем як справжньою кнопкою й оголошує вибрані та вимкнені дні програмам зчитування з екрана. Обидва варіанти бекенду поводяться однаково, бо обгортають ту саму бібліотеку.
Клавіатурні взаємодії:
| Клавіша | Дія |
|---|---|
Tab / Shift + Tab | Переміщує фокус між кнопками навігації та сіткою днів |
ArrowLeft / ArrowRight | Переміщує фокус на попередній / наступний день |
ArrowUp / ArrowDown | Переміщує фокус на той самий день тижня в попередній / наступний тиждень |
Home / End | Переміщує фокус на перший / останній день тижня |
PageUp / PageDown | Переходить до попереднього / наступного місяця |
Shift + PageUp / Shift + PageDown | Переходить до попереднього / наступного року |
Space / Enter | Вибирає день у фокусі |
Довідник API#
Докладніше про компонент Calendar див. у документації React DayPicker.
Журнал змін#
Підтримка RTL#
Якщо ви оновлюєтеся з попередньої версії компонента Calendar, вам потрібно застосувати такі зміни, щоб додати підтримку локалей:
Імпортуйте тип 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, щоб надати форматування, специфічне для конкретної локалі:
import { enUS } from "react-day-picker/locale"
;<Calendar mode="single" selected={date} onSelect={setDate} locale={enUS} />