Calendar
A calendar component with single and range selection support
registry/preskok/ui/preskok-ui/calendar.tsx
"use client"
import { use } from "react"
import {
getLocalTimeZone,
today,
type CalendarDate,
} from "@internationalized/date"
import { twMerge } from "cn"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import type {
CalendarProps as CalendarPrimitiveProps,
CalendarState,
DateValue,
} from "react-aria-components/Calendar"
import {
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarGridHeader as CalendarGridHeaderPrimitive,
CalendarHeaderCell,
Calendar as CalendarPrimitive,
CalendarStateContext,
Heading,
} from "react-aria-components/Calendar"
import { composeRenderProps } from "react-aria-components/composeRenderProps"
import { useLocale } from "react-aria-components/I18nProvider"
import {
RangeCalendarStateContext,
type RangeCalendarState,
} from "react-aria-components/RangeCalendar"
import { useDateFormatter } from "react-aria/useDateFormatter"
import { Button } from "./button"
import {
Select,
SelectContent,
SelectItem,
SelectLabel,
SelectTrigger,
} from "./select"
type CalendarSelectionMode = "single" | "multiple"
type CalendarHeaderState =
| CalendarState<CalendarSelectionMode>
| RangeCalendarState<DateValue>
interface CalendarProps<T extends DateValue> extends Omit<
CalendarPrimitiveProps<T>,
"visibleDuration"
> {
className?: string
}
const Calendar = <T extends DateValue>({
className,
...props
}: CalendarProps<T>) => {
const now = today(getLocalTimeZone())
return (
<CalendarPrimitive data-slot="calendar" {...props}>
<CalendarHeader />
<CalendarGrid>
<CalendarGridHeader />
<CalendarGridBody>
{(date) => (
<CalendarCell
date={date}
className={composeRenderProps(
className,
(className, { isSelected, isDisabled }) =>
twMerge(
"text-foreground hover:bg-secondary-foreground/15 relative flex size-11 cursor-default items-center justify-center rounded-lg tabular-nums outline-hidden sm:size-9 sm:text-sm/6 forced-colors:text-[ButtonText] forced-colors:outline-0",
isSelected &&
"bg-primary pressed:bg-primary text-primary-foreground hover:bg-primary/90 data-invalid:bg-destructive data-invalid:text-destructive-foreground forced-colors:bg-[Highlight] forced-colors:text-[Highlight] forced-colors:data-invalid:bg-[Mark]",
isDisabled &&
"text-muted-foreground forced-colors:text-[GrayText]",
date.compare(now) === 0 &&
"after:bg-primary selected:after:bg-primary-foreground focus-visible:after:bg-primary-foreground after:pointer-events-none after:absolute after:start-1/2 after:bottom-1 after:z-10 after:size-[3px] after:-translate-x-1/2 after:rounded-full",
className
)
)}
>
{({ formattedDate, isFocusVisible }) => (
<span
className={twMerge(
"flex size-full items-center justify-center rounded-lg tabular-nums forced-color-adjust-none",
isFocusVisible &&
"outline-ring outline outline-2 outline-offset-2 forced-colors:outline-[Highlight]"
)}
>
{formattedDate}
</span>
)}
</CalendarCell>
)}
</CalendarGridBody>
</CalendarGrid>
</CalendarPrimitive>
)
}
const CalendarHeader = ({
isRange,
className,
...props
}: React.ComponentProps<"header"> & { isRange?: boolean }) => {
const { direction } = useLocale()
const calendarState = use(CalendarStateContext)
const rangeState = use(RangeCalendarStateContext)
const state = (calendarState ?? rangeState) as CalendarHeaderState
const monthFormatter = useDateFormatter({
month: "long",
timeZone: state.timeZone,
})
const monthYearFormatter = useDateFormatter({
month: "long",
year: "numeric",
timeZone: state.timeZone,
})
const headingText = getCalendarHeadingText({
monthFormatter,
monthYearFormatter,
state,
})
return (
<header
data-slot="calendar-header"
className={twMerge(
"flex w-full justify-between gap-1.5 pt-1 pr-1 pb-5 pl-1.5 sm:pb-4",
className
)}
{...props}
>
{!isRange && (
<div className="flex items-center gap-1.5">
<SelectMonth state={calendarState!} />
<SelectYear state={calendarState!} />
</div>
)}
<Heading
className={twMerge(
"text-muted-foreground mr-2 flex-1 text-left font-medium sm:text-sm",
!isRange && "sr-only",
className
)}
>
{headingText}
</Heading>
<div className="flex items-center gap-1">
<Button
size="sq-sm"
className="size-8 **:data-[slot=icon]:text-foreground sm:size-7"
isCircle
intent="plain"
slot="previous"
>
{direction === "rtl" ? (
<ChevronRightIcon data-slot="icon" />
) : (
<ChevronLeftIcon data-slot="icon" />
)}
</Button>
<Button
size="sq-sm"
className="size-8 **:data-[slot=icon]:text-foreground sm:size-7"
isCircle
intent="plain"
slot="next"
>
{direction === "rtl" ? (
<ChevronLeftIcon data-slot="icon" />
) : (
<ChevronRightIcon data-slot="icon" />
)}
</Button>
</div>
</header>
)
}
function getCalendarHeadingText({
monthFormatter,
monthYearFormatter,
state,
}: {
monthFormatter: Intl.DateTimeFormat
monthYearFormatter: Intl.DateTimeFormat
state: CalendarHeaderState
}) {
const start = state.visibleRange.start
const end = state.visibleRange.end
const startDate = start.toDate(state.timeZone)
const endDate = end.toDate(state.timeZone)
if (start.year !== end.year) {
return `${monthYearFormatter.format(startDate)} - ${monthYearFormatter.format(endDate)}`
}
if (start.month !== end.month) {
return `${monthFormatter.format(startDate)} - ${monthYearFormatter.format(endDate)}`
}
return monthYearFormatter.format(startDate)
}
const SelectMonth = ({
state,
}: {
state: CalendarState<CalendarSelectionMode>
}) => {
const months = []
const formatter = useDateFormatter({
month: "long",
timeZone: state.timeZone,
})
const numMonths = state.focusedDate.calendar.getMonthsInYear(
state.focusedDate
)
for (let i = 1; i <= numMonths; i++) {
const date = state.focusedDate.set({ month: i })
months.push(formatter.format(date.toDate(state.timeZone)))
}
return (
<Select
className="[popover-width:8rem]"
aria-label="Select month"
value={
state.focusedDate.month.toString() ??
(new Date().getMonth() + 1).toString()
}
onChange={(value) => {
state.setFocusedDate(state.focusedDate.set({ month: Number(value) }))
}}
>
<SelectTrigger className="w-22 text-sm/5 **:data-[slot=select-value]:inline-block **:data-[slot=select-value]:truncate sm:px-2.5 sm:py-1.5 sm:*:text-sm/5" />
<SelectContent className="min-w-0">
{months.map((month, index) => (
<SelectItem key={index} id={(index + 1).toString()} textValue={month}>
<SelectLabel>{month}</SelectLabel>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const SelectYear = ({
state,
}: {
state: CalendarState<CalendarSelectionMode>
}) => {
const years: { value: CalendarDate; formatted: string }[] = []
const formatter = useDateFormatter({
year: "numeric",
timeZone: state.timeZone,
})
for (let i = -20; i <= 20; i++) {
const date = state.focusedDate.add({ years: i })
years.push({
value: date,
formatted: formatter.format(date.toDate(state.timeZone)),
})
}
return (
<Select
aria-label="Select year"
value={20}
onChange={(value) => {
state.setFocusedDate(years[Number(value)]?.value as CalendarDate)
}}
>
<SelectTrigger className="text-sm/5 sm:px-2.5 sm:py-1.5 sm:*:text-sm/5" />
<SelectContent>
{years.map((year, i) => (
<SelectItem key={i} id={i} textValue={year.formatted}>
<SelectLabel>{year.formatted}</SelectLabel>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const CalendarGridHeader = () => {
return (
<CalendarGridHeaderPrimitive>
{(day) => (
<CalendarHeaderCell className="pb-2 text-center text-sm/6 font-semibold text-muted-foreground sm:px-0 sm:py-0.5 lg:text-xs">
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeaderPrimitive>
)
}
export { Calendar, CalendarGridHeader, CalendarHeader, SelectMonth, SelectYear }
export type { CalendarProps }
- Where it goes
- calendar
- Interaction
- dropdown
DependenciesBundle weight · light
- icons
- lucide-react