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"