shadimage

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