primitives

Calendar

Calendar — базовый примитив RevueUI (Reka UI + shadcn-совместимый API).

Загрузка превью…

src/ui/calendar/Calendar.vue

<!--
  ЗАМЕНА ПРИМИТИВА (см. docs/PORTING.md, §5/§13): оригинал построен на
  `react-day-picker` (не имеет Vue-порта, принимает весь его `mode`-generic
  API — `React.ComponentProps<typeof DayPicker>`). Заменён на `CalendarRoot` +
  `RangeCalendarRoot` из `reka-ui` и `@internationalized/date`.

  ДОЛГ 1 (docs/PORTING.md §13): до этой правки примитив поддерживал только
  одиночный выбор без `classNames`/`disabled`-матчеров, из-за чего
  `packages/blocks/src/calendar/` держал свою обёртку
  `_shared/CCalendar.vue` поверх `CalendarRoot`/`RangeCalendarRoot` —
  дублирование. Здесь эта обёртка ПОДНЯТА в примитив дословно (тот же
  шаблон/логика, уже проверенные всеми 30 блоками категории `calendar`),
  обёртка в блоках удалена, блоки переведены на этот файл напрямую.

  Известное расхождение остаётся тем же, что и раньше (не задето этой
  правкой — не примитив тому причина): `react-day-picker` строит DOM
  настоящей `<table>`, у `reka-ui` `CalendarGrid`/`GridHead`/`GridBody`/
  `GridRow`/`Cell`/`HeadCell` по умолчанию рендерят те же табличные теги
  (`<table>/<thead>/<tbody>/<tr>/<td>/<th>` — тег не переопределён `as`),
  но деление 7 колонок flexbox'ом внутри `<tr>` (не нативным table-layout)
  копит субпиксельную разницу с react-day-picker по неделям — см. §13/§38
  (`block-calendar`: 0.12–0.21% на большинство тем, честно
  задокументированное ограничение библиотек, не примитива).

  Не реализовано (документируем честно, не подгоняем):
  - `captionLayout="dropdown"` (выпадающие списки месяца/года) —
    `reka-ui` не даёт готового аналога `formatMonthDropdown`/`dropdown`;
    реализован только `captionLayout="label"` (обычный текст).
  - `showOutsideDays={false}` — `reka-ui`-грид всегда возвращает недели
    целиком (включая дни соседних месяцев для выравнивания сетки);
    убрать их из потока, как `react-day-picker`, нельзя без второй сетки
    классов, поэтому дни соседних месяцев показываются всегда (как при
    `showOutsideDays={true}` — значении по умолчанию и в оригинале). Проп
    принимается ради совместимости сигнатуры, но не влияет на разметку —
    то же самое ограничение, что было в примитиве и раньше.
  - `numberOfMonths > 1` — заголовок месяца в оригинале вычисляется на
    каждый рендерящийся месяц отдельно; здесь то же самое сделано вручную
    через `Intl.DateTimeFormat` (`CalendarHeading` у `reka-ui` даёт один
    общий `headingValue` на весь календарь, что не подходит для
    мультимесячной раскладки), поэтому `month_caption`/`caption_label` —
    обычный `<div>`, а не `<CalendarHeading>`.
-->
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { getWeek } from "date-fns"
import { CalendarDate, getLocalTimeZone, today, type DateValue } from "@internationalized/date"
import {
  CalendarCell,
  CalendarCellTrigger,
  CalendarGrid,
  CalendarGridBody,
  CalendarGridHead,
  CalendarGridRow,
  CalendarHeadCell,
  CalendarHeader,
  CalendarNext,
  CalendarPrev,
  CalendarRoot,
  RangeCalendarCell,
  RangeCalendarCellTrigger,
  RangeCalendarGrid,
  RangeCalendarGridBody,
  RangeCalendarGridHead,
  RangeCalendarGridRow,
  RangeCalendarHeadCell,
  RangeCalendarHeader,
  RangeCalendarNext,
  RangeCalendarPrev,
  RangeCalendarRoot,
  type DateRange,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants, type ButtonVariants } from "@/components/ui/button"

export interface CalendarDateRange {
  from?: Date
  to?: Date
}

const props = withDefaults(
  defineProps<{
    mode?: "single" | "multiple" | "range"
    modelValue?: Date | Date[] | CalendarDateRange | null
    /** Начальный/якорный месяц (back-compat имя, см. `month` ниже — оба означают одно и то же, `month` ближе к имени пропа апстрима `DayPicker`). */
    placeholder?: Date
    /** То же самое, что `placeholder`, именем как у апстримного `DayPicker`'s `month`/`onMonthChange`. */
    month?: Date
    /** Доп. классы. Имя `class` — соглашение shadcn-vue (не `className`). */
    class?: HTMLAttributes["class"]
    showOutsideDays?: boolean
    captionLayout?: "label"
    buttonVariant?: ButtonVariants["variant"]
    locale?: string
    weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
    numberOfMonths?: number
    fixedWeeks?: boolean
    disabled?: boolean
    disabledFn?: (date: Date) => boolean
    hideNavigation?: boolean
    /** Только `mode="single"`/`"multiple"`. Апстримный `showWeekNumber` (react-day-picker). */
    showWeekNumber?: boolean
    dayButtonClass?: HTMLAttributes["class"]
    dayClass?: HTMLAttributes["class"]
    captionClass?: HTMLAttributes["class"]
    navClass?: HTMLAttributes["class"]
  }>(),
  {
    mode: "single",
    showOutsideDays: true,
    captionLayout: "label",
    buttonVariant: "ghost",
    numberOfMonths: 1,
  }
)

const emit = defineEmits<{
  "update:modelValue": [date: Date | Date[] | CalendarDateRange | undefined]
  "update:month": [date: Date]
}>()

defineSlots<{
  caption?(props: { month: DateValue; caption: string }): unknown
  dayContent?(props: { date: Date } & Record<string, unknown>): unknown
}>()

function toCalendarDate(date: Date): CalendarDate {
  return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}

function toJsDate(date: DateValue): Date {
  return new Date(date.year, date.month - 1, date.day)
}

const singleValue = computed<DateValue | DateValue[] | undefined>(() => {
  if (props.mode === "multiple") {
    return Array.isArray(props.modelValue) ? props.modelValue.map(toCalendarDate) : []
  }
  return props.modelValue instanceof Date ? toCalendarDate(props.modelValue) : undefined
})

const rangeValue = computed<DateRange | undefined>(() => {
  const value = props.modelValue as CalendarDateRange | null | undefined
  if (!value || props.mode !== "range") return undefined
  return {
    start: value.from ? toCalendarDate(value.from) : undefined,
    end: value.to ? toCalendarDate(value.to) : undefined,
  }
})

// `data-selection-start`/`data-selection-end`/`data-highlighted` у
// `RangeCalendarCellTrigger` относятся только к активному drag-предпросмотру
// и отсутствуют у уже установленного (не интерактивно выбираемого)
// диапазона — картина, которую рисуют все `mode="range"` потребители этого
// примитива (значение приходит готовым пропом, а не выбирается кликом).
// Поэтому "начало"/"конец"/"середина" диапазона для CSS определяются
// сравнением самой даты дня со значением `modelValue`, а не по этим
// data-атрибутам (см. `<style scoped>` ниже).
function isSameDay(a: Date, b: Date): boolean {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function rangeEdgeClass(day: DateValue): string | undefined {
  if (props.mode !== "range") return undefined
  const value = props.modelValue as CalendarDateRange | null | undefined
  if (!value?.from || !value.to) return undefined
  const d = toJsDate(day)
  const isStart = isSameDay(d, value.from)
  const isEnd = isSameDay(d, value.to)
  if (isStart && isEnd) return undefined
  if (isStart) return "cn-range-start"
  if (isEnd) return "cn-range-end"
  if (d > value.from && d < value.to) return "cn-range-middle"
  return undefined
}

const anchorDate = computed<Date | undefined>(() => {
  if (props.month) return props.month
  if (props.placeholder) return props.placeholder
  if (props.mode === "range") {
    const value = props.modelValue as CalendarDateRange | null | undefined
    return value?.from
  }
  if (props.mode === "multiple") {
    return Array.isArray(props.modelValue) ? props.modelValue[0] : undefined
  }
  return props.modelValue instanceof Date ? props.modelValue : undefined
})

const placeholderValue = computed<DateValue>(() => (anchorDate.value ? toCalendarDate(anchorDate.value) : today(getLocalTimeZone())))

function onUpdateSingle(date: DateValue | DateValue[] | undefined) {
  if (Array.isArray(date)) {
    emit("update:modelValue", date.map(toJsDate))
  } else {
    emit("update:modelValue", date ? toJsDate(date) : undefined)
  }
}

function onUpdateRange(range: DateRange) {
  emit("update:modelValue", {
    from: range.start ? toJsDate(range.start) : undefined,
    to: range.end ? toJsDate(range.end) : undefined,
  })
}

function onUpdatePlaceholder(date: DateValue) {
  emit("update:month", toJsDate(date))
}

function isDateDisabled(date: DateValue): boolean {
  return props.disabledFn ? props.disabledFn(toJsDate(date)) : false
}

// `react-day-picker` по умолчанию форматирует подписи на английском
// (собственная константа `enUS`), независимо от локали браузера/ОС.
// `Intl.DateTimeFormat(undefined, ...)` вместо этого берёт locale
// окружения, которая на стенде visual-diff принудительно `ru-RU`
// (см. tools/visual-diff/diff.mjs) — без явного фолбэка подписи сместились
// бы в кириллицу, ломая совпадение по ширине. Проп `locale` продолжает
// работать как явное переопределение.
const effectiveLocale = computed(() => props.locale || "en-US")

function monthCaption(value: DateValue): string {
  return new Intl.DateTimeFormat(effectiveLocale.value, { month: "long", year: "numeric" }).format(toJsDate(value))
}

// `react-day-picker`'s default weekday header format is date-fns's `cccccc`
// token (2-letter standalone weekday, "Su"/"Mo"/…) — neither of reka-ui's
// `weekdayFormat` options ("narrow" = "S"/"M", "short" = "Sun"/"Mon")
// matches that width, so the header labels are computed locally instead of
// using `CalendarRoot`'s own `weekDays` slot prop.
const weekdayLabels = computed(() => {
  const start = props.weekStartsOn ?? 0
  // 2024-01-07 — известное воскресенье (UTC), безопасная опорная точка.
  const sunday = new Date(Date.UTC(2024, 0, 7))
  const fmt = new Intl.DateTimeFormat(effectiveLocale.value, { weekday: "short", timeZone: "UTC" })
  return Array.from({ length: 7 }, (_, i) => {
    const d = new Date(sunday)
    d.setUTCDate(d.getUTCDate() + ((start + i) % 7))
    return fmt.format(d).slice(0, 2)
  })
})

// `react-day-picker`'s default `week.weekNumber` is NOT ISO-8601: its
// `noonDateLib.getWeek` calls date-fns `getWeek(date, { weekStartsOn,
// firstWeekContainsDate: locale?.options?.firstWeekContainsDate ?? 1 })`
// (see react-day-picker/dist/esm/noonDateLib.js) — `ISOWeek`/custom locale
// props aren't passed anywhere in this codebase, so the effective options
// are always `firstWeekContainsDate: 1` (react-day-picker's default locale
// is `enUS`) and `weekStartsOn` from this component's own prop
// (react-day-picker falls back to the same `0`).
function weekNumber(value: DateValue): number {
  const date = toJsDate(value)
  return getWeek(date, { weekStartsOn: props.weekStartsOn ?? 0, firstWeekContainsDate: 1 })
}

// `react-day-picker`'s `CalendarDayButton` wraps a real `<Button variant="ghost"
// size="icon">`, so the rendered class list includes `buttonVariants`' own
// classes (`cn-button …`, `items-center justify-center`, `rounded-lg` via
// the `.cn-button` theme rule, etc.) ahead of the calendar-specific override
// string. `CalendarCellTrigger`/`RangeCalendarCellTrigger` render a plain
// `<div role="button">`, not our `<Button>`, so those base classes are added
// explicitly here via the same `buttonVariants()` used for the nav buttons
// above — otherwise the selected/range background renders unrounded and
// off-center (see docs/PORTING.md §5, `ui-calendar` finding #3).
const dayButtonClasses = computed(() =>
  cn(
    buttonVariants({ variant: "ghost", size: "icon" }),
    "cn-calendar-day-button relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col 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:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
    props.dayButtonClass
  )
)

/**
 * `react-day-picker`'s `classNames` REPLACES the base class string for a
 * given key (`{...defaults, ...classNames}`), it does not merge with it —
 * see docs/PORTING.md §38 (`block-calendar`). `nav`/`month_caption`
 * overrides below follow the same replace semantics, not `cn()` merge.
 */
// `HTMLAttributes["class"]` не резолвится макросом `defineProps` до конкретного
// типа, поэтому непереданный проп приходит как `false` (Vue трактует
// неразрешённый тип как boolean), а не `undefined` — `||`, а не `??`.
const navClasses = computed(() => props.navClass || "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1")
const captionWrapperClasses = computed(() => props.captionClass || "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)")

const dayCellClasses = computed(() => cn("group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none", props.dayClass))
</script>

<template>
  <CalendarRoot
    v-if="mode !== 'range'"
    data-slot="calendar"
    :model-value="singleValue"
    :multiple="mode === 'multiple'"
    :placeholder="placeholderValue"
    :locale="effectiveLocale"
    :week-starts-on="props.weekStartsOn"
    :number-of-months="props.numberOfMonths"
    :fixed-weeks="props.fixedWeeks"
    :disabled="props.disabled"
    :is-date-disabled="isDateDisabled"
    :class="
      cn(
        'cn-calendar group/calendar w-fit bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
        props.class
      )
    "
    @update:model-value="onUpdateSingle"
    @update:placeholder="onUpdatePlaceholder"
  >
    <template #default="{ grid }">
      <div class="relative flex flex-col gap-4 md:flex-row">
        <div v-for="month in grid" :key="`${month.value.year}-${month.value.month}`" class="flex w-full flex-col gap-4">
          <CalendarHeader v-if="!hideNavigation" :class="navClasses">
            <CalendarPrev
              :class="cn(buttonVariants({ variant: props.buttonVariant }), 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50')"
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="cn-rtl-flip size-4"
              >
                <path d="m15 18-6-6 6-6" />
              </svg>
            </CalendarPrev>
            <CalendarNext
              :class="cn(buttonVariants({ variant: props.buttonVariant }), 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50')"
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="cn-rtl-flip size-4"
              >
                <path d="m9 18 6-6-6-6" />
              </svg>
            </CalendarNext>
          </CalendarHeader>
          <slot name="caption" :month="month.value" :caption="monthCaption(month.value)">
            <div :class="captionWrapperClasses">
              <div class="cn-calendar-caption font-medium text-sm select-none">
                {{ monthCaption(month.value) }}
              </div>
            </div>
          </slot>
          <CalendarGrid class="w-full border-collapse">
            <CalendarGridHead aria-hidden="true">
              <CalendarGridRow class="flex">
                <CalendarHeadCell
                  v-if="showWeekNumber"
                  class="w-(--cell-size) rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none"
                />
                <CalendarHeadCell
                  v-for="(label, i) in weekdayLabels"
                  :key="i"
                  class="flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none"
                >
                  {{ label }}
                </CalendarHeadCell>
              </CalendarGridRow>
            </CalendarGridHead>
            <CalendarGridBody>
              <CalendarGridRow v-for="(week, wi) in month.rows" :key="wi" class="mt-2 flex w-full">
                <CalendarHeadCell v-if="showWeekNumber" class="w-(--cell-size)">
                  <span class="flex size-8 items-center justify-center text-sm font-normal text-muted-foreground">
                    {{ weekNumber(week[0]!) }}
                  </span>
                </CalendarHeadCell>
                <CalendarCell v-for="day in week" :key="day.toString()" :date="day" :class="dayCellClasses">
                  <CalendarCellTrigger :day="day" :month="month.value" :class="dayButtonClasses">
                    <template v-if="$slots.dayContent" #default="slotProps">
                      <slot name="dayContent" v-bind="slotProps" :date="toJsDate(day)" />
                    </template>
                  </CalendarCellTrigger>
                </CalendarCell>
              </CalendarGridRow>
            </CalendarGridBody>
          </CalendarGrid>
        </div>
      </div>
    </template>
  </CalendarRoot>

  <RangeCalendarRoot
    v-else
    data-slot="calendar"
    :model-value="rangeValue"
    :placeholder="placeholderValue"
    :locale="effectiveLocale"
    :week-starts-on="props.weekStartsOn"
    :number-of-months="props.numberOfMonths"
    :fixed-weeks="props.fixedWeeks"
    :disabled="props.disabled"
    :is-date-disabled="isDateDisabled"
    :class="
      cn(
        'cn-calendar group/calendar w-fit bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
        props.class
      )
    "
    @update:model-value="onUpdateRange"
    @update:placeholder="onUpdatePlaceholder"
  >
    <template #default="{ grid }">
      <div class="relative flex flex-col gap-4 md:flex-row">
        <div v-for="month in grid" :key="`${month.value.year}-${month.value.month}`" class="flex w-full flex-col gap-4">
          <RangeCalendarHeader v-if="!hideNavigation" :class="navClasses">
            <RangeCalendarPrev
              :class="cn(buttonVariants({ variant: props.buttonVariant }), 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50')"
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="cn-rtl-flip size-4"
              >
                <path d="m15 18-6-6 6-6" />
              </svg>
            </RangeCalendarPrev>
            <RangeCalendarNext
              :class="cn(buttonVariants({ variant: props.buttonVariant }), 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50')"
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="cn-rtl-flip size-4"
              >
                <path d="m9 18 6-6-6-6" />
              </svg>
            </RangeCalendarNext>
          </RangeCalendarHeader>
          <slot name="caption" :month="month.value" :caption="monthCaption(month.value)">
            <div :class="captionWrapperClasses">
              <div class="cn-calendar-caption font-medium text-sm select-none">
                {{ monthCaption(month.value) }}
              </div>
            </div>
          </slot>
          <RangeCalendarGrid class="w-full border-collapse">
            <RangeCalendarGridHead aria-hidden="true">
              <RangeCalendarGridRow class="flex">
                <RangeCalendarHeadCell
                  v-for="(label, i) in weekdayLabels"
                  :key="i"
                  class="flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none"
                >
                  {{ label }}
                </RangeCalendarHeadCell>
              </RangeCalendarGridRow>
            </RangeCalendarGridHead>
            <RangeCalendarGridBody>
              <RangeCalendarGridRow v-for="(week, wi) in month.rows" :key="wi" class="mt-2 flex w-full">
                <RangeCalendarCell v-for="day in week" :key="day.toString()" :date="day" :class="dayCellClasses">
                  <RangeCalendarCellTrigger :day="day" :month="month.value" :class="[dayButtonClasses, rangeEdgeClass(day)]" />
                </RangeCalendarCell>
              </RangeCalendarGridRow>
            </RangeCalendarGridBody>
          </RangeCalendarGrid>
        </div>
      </div>
    </template>
  </RangeCalendarRoot>
</template>

<style scoped>
/* Tailwind v4 JIT почему-то не генерирует правило для
   `data-[selected=true]:bg-primary`/`data-[selected=true]:text-primary-
   foreground` из класса `dayButtonClasses` выше (проверено: класс
   присутствует в DOM, но соответствующего `.data-\[selected=true\]\:bg-
   primary[data-selected="true"]` нет в скомпилированном CSS вообще — при
   этом идентичный по синтаксису вариант `data-[selected=true]:rounded-none`
   из `react.tsx`-эталонов компилируется нормально). Причина не установлена
   (не воспроизводится ни правкой tailwind-merge, ни сменой атрибута),
   поэтому здесь — прямое CSS-правило с теми же токенами темы
   (`var(--primary)`/`var(--primary-foreground)`), что и у утилит
   `bg-primary`/`text-primary-foreground`, как временный, но надёжный обход.

   `mode="single"`/`"multiple"` (`CalendarCellTrigger`): просто
   `data-selected`. `mode="range"` окрашивается через классы
   `cn-range-start`/`cn-range-middle`/`cn-range-end`, которые вычисляет
   `rangeEdgeClass()` сравнением дня со значением `modelValue` (см. её
   комментарий) — воспроизводя различие `range_start`/`range_end`
   (bg-primary) vs `range_middle` (bg-muted) у оригинального
   `calendar.tsx`, где источник истины тот же (сам диапазон, а не
   промежуточное состояние drag-выбора). */
[data-selected="true"].cn-calendar-day-button {
  background-color: var(--primary);
  color: var(--primary-foreground);
}

/* Тот же JIT-обход, что и выше: `data-[outside-view]:text-muted-foreground`
   (для дней соседних месяцев, аналог `outside` classNames-ключа у
   react-day-picker) в скомпилированный CSS не попадает, хотя атрибут и
   класс присутствуют в DOM. */
[data-outside-view].cn-calendar-day-button {
  color: var(--muted-foreground);
}
.cn-range-middle.cn-calendar-day-button {
  background-color: var(--muted);
  color: var(--foreground);
  border-radius: 0;
}
.cn-range-start.cn-calendar-day-button {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}
.cn-range-end.cn-calendar-day-button {
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
}
</style>

src/ui/calendar/index.ts

export { default as Calendar } from "./Calendar.vue"
export type { CalendarDateRange } from "./Calendar.vue"

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/calendar.json

Зависимости реестра

npm-зависимости

  • @internationalized/date
  • date-fns
  • reka-ui

Источник: порт из ReUI (Keenthemes, MIT)