calendar

PreviousNext

calendar

Docs
intentuiui

Preview

Loading preview…
components/ui/calendar.tsx
"use client"

import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/solid"
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date"
import { useDateFormatter } from "@react-aria/i18n"
import { use } from "react"
import type {
  CalendarProps as CalendarPrimitiveProps,
  CalendarState,
  DateValue,
} from "react-aria-components"
import {
  CalendarCell,
  CalendarGrid,
  CalendarGridBody,
  CalendarGridHeader as CalendarGridHeaderPrimitive,
  CalendarHeaderCell,
  Calendar as CalendarPrimitive,
  CalendarStateContext,
  composeRenderProps,
  Heading,
  useLocale,
} from "react-aria-components"
import { twMerge } from "tailwind-merge"
import { Button } from "./button"
import { Select, SelectContent, SelectItem, SelectLabel, SelectTrigger } from "./select"

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(
                  "relative flex size-11 cursor-default items-center justify-center rounded-lg text-fg tabular-nums outline-hidden hover:bg-secondary-fg/15 sm:size-9 sm:text-sm/6 forced-colors:text-[ButtonText] forced-colors:outline-0",
                  isSelected &&
                    "bg-primary pressed:bg-primary text-primary-fg hover:bg-primary/90 data-invalid:bg-danger data-invalid:text-danger-fg forced-colors:bg-[Highlight] forced-colors:text-[Highlight] forced-colors:data-invalid:bg-[Mark]",
                  isDisabled && "text-muted-fg forced-colors:text-[GrayText]",
                  date.compare(now) === 0 &&
                    "after:pointer-events-none after:absolute after:start-1/2 after:bottom-1 after:z-10 after:size-0.75 after:-translate-x-1/2 after:rounded-full after:bg-primary selected:after:bg-primary-fg focus-visible:after:bg-primary-fg",
                  className,
                ),
              )}
            />
          )}
        </CalendarGridBody>
      </CalendarGrid>
    </CalendarPrimitive>
  )
}

const CalendarHeader = ({
  isRange,
  className,
  ...props
}: React.ComponentProps<"header"> & { isRange?: boolean }) => {
  const { direction } = useLocale()
  const state = use(CalendarStateContext)!

  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={state} />
          <SelectYear state={state} />
        </div>
      )}
      <Heading
        className={twMerge(
          "mr-2 flex-1 text-left font-medium text-muted-fg sm:text-sm",
          !isRange && "sr-only",
          className,
        )}
      />
      <div className="flex items-center gap-1">
        <Button
          size="sq-sm"
          className="size-8 **:data-[slot=icon]:text-fg sm:size-7"
          isCircle
          intent="plain"
          slot="previous"
        >
          {direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
        </Button>
        <Button
          size="sq-sm"
          className="size-8 **:data-[slot=icon]:text-fg sm:size-7"
          isCircle
          intent="plain"
          slot="next"
        >
          {direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />}
        </Button>
      </div>
    </header>
  )
}

const SelectMonth = ({ state }: { state: CalendarState }) => {
  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 }) => {
  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 font-semibold text-muted-fg text-sm/6 sm:px-0 sm:py-0.5 lg:text-xs">
          {day}
        </CalendarHeaderCell>
      )}
    </CalendarGridHeaderPrimitive>
  )
}

export type { CalendarProps }
export { Calendar, SelectMonth, SelectYear, CalendarHeader, CalendarGridHeader }

Installation

npx shadcn@latest add @intentui/calendar

Usage

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