Event Calendar
Event Calendar — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/event-calendar/EventCalendar.vue
<script setup lang="ts" generic="TData = unknown">
/**
* Порт корневого ReUI `<EventCalendar>` (event-calendar.tsx, MIT) —
* провайдер движка + контейнер + announcer.
*
* ponytail: принимает `EventCalendarOptions` (состояние/колбэки) плюс один
* плоский `viewConfig`-проп (частичный `EventCalendarViewConfig`, см.
* `context.ts`) — а не полный список отдельных плоских пропов
* (`maxEventsPerCell`, `renderEvent` и т.п. по одному), как в оригинале
* (`splitOptions`/`OPTION_KEYS`/`VIEW_CONFIG_KEYS`). Добавлено, когда
* `packages/blocks/src/event-calendar/` понадобился root-level оверрайд
* `renderEvent`/`renderEventTooltip`/`eventTooltip`/`maxEventsPerCell`
* (composite-блоки `c-event-calendar-1/4/5`, которые в оригинале передают
* эти пропы прямо на `<EventCalendar>`, а не на отдельный view-компонент,
* как единственный до того кейс `ui-event-calendar`). Не реализованы
* `calendar`/`apiRef`-пропы (adopt вынесенного инстанса) и `asChild` — оба
* используются только вокруг составных сценариев (внешнее состояние,
* полиморфный корень), которых пока нет ни у одного кейса/блока.
*
* Боевая реализация `EventCalendarGestures` (`dnd.ts`) провайдится здесь —
* единственное место, которое знает и про движок, и про pointer-engine;
* потребители (`EventCalendarEvent.vue` и виды) не меняются вообще, они уже
* читали заглушку через тот же `EventCalendarGesturesContextKey`.
*/
import { provide, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import {
createEventCalendar,
DEFAULT_EVENT_CALENDAR_VIEW_CONFIG,
EventCalendarContextKey,
EventCalendarGesturesContextKey,
EventCalendarViewConfigContextKey,
type EventCalendarOptions,
type EventCalendarViewConfig,
} from "./context"
import { createEventCalendarGestures } from "./dnd"
// withDefaults(..., { fixedWeeks: undefined, ... }) is load-bearing, not
// decorative: Vue casts an ABSENT boolean-typed prop to `false` (not
// `undefined`) unless it has an explicit `default` key — silently turning
// `fixedWeeks ?? true` in resolveSettings() into `false ?? true` => `false`
// (5 grid rows instead of 6). An explicit `default: undefined` makes
// `hasDefault` true and suppresses that cast. Same trap for every other
// boolean-typed field of EventCalendarOptions (loading, showOutsideDays).
const props = withDefaults(
defineProps<
EventCalendarOptions<TData> & {
class?: HTMLAttributes["class"]
viewConfig?: Partial<EventCalendarViewConfig<TData>>
}
>(),
{ fixedWeeks: undefined, showOutsideDays: undefined, loading: undefined }
)
const instance = createEventCalendar<TData>(props)
provide(EventCalendarContextKey, instance)
provide(EventCalendarGesturesContextKey, createEventCalendarGestures<TData>(instance))
// Plain merged object, not a `computed()`: consumers (`useEventCalendarViewConfig()`
// callers) read fields straight off the injected value (`viewConfig.classNames`,
// `viewConfig.maxEventsPerCell`, ...) with no `.value` — same v1 uncontrolled
// contract as the rest of `EventCalendarOptions` (read once at creation, see
// file header). Providing a `ComputedRef` here instead would silently break
// every existing reader the same way §33 (gantt) documents.
provide(
EventCalendarViewConfigContextKey,
props.viewConfig
? { ...DEFAULT_EVENT_CALENDAR_VIEW_CONFIG, ...props.viewConfig }
: DEFAULT_EVENT_CALENDAR_VIEW_CONFIG
)
defineExpose({ api: instance.api, state: instance.state })
</script>
<template>
<div data-slot="event-calendar" :class="cn('flex min-h-0 min-w-0 flex-col text-xs', props.class)">
<slot />
<div data-slot="event-calendar-announcer" aria-live="polite" class="sr-only" />
</div>
</template>
src/reui/event-calendar/EventCalendarAgendaView.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarAgendaView (event-calendar-agenda-view.tsx, MIT) —
* хронологический список по дням: заголовок дня + плоский список событий.
*
* ponytail: `scrollbars: "native"` ветка не перенесена — всегда `ScrollArea`
* (дефолт оригинала), как и в EventCalendarTimeGrid.vue; `asChild` тоже
* пропущен. `renderNoEvents`/`renderAgendaEventDetails` (раскрывающиеся
* детали строки) не перенесены — ни один текущий кейс не показывает пустой
* агенду и не использует details-колбэк.
* `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (путь
* lucide "calendar" v0.545.0) внутри уже портированного `IconStack`.
*/
import { computed, provide } from "vue"
import { addDays, format } from "date-fns"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import { IconStack } from "@/components/reui/icon-stack"
import {
EventCalendarViewContextKey,
useEventCalendar,
useEventCalendarSettings,
useEventCalendarViewConfig,
} from "./context"
import { getDayKey, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarDayBucket } from "./lib"
import type { EventCalendarSegment } from "./types"
provide(EventCalendarViewContextKey, { view: "agenda" })
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const visibleRange = computed(() => instance.state.value.visibleRange)
const days = computed(() => {
const result: Date[] = []
let cursor = zonedStartOfDay(visibleRange.value.start, settings.timeZone)
while (cursor < visibleRange.value.end) {
result.push(cursor)
cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone)
}
return result
})
interface DayGroup {
day: Date
bucket: EventCalendarDayBucket | undefined
items: EventCalendarSegment[]
}
const groups = computed<DayGroup[]>(() => {
const index = instance.api.getIndex()
return days.value
.map((day) => ({ day, bucket: index.byDay.get(getDayKey(day, settings.timeZone)) }))
.filter((g) => (g.bucket?.allDay.length ?? 0) + (g.bucket?.timed.length ?? 0) > 0)
.map((g) => ({ ...g, items: [...(g.bucket?.allDay ?? []), ...(g.bucket?.timed ?? [])] }))
})
function isToday(day: Date): boolean {
return getDayKey(day, settings.timeZone) === getDayKey(new Date(), settings.timeZone)
}
</script>
<template>
<div
data-slot="event-calendar-agenda-view"
data-view="agenda"
role="group"
:aria-label="settings.i18n.functions.formatDayRange(visibleRange, { locale: settings.locale })"
:class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.agendaView)"
>
<ScrollArea class="h-full">
<div v-if="groups.length === 0" data-slot="event-calendar-no-events" :class="cn('flex min-h-72 flex-col items-center justify-center gap-4 py-16', viewConfig.classNames?.noEvents)">
<IconStack>
<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="size-5" aria-hidden="true"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /></svg>
</IconStack>
<span class="text-muted-foreground text-sm">{{ settings.i18n.labels.noEvents }}</span>
</div>
<div v-else class="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
<div
v-for="group in groups"
:key="group.day.getTime()"
data-slot="event-calendar-agenda-day"
:data-today="isToday(group.day) || undefined"
role="group"
:aria-label="`${format(toZoned(group.day, settings.timeZone), 'EEEE', { locale: settings.locale })}, ${format(toZoned(group.day, settings.timeZone), 'MMMM d, yyyy', { locale: settings.locale })}, ${settings.i18n.labels.events(group.items.length)}`"
>
<div
data-slot="event-calendar-agenda-day-header"
role="heading"
aria-level="3"
:class="cn('bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2 me-2.5', viewConfig.classNames?.agendaDayHeader)"
>
<span :class="cn('text-foreground font-semibold', isToday(group.day) && 'text-primary')">
{{ format(toZoned(group.day, settings.timeZone), "EEEE", { locale: settings.locale }) }}
</span>
<span class="text-muted-foreground font-medium tabular-nums">
{{ format(toZoned(group.day, settings.timeZone), "MMMM d, yyyy", { locale: settings.locale }) }}
</span>
</div>
<EventCalendarEvent
v-for="segment in group.items"
:key="segment.occurrence.key"
:segment="segment"
:class="cn('hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors', viewConfig.classNames?.agendaItem)"
/>
</div>
</div>
</ScrollArea>
</div>
</template>
src/reui/event-calendar/EventCalendarContent.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarContent (event-calendar-content.tsx, MIT) —
* коммутатор активного представления.
*
* ponytail: `DEFAULT_VIEW_COMPONENTS`/`components`-проп теперь содержат все
* шесть `CalendarView` — весь визуальный слой представлений портирован
* (см. docs/PORTING.md). Остаётся только `event-calendar-dnd.tsx`.
*/
import { computed, type Component, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useEventCalendar, useEventCalendarViewConfig } from "./context"
import EventCalendarMonthView from "./EventCalendarMonthView.vue"
import EventCalendarWeekView from "./EventCalendarWeekView.vue"
import EventCalendarDayView from "./EventCalendarDayView.vue"
import EventCalendarDaysView from "./EventCalendarDaysView.vue"
import EventCalendarAgendaView from "./EventCalendarAgendaView.vue"
import EventCalendarResourceView from "./EventCalendarResourceView.vue"
import type { CalendarView } from "./types"
const props = defineProps<{
class?: HTMLAttributes["class"]
components?: Partial<Record<CalendarView, Component>>
}>()
const instance = useEventCalendar()
const viewConfig = useEventCalendarViewConfig()
const defaultViewComponents: Partial<Record<CalendarView, Component>> = {
month: EventCalendarMonthView,
week: EventCalendarWeekView,
day: EventCalendarDayView,
days: EventCalendarDaysView,
agenda: EventCalendarAgendaView,
resource: EventCalendarResourceView,
}
const view = computed(() => instance.state.value.view)
const loading = computed(() => instance.state.value.loading)
const activeView = computed(
() =>
props.components?.[view.value] ??
viewConfig.components?.[view.value] ??
defaultViewComponents[view.value]
)
</script>
<template>
<div
data-slot="event-calendar-content"
:data-view="view"
:data-loading="loading || undefined"
:class="
cn(
'relative flex min-h-0 min-w-0 flex-1 flex-col',
'data-loading:pointer-events-none data-loading:opacity-60',
viewConfig.classNames?.content,
props.class
)
"
>
<slot>
<component :is="activeView" v-if="activeView" />
</slot>
</div>
</template>
src/reui/event-calendar/EventCalendarDayView.vue
<script setup lang="ts">
/** Тонкая обёртка над EventCalendarTimeGrid с фиксированным `view="day"`, как в оригинале. */
import type { HTMLAttributes } from "vue"
import EventCalendarTimeGrid from "./EventCalendarTimeGrid.vue"
// showAllDay: undefined в withDefaults — иначе Vue приводит отсутствующий
// boolean-проп к `false` вместо `undefined` и глушит дефолт `true` из
// EventCalendarTimeGrid.vue (та же ловушка, что задокументирована в
// docs/PORTING.md §32 / EventCalendar.vue).
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
dayStartHour?: number
dayEndHour?: number
showAllDay?: boolean
interval?: number
}>(),
{ showAllDay: undefined }
)
</script>
<template>
<EventCalendarTimeGrid
view="day"
:class="props.class"
:day-start-hour="props.dayStartHour"
:day-end-hour="props.dayEndHour"
:show-all-day="props.showAllDay"
:interval="props.interval"
/>
</template>
src/reui/event-calendar/EventCalendarDaysView.vue
<script setup lang="ts">
/** Тонкая обёртка над EventCalendarTimeGrid с фиксированным `view="days"`, как в оригинале. */
import type { HTMLAttributes } from "vue"
import EventCalendarTimeGrid from "./EventCalendarTimeGrid.vue"
// showAllDay: undefined в withDefaults — иначе Vue приводит отсутствующий
// boolean-проп к `false` вместо `undefined` и глушит дефолт `true` из
// EventCalendarTimeGrid.vue (та же ловушка, что задокументирована в
// docs/PORTING.md §32 / EventCalendar.vue).
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
dayStartHour?: number
dayEndHour?: number
showAllDay?: boolean
interval?: number
}>(),
{ showAllDay: undefined }
)
</script>
<template>
<EventCalendarTimeGrid
view="days"
:class="props.class"
:day-start-hour="props.dayStartHour"
:day-end-hour="props.dayEndHour"
:show-all-day="props.showAllDay"
:interval="props.interval"
/>
</template>
src/reui/event-calendar/EventCalendarEvent.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarEvent (event-calendar-event.tsx, MIT) — переиспользуемый
* чип/бар/блок события: позиционирование остаётся за вызывающим представлением
* (month-view/time-grid/agenda-view), этот компонент — сам интерактивный
* элемент: выбор, клики, атрибуты доступности, --ec-event-color.
*
* ponytail: перенесены выбор/клики/дефолтный контент/ручки ресайза (сама
* разметка `data-slot="event-calendar-resize-handle"`); ЧТО ПРОПУЩЕНО и
* почему:
* - `TooltipProvider`/`Tooltip` (`viewConfig.eventTooltip`) — дефолт `false`,
* не нужен для первого рендера; добавить как обычный reui/tooltip враппер,
* когда появится кейс с `eventTooltip: true`.
* - `useMemo` для `customContent` — в оригинале это защита от лишних
* ре-рендеров React при перетаскивании; Vue `computed()` даёт то же самое
* бесплатно (см. context.ts header, тот же приём).
* `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (путь
* lucide "repeat" v0.545.0), как и во всех остальных портах.
*/
import { computed, type HTMLAttributes, type StyleValue } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { addDays, format } from "date-fns"
import { cn } from "@/lib/utils"
import {
useEventCalendar,
useEventCalendarGestures,
useEventCalendarSettings,
useEventCalendarViewConfig,
useEventCalendarViewContext,
} from "./context"
import {
spansMultipleDays,
toZoned,
zonedStartOfDay,
} from "./lib"
import type { EventCalendarSegment } from "./types"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
segment: EventCalendarSegment
/** Static drag clone: chip rendered as-is but inert. */
preview?: boolean
asChild?: boolean
style?: StyleValue
}>(),
{ preview: false, asChild: false }
)
defineSlots<{ default?(): unknown }>()
const { forwardRef } = useForwardExpose()
const instance = useEventCalendar()
const viewConfig = useEventCalendarViewConfig()
const settings = useEventCalendarSettings()
const { view } = useEventCalendarViewContext()
const gestures = useEventCalendarGestures()
const occurrence = computed(() => props.segment.occurrence)
const event = computed(() => occurrence.value.event)
const isSelectedRaw = computed(() =>
instance.state.value.selection.eventKeys.includes(occurrence.value.key)
)
const isDraggingRaw = computed(
() => instance.state.value.drag?.occurrence.key === occurrence.value.key
)
const resizeOn = computed(() => instance.state.value.interactions.resize)
// A preview clone must never inherit the source's selected/dragging state.
const isSelected = computed(() => (props.preview ? false : isSelectedRaw.value))
const isDragging = computed(() => (props.preview ? false : isDraggingRaw.value))
const isBar = computed(
() => occurrence.value.allDay || spansMultipleDays(occurrence.value, settings.timeZone)
)
const inTimeGrid = computed(
() => view === "week" || view === "day" || view === "days" || view === "resource"
)
const interactive = computed(() => view !== "agenda" && !props.preview)
const timedBlock = computed(() => inTimeGrid.value && !isBar.value)
const horizontalBar = computed(() => isBar.value && !inTimeGrid.value)
const stackedBlock = computed(
() =>
timedBlock.value &&
(props.segment.endMin ?? 0) - (props.segment.startMin ?? 0) >=
viewConfig.compactEventMinutes
)
const FADE_TRUNCATE =
"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]"
const startTimeLabel = computed(() =>
format(
toZoned(occurrence.value.start, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)
)
const rangeTimeLabel = computed(() =>
settings.i18n.functions.formatEventTime(
toZoned(occurrence.value.start, settings.timeZone),
toZoned(occurrence.value.end, settings.timeZone),
occurrence.value.allDay,
{ locale: settings.locale }
)
)
// Agenda time text is per-day for multi-day events (see original comment).
const agendaTimeText = computed(() => {
if (view !== "agenda") return ""
if (occurrence.value.allDay) return settings.i18n.labels.allDay
const dayStart = zonedStartOfDay(props.segment.day, settings.timeZone)
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
const startsBefore = occurrence.value.start < dayStart
const endsAfter = occurrence.value.end > dayEnd
if (startsBefore && endsAfter) return settings.i18n.labels.allDay
if (endsAfter) return settings.i18n.labels.timeFrom(startTimeLabel.value)
if (startsBefore) {
return settings.i18n.labels.timeUntil(
format(
toZoned(occurrence.value.end, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)
)
}
return rangeTimeLabel.value
})
const timeLabel = rangeTimeLabel
const label = computed(() =>
settings.i18n.functions.formatEventLabel
? settings.i18n.functions.formatEventLabel(event.value.title, timeLabel.value)
: `${event.value.title}, ${timeLabel.value}`
)
const showResize = computed(
() =>
interactive.value &&
resizeOn.value &&
!event.value.readOnly &&
event.value.resizable !== false
)
const chipStyle = computed(() => ({
"--ec-event-color": event.value.color ?? "var(--color-primary)",
...(props.style as Record<string, string> | undefined),
}))
const ariaLabel = computed(
() =>
settings.i18n.functions.formatEventAriaLabel?.(
event.value.title,
timeLabel.value,
props.segment.continuesBefore || props.segment.continuesAfter
) ??
`${event.value.title}, ${timeLabel.value}${
props.segment.continuesBefore || props.segment.continuesAfter
? `, ${settings.i18n.labels.continues}`
: ""
}`
)
function onPointerDown(e: PointerEvent) {
e.stopPropagation()
gestures.markChipPress()
if (interactive.value) gestures.beginMove(e, props.segment)
}
function onClick(e: MouseEvent) {
e.stopPropagation()
if (gestures.wasRecentDrag()) return
settings.onEventClick?.(occurrence.value, e)
if (e.defaultPrevented || view === "agenda") return
instance.api.selectEvent(occurrence.value.key)
}
function onDoubleClick(e: MouseEvent) {
e.stopPropagation()
settings.onEventDoubleClick?.(occurrence.value, e)
}
</script>
<template>
<Primitive
:ref="forwardRef"
as="button"
:as-child="asChild"
type="button"
data-slot="event-calendar-event"
:data-view="view"
:data-all-day="occurrence.allDay || undefined"
:data-recurring="occurrence.isRecurring || undefined"
:data-selected="isSelected || undefined"
:data-dragging="isDragging || undefined"
:data-preview="preview || undefined"
:data-past="occurrence.end.getTime() < Date.now() || undefined"
:title="preview ? undefined : label"
:aria-label="ariaLabel"
:aria-pressed="interactive ? isSelected : undefined"
:aria-hidden="preview || undefined"
:tabindex="preview ? -1 : undefined"
:style="chipStyle"
:class="
cn(
'group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none',
'focus-visible:ring-ring/50 outline-none focus-visible:ring-2',
preview && 'pointer-events-none',
view === 'agenda'
? 'gap-3 rounded-md text-sm'
: cn(
'@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal',
'bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25',
'dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30',
'inset-ring inset-ring-(--ec-event-color)/15',
'transition-[background-color,box-shadow] duration-150',
'data-dragging:opacity-40',
'data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40',
segment.continuesBefore && 'rounded-s-none',
segment.continuesAfter && 'rounded-e-none'
),
viewConfig.classNames?.event,
props.class
)
"
@pointerdown="onPointerDown"
@click="onClick"
@dblclick="onDoubleClick"
>
<slot>
<template v-if="view === 'agenda'">
<span class="text-muted-foreground w-40 shrink-0 truncate tabular-nums">{{ agendaTimeText }}</span>
<span aria-hidden data-slot="event-calendar-agenda-dot" class="size-2 shrink-0 rounded-full bg-(--ec-event-color)" />
<span class="truncate text-sm">{{ event.title }}</span>
<svg v-if="occurrence.isRecurring" 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="text-muted-foreground size-2.5 shrink-0" aria-hidden="true"><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></svg>
</template>
<template v-else>
<span v-if="!timedBlock" aria-hidden data-slot="event-calendar-event-dot" class="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)" />
<svg v-if="occurrence.isRecurring" 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="size-2.5 shrink-0 opacity-70" aria-hidden="true"><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></svg>
<span :class="cn('font-medium', stackedBlock ? FADE_TRUNCATE : 'truncate')">{{ event.title }}</span>
<template v-if="!occurrence.allDay && segment.isStart">
<span v-if="view === 'month'" class="text-muted-foreground shrink-0">{{ startTimeLabel }}</span>
<span v-else :class="cn('text-muted-foreground hidden @[8rem]:inline', stackedBlock ? FADE_TRUNCATE : 'truncate')">{{ rangeTimeLabel }}</span>
</template>
</template>
</slot>
<template v-if="showResize">
<span
v-if="timedBlock && segment.isStart"
data-slot="event-calendar-resize-handle"
data-edge="start"
:class="cn('absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100', viewConfig.classNames?.resizeHandle)"
@pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'start')"
>
<span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
</span>
<span
v-if="timedBlock && segment.isEnd"
data-slot="event-calendar-resize-handle"
data-edge="end"
:class="cn('absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100', viewConfig.classNames?.resizeHandle)"
@pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'end')"
>
<span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
</span>
<span
v-if="(horizontalBar || (isBar && inTimeGrid)) && segment.isStart"
data-slot="event-calendar-resize-handle"
data-edge="start"
:class="cn('absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100', viewConfig.classNames?.resizeHandle)"
@pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'start')"
>
<span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
</span>
<span
v-if="(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd"
data-slot="event-calendar-resize-handle"
data-edge="end"
:class="cn('absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100', viewConfig.classNames?.resizeHandle)"
@pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'end')"
>
<span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
</span>
</template>
</Primitive>
</template>
src/reui/event-calendar/EventCalendarMonthView.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarMonthView (event-calendar-month-view.tsx, MIT).
* Первое представление — минимальное ядро для запуска визуального гейта.
*
* ponytail — сознательно НЕ перенесено в этом проходе (добавить по мере
* появления кейсов, которые это используют):
* - drag/resize ghosts, inline drop placeholder, `data-drop-target` —
* зависят от `event-calendar-dnd.tsx`, которого ещё нет (план: последним).
* - "auto" measuring `maxEventsPerCell` через ResizeObserver — используется
* фиксированный fallback (3), как в оригинале при отсутствии измерения.
* - "+N more" — статичный неинтерактивный индикатор (`<span>`), а не Popover
* со списком скрытых событий (оригинал: `Popover`/`PopoverContent`).
* - week-number gutter, off-days marking, `showDayAddButton`, `renderMonthCell`
* и прочие render-пропы, `asChild`, фокус-реставрация после ре-ключевания
* чипа (`restoreChipFocus`, актуально только вместе с dnd).
* - `useEventCalendarViewSettings` (слияние view-level пропов с
* `state.viewSettings`) — weekends/weekNumbers/nowIndicator/offDays
* читаются прямо из `state.viewSettings` (root-level), без per-view
* プроп-оверрайдов (эта view не принимает `weekends`/`weekNumbers` пропы).
*
* Раскладка бар-оверлея (colStart/colSpan/lane) и упаковка НЕ пересчитываются
* здесь — переиспользуются уже упакованные `index.weekRows[].bars` из lib.ts
* (`packWeekRowLanes`), тот же источник, что и `useEventCalendarWeek` в
* оригинале.
*/
import { computed, provide, type HTMLAttributes } from "vue"
import { addDays, format } from "date-fns"
import { cn } from "@/lib/utils"
import {
EventCalendarViewContextKey,
useEventCalendar,
useEventCalendarSettings,
useEventCalendarViewConfig,
} from "./context"
import { getDayKey, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarDayBucket } from "./lib"
import type { EventCalendarSegment } from "./types"
const props = defineProps<{
class?: HTMLAttributes["class"]
maxEventsPerCell?: number | "auto"
}>()
provide(EventCalendarViewContextKey, { view: "month" })
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const visibleRange = computed(() => instance.state.value.visibleRange)
const anchorDate = computed(() => instance.state.value.date)
const weekendsOn = computed(() => instance.state.value.viewSettings.weekends ?? true)
const weeks = computed(() => {
const days: Date[] = []
let cursor = zonedStartOfDay(visibleRange.value.start, settings.timeZone)
while (cursor < visibleRange.value.end) {
days.push(cursor)
cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone)
}
const rows: Date[][] = []
for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7))
if (weekendsOn.value) return rows
return rows.map((row) =>
row.filter((day) => !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay()))
)
})
const headerDays = computed(() => weeks.value[0] ?? [])
const title = computed(() =>
settings.i18n.functions.formatTitle("month", {
date: toZoned(anchorDate.value, settings.timeZone),
activeRange: instance.api.getActiveRange(),
visibleRange: visibleRange.value,
locale: settings.locale,
})
)
const gridTemplateColumns = computed(
() => `repeat(${headerDays.value.length}, minmax(0, 1fr))`
)
const cap = computed(() => {
const configured = props.maxEventsPerCell ?? viewConfig.maxEventsPerCell
return configured === "auto" ? 3 : configured
})
function weekRowFor(weekStart: Date) {
const startMs = zonedStartOfDay(weekStart, settings.timeZone).getTime()
return instance.api
.getIndex()
.weekRows.find(
(r) => zonedStartOfDay(r.rowStart, settings.timeZone).getTime() === startMs
)
}
function dayBucket(day: Date): EventCalendarDayBucket {
return (
instance.api.getIndex().byDay.get(getDayKey(day, settings.timeZone)) ?? {
allDay: [],
timed: [],
}
)
}
function isToday(day: Date): boolean {
return getDayKey(new Date(), settings.timeZone) === getDayKey(day, settings.timeZone)
}
function isOutside(day: Date): boolean {
const dayStart = zonedStartOfDay(day, settings.timeZone)
const active = instance.state.value.activeRange
return dayStart < active.start || dayStart >= active.end
}
interface WeekLayout {
week: Date[]
offsets: number[]
visibleBars: EventCalendarSegment[]
gridPos: (colStart: number, colSpan: number) => { col: number; span: number } | null
reservedLanes: (col: number) => number
hiddenBarKeys: (col: number) => Set<string>
}
function layoutWeek(week: Date[]): WeekLayout {
const rowStart = weekRowFor(week[0]!)?.rowStart ?? week[0]!
const bars = weekRowFor(week[0]!)?.bars ?? []
const rowStartMs = zonedStartOfDay(rowStart, settings.timeZone).getTime()
const dayMs = 86400000
const offsets = week.map((d) =>
Math.round((zonedStartOfDay(d, settings.timeZone).getTime() - rowStartMs) / dayMs)
)
const gridPos = (colStart: number, colSpan: number) => {
let start = -1
let end = -1
for (let o = colStart; o < colStart + colSpan; o++) {
const col = offsets.indexOf(o)
if (col === -1) continue
if (start === -1) start = col
end = col
}
return start === -1 ? null : { col: start, span: end - start + 1 }
}
const visibleBars = bars.filter((b) => (b.lane ?? 0) < cap.value)
const covers = (b: EventCalendarSegment, dayOffset: number) =>
(b.colStart ?? 0) <= dayOffset && dayOffset < (b.colStart ?? 0) + (b.colSpan ?? 1)
return {
week,
offsets,
visibleBars,
gridPos,
reservedLanes: (col) =>
visibleBars.reduce(
(max, b) => (covers(b, offsets[col]!) ? Math.max(max, (b.lane ?? 0) + 1) : max),
0
),
hiddenBarKeys: (col) =>
new Set(
bars
.filter((b) => (b.lane ?? 0) >= cap.value && covers(b, offsets[col]!))
.map((b) => b.occurrence.key)
),
}
}
interface CellView {
bucket: EventCalendarDayBucket
visibleTimed: EventCalendarSegment[]
overflowCount: number
}
function cellView(day: Date, layout: WeekLayout, col: number): CellView {
const bucket = dayBucket(day)
const hiddenBarKeys = layout.hiddenBarKeys(col)
const reservedLanes = layout.reservedLanes(col)
const timedSlots = Math.max(0, cap.value - reservedLanes)
const visibleTimed = bucket.timed.slice(0, timedSlots)
const overflowCount = hiddenBarKeys.size + Math.max(0, bucket.timed.length - timedSlots)
return { bucket, visibleTimed, overflowCount }
}
</script>
<template>
<div
data-slot="event-calendar-month-view"
data-view="month"
role="grid"
:aria-label="title"
:class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.monthView, props.class)"
>
<div
role="row"
data-slot="event-calendar-month-header"
:class="cn('@container grid border-b', viewConfig.classNames?.monthHeader)"
:style="{ gridTemplateColumns }"
>
<div
v-for="day in headerDays"
:key="day.getTime()"
role="columnheader"
:class="cn('text-muted-foreground truncate px-2 py-1.5 font-medium', viewConfig.classNames?.monthDayHeader)"
>
<span class="@max-[36rem]:hidden">{{ format(toZoned(day, settings.timeZone), settings.i18n.formats.monthDayHeader, { locale: settings.locale }) }}</span>
<span class="hidden @max-[36rem]:inline">{{ format(toZoned(day, settings.timeZone), settings.i18n.formats.monthDayHeaderNarrow, { locale: settings.locale }) }}</span>
</div>
</div>
<div
data-slot="event-calendar-month-body"
:class="cn('grid min-h-0 flex-1', viewConfig.classNames?.monthBody)"
:style="{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }"
>
<div
v-for="(week, rowIndex) in weeks"
:key="rowIndex"
role="row"
data-slot="event-calendar-month-row"
:class="cn('relative grid min-h-0 border-b last:border-b-0', viewConfig.classNames?.monthRow)"
:style="{ gridTemplateColumns }"
>
<template v-for="(day, col) in week" :key="day.getTime()">
<div
role="gridcell"
data-slot="event-calendar-month-cell"
:data-today="isToday(day) || undefined"
:data-outside="isOutside(day) || undefined"
:data-weekend="settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay()) || undefined"
:data-ec-day="zonedStartOfDay(day, settings.timeZone).getTime()"
:aria-label="format(toZoned(day, settings.timeZone), settings.i18n.formats.monthCellAriaLabel, { locale: settings.locale })"
:class="
cn(
'group/ec-cell relative flex min-h-0 min-w-0 flex-col overflow-hidden',
col !== week.length - 1 && 'border-e',
isOutside(day) && !settings.showOutsideDays && 'invisible',
isToday(day) && cn('bg-primary/3 border-b-primary/40 relative border-b-2', viewConfig.todayClassName),
viewConfig.classNames?.monthCell
)
"
@click="settings.onSlotClick?.({ date: day, allDay: true, view: 'month' }, $event)"
>
<div :class="cn('flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden px-1 pt-1.5', viewConfig.classNames?.monthCellContent)">
<div
v-if="layoutWeek(week).reservedLanes(col) > 0"
aria-hidden
class="shrink-0"
:style="{ height: `calc(${layoutWeek(week).reservedLanes(col)} * var(--ec-month-bar-h, 1.75rem) - 0.125rem)` }"
/>
<EventCalendarEvent
v-for="segment in cellView(day, layoutWeek(week), col).visibleTimed"
:key="segment.occurrence.key"
:segment="segment"
class="shrink-0"
/>
<span
v-if="cellView(day, layoutWeek(week), col).overflowCount > 0"
data-slot="event-calendar-more"
:class="cn('text-muted-foreground shrink-0 truncate px-1 text-xs font-medium', viewConfig.classNames?.moreIndicator)"
>
{{ settings.i18n.labels.more(cellView(day, layoutWeek(week), col).overflowCount) }}
</span>
</div>
<div :class="cn('flex items-center justify-end gap-1 px-2 pb-1.5', viewConfig.classNames?.monthCellFooter)">
<span
data-slot="event-calendar-month-day-number"
:class="
cn(
'flex size-5 items-center justify-center rounded-full',
isOutside(day) && 'text-muted-foreground',
isToday(day) && 'bg-primary text-primary-foreground font-light',
viewConfig.classNames?.monthDayNumber
)
"
>
{{ format(toZoned(day, settings.timeZone), settings.i18n.formats.monthCellDay, { locale: settings.locale }) }}
</span>
</div>
</div>
</template>
<div
v-if="layoutWeek(week).visibleBars.length > 0"
data-slot="event-calendar-month-bar-overlay"
class="pointer-events-none absolute inset-x-0 top-0 z-10 grid pt-1.5"
:style="{ gridTemplateColumns, gridAutoRows: 'var(--ec-month-bar-h, 1.75rem)' }"
>
<template v-for="bar in layoutWeek(week).visibleBars" :key="bar.occurrence.key">
<div
v-if="layoutWeek(week).gridPos(bar.colStart ?? 0, bar.colSpan ?? 1)"
:class="cn('pointer-events-auto min-w-0 px-1', viewConfig.classNames?.monthBar)"
:style="{
gridColumn: `${(layoutWeek(week).gridPos(bar.colStart ?? 0, bar.colSpan ?? 1)!.col) + 1} / span ${layoutWeek(week).gridPos(bar.colStart ?? 0, bar.colSpan ?? 1)!.span}`,
gridRow: (bar.lane ?? 0) + 1,
}"
>
<EventCalendarEvent :segment="bar" class="h-[calc(var(--ec-month-bar-h,1.75rem)-0.125rem)]" />
</div>
</template>
</div>
</div>
</div>
</div>
</template>
src/reui/event-calendar/EventCalendarNav.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarNav (event-calendar-nav.tsx, MIT) — Today/prev/next/
* заголовок периода. Композиция составлена по образцу `EventCalendarNav`
* оригинала (Today, prev/next, заголовок, growing spacer).
*
* ponytail — НЕ портировано в этом проходе (ни одного текущего кейса не
* блокирует, ни один не переключает вид):
* - `EventCalendarViewSwitcher` (DropdownMenu со списком видов) и
* `EventCalendarDatePicker` (Popover+Calendar) — обе плавающие поверхности,
* требуют reui/dropdown-menu и ui/calendar интеграции сверху уже большого
* файла; добавить, когда появится кейс с несколькими видами/датапикером.
* - `EventCalendarToolbar` — тонкая обёртка-層 (`flex items-center gap-2`),
* добавить вместе с view switcher, когда понадобится слот тулбара.
* - Tooltip на каждой кнопке (`NavTooltip`/`viewConfig.navTooltips`) — не
* влияет на закрытый статичный рендер (TooltipTrigger asChild ничего не
* добавляет в DOM), пропущен ради объёма; добавить как обычный
* reui/tooltip враппер.
* - `asChild` на кнопках/заголовке/toolbar.
* `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (пути
* lucide "chevron-left"/"chevron-right" v0.545.0), тем же приёмом, что и
* везде в порте.
*/
import { computed, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useEventCalendar, useEventCalendarSettings, useEventCalendarViewConfig } from "./context"
import { toZoned } from "./lib"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const navVariant = computed(() => viewConfig.navButtonVariant)
const navSize = computed(() => viewConfig.navButtonSize)
const navIconSize = computed(() => (viewConfig.navButtonSize === "sm" ? "icon-sm" : "icon"))
const date = computed(() => instance.state.value.date)
const title = computed(() =>
settings.i18n.functions.formatTitle(instance.state.value.view, {
date: toZoned(date.value, settings.timeZone),
activeRange: instance.state.value.activeRange,
visibleRange: instance.state.value.visibleRange,
locale: settings.locale,
})
)
const isToday = computed(() => {
const now = toZoned(new Date(), settings.timeZone)
return now >= instance.state.value.activeRange.start && now < instance.state.value.activeRange.end
})
</script>
<template>
<div
data-slot="event-calendar-nav"
:class="
cn(
'flex min-w-0 flex-wrap items-center gap-1 px-2 py-2',
viewConfig.stickyNav && 'bg-background sticky top-0 z-30',
viewConfig.classNames?.nav,
props.class
)
"
>
<Button
:variant="navVariant"
:size="navSize"
data-slot="event-calendar-nav-today"
:data-active="isToday || undefined"
:class="viewConfig.classNames?.navButton"
@click="instance.api.today()"
>
{{ settings.i18n.labels.today }}
</Button>
<div class="flex items-center">
<Button
:variant="navVariant"
:size="navIconSize"
data-slot="event-calendar-nav-prev"
:aria-label="settings.i18n.labels.previous"
:class="viewConfig.classNames?.navButton"
@click="instance.api.prev()"
>
<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="size-4" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
</Button>
<Button
:variant="navVariant"
:size="navIconSize"
data-slot="event-calendar-nav-next"
:aria-label="settings.i18n.labels.next"
:class="viewConfig.classNames?.navButton"
@click="instance.api.next()"
>
<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="size-4" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</Button>
</div>
<div data-slot="event-calendar-title" aria-live="polite" :class="cn('ms-3 min-w-0 truncate text-sm font-semibold', viewConfig.classNames?.title)">
{{ title }}
</div>
<div class="grow" />
</div>
</template>
src/reui/event-calendar/EventCalendarResourceView.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarResourceView (event-calendar-resource-view.tsx, MIT)
* — колонки-ресурсы на один день (бронирования): одна часовая шкала, одна
* колонка на лист-ресурс.
*
* ponytail — тот же список причин, что в EventCalendarTimeGrid.vue (dnd,
* измерение реального DOM, не нужно ни одному текущему кейсу):
* - drag/resize ghosts, slot-draft, `data-drop-target` — ждут dnd.
* - `EventCalendarNowIndicator`, начальный скролл к `scrollToHour`,
* `--ec-scrollbar-w` — не перенесены (см. EventCalendarTimeGrid.vue).
* - off-days marking, render-пропы (`renderResourceHeader`/
* `renderAllDaySection`), `asChild`, `scrollbars: "native"` ветка.
* `flattenResources`/`packTimedSegments` (lib.ts) переиспользованы как есть.
* НЕ забыт `provide(EventCalendarViewContextKey, ...)` (§ найдено на
* agenda-view: без него `EventCalendarEvent` бросает и роняет всё дерево —
* см. docs/PORTING.md).
*/
import { computed, provide, type HTMLAttributes } from "vue"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
EventCalendarViewContextKey,
useEventCalendar,
useEventCalendarSettings,
useEventCalendarViewConfig,
} from "./context"
import { flattenResources, getDayKey, getDayTotalMinutes, packTimedSegments, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarResource, EventCalendarSegment } from "./types"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
dayStartHour?: number
dayEndHour?: number
showAllDay?: boolean
interval?: number
}>(),
{ showAllDay: undefined, dayStartHour: undefined, dayEndHour: undefined, interval: undefined }
)
provide(EventCalendarViewContextKey, { view: "resource" })
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const showAllDay = computed(() => props.showAllDay ?? true)
const startHour = computed(() => props.dayStartHour ?? settings.dayStartHour)
const endHour = computed(() => props.dayEndHour ?? settings.dayEndHour)
const interval = computed(() => Math.min(Math.max(props.interval ?? viewConfig.interval, 5), 240))
const day = computed(() => zonedStartOfDay(instance.state.value.date, settings.timeZone))
const resources = computed<EventCalendarResource[]>(() =>
flattenResources(settings.resources)
.filter(({ resource }) => !resource.children?.length)
.map(({ resource }) => resource)
)
const slots = computed(() => {
const result: number[] = []
for (let m = startHour.value * 60; m < endHour.value * 60; m += interval.value) result.push(m)
return result
})
const gridTemplateColumns = computed(() => `repeat(${resources.value.length || 1}, minmax(8rem, 1fr))`)
function dayBucketAllDay(): EventCalendarSegment[] {
return instance.api.getIndex().byDay.get(getDayKey(day.value, settings.timeZone))?.allDay ?? []
}
function dayBucketTimed(): EventCalendarSegment[] {
return instance.api.getIndex().byDay.get(getDayKey(day.value, settings.timeZone))?.timed ?? []
}
function allDayForResource(resourceId: string): EventCalendarSegment[] {
return dayBucketAllDay().filter((segment) => segment.occurrence.event.resourceId === resourceId)
}
interface ColumnView {
boundsStartMin: number
boundsEndMin: number
boundsMinutes: number
packedTimed: EventCalendarSegment[]
}
function columnView(resourceId: string): ColumnView {
const totalMinutes = getDayTotalMinutes(day.value, settings.timeZone)
const boundsStartMin = startHour.value * 60
const boundsEndMin = Math.min(endHour.value * 60, totalMinutes)
const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
const mine = dayBucketTimed()
.filter((segment) => {
if (segment.occurrence.event.resourceId !== resourceId) return false
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
return endMin > boundsStartMin && startMin < boundsEndMin
})
.map((segment) => ({ ...segment }))
packTimedSegments(mine)
return { boundsStartMin, boundsEndMin, boundsMinutes, packedTimed: mine }
}
function minuteBlockStyle(startMin: number, endMin: number, boundsStartMin: number) {
const top = (startMin - boundsStartMin) / 60
const height = Math.max((endMin - startMin) / 60, 0.25)
return {
top: `calc(var(--ec-hour-height) * ${top})`,
height: `calc(var(--ec-hour-height) * ${height})`,
}
}
</script>
<template>
<div
data-slot="event-calendar-resource-view"
data-view="resource"
:class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.timeGrid, props.class)"
:style="{ '--ec-hour-height': '4rem' }"
>
<div :class="cn('flex border-b', viewConfig.classNames?.timeGridHeader)">
<div class="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
<div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
<div
v-for="resource in resources"
:key="resource.id"
data-slot="event-calendar-resource-header"
:class="cn('min-w-0 truncate border-e px-2 py-1.5 text-center font-medium last:border-e-0', viewConfig.classNames?.resourceHeader)"
>
{{ resource.title }}
</div>
</div>
</div>
<div v-if="showAllDay" data-slot="event-calendar-all-day-section" :class="cn('flex border-b', viewConfig.classNames?.allDaySection)">
<div :class="cn('text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5', viewConfig.classNames?.allDayLabel)">
<span class="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">{{ settings.i18n.labels.allDay }}</span>
</div>
<div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
<div
v-for="resource in resources"
:key="resource.id"
data-slot="event-calendar-all-day-cell"
:class="cn('relative flex min-h-[calc(var(--ec-month-bar-h,1.625rem)+0.625rem)] min-w-0 flex-col gap-0.5 border-e px-1 py-1.5 last:border-e-0', viewConfig.classNames?.allDayCell)"
>
<EventCalendarEvent
v-for="segment in allDayForResource(resource.id)"
:key="segment.occurrence.key"
:segment="segment"
class="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]"
/>
</div>
</div>
</div>
<div class="min-h-0 flex-1">
<ScrollArea class="h-full">
<div class="relative flex">
<div data-slot="event-calendar-time-gutter" :class="cn('relative w-(--ec-gutter-width,4.5rem) shrink-0 border-e', viewConfig.classNames?.timeGutter)">
<div
v-for="minutes in slots"
:key="minutes"
class="relative"
:style="{ height: `calc(var(--ec-hour-height) * ${interval / 60})` }"
>
<span
v-if="minutes > startHour * 60"
:class="cn('text-muted-foreground absolute end-2.5 -top-2', viewConfig.classNames?.timeGutterLabel)"
>
{{ format(toZoned(new Date(day.getTime() + minutes * 60000), settings.timeZone), interval % 60 === 0 ? settings.i18n.formats.timeGutter : settings.i18n.formats.timeGutterMinute, { locale: settings.locale }) }}
</span>
</div>
</div>
<div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
<div
v-for="resource in resources"
:key="resource.id"
data-slot="event-calendar-day-column"
:aria-label="resource.title"
role="group"
:data-ec-day="day.getTime()"
:data-ec-bounds-start="columnView(resource.id).boundsStartMin"
:data-ec-bounds-end="columnView(resource.id).boundsEndMin"
:data-ec-resource="resource.id"
:class="cn('relative min-w-0 border-e last:border-e-0', viewConfig.classNames?.dayColumn)"
:style="{
height: `calc(var(--ec-hour-height) * ${columnView(resource.id).boundsMinutes / 60})`,
backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
}"
>
<div
v-for="segment in columnView(resource.id).packedTimed"
:key="segment.occurrence.key"
class="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
:style="{
...minuteBlockStyle(Math.max(segment.startMin ?? 0, columnView(resource.id).boundsStartMin), Math.min(segment.endMin ?? 0, columnView(resource.id).boundsEndMin), columnView(resource.id).boundsStartMin),
left: `${(segment.column ?? 0) * (100 / (segment.columnCount ?? 1))}%`,
width: `${(segment.columnSpan ?? 1) * (100 / (segment.columnCount ?? 1))}%`,
'--ec-z': segment.occurrence.event.zIndex ?? 10 + (segment.column ?? 0),
}"
>
<EventCalendarEvent
:segment="segment"
:class="
cn(
(segment.columnCount ?? 1) > 1 && 'ring-background ring-1',
(segment.endMin ?? 0) - (segment.startMin ?? 0) < viewConfig.compactEventMinutes
? 'h-full gap-1 py-0 leading-4'
: 'h-full flex-col items-start justify-start gap-0 py-1',
viewConfig.classNames?.timedChip
)
"
/>
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
</div>
</template>
src/reui/event-calendar/EventCalendarTimeGrid.vue
<script setup lang="ts">
/**
* Порт ReUI EventCalendarTimeGrid (event-calendar-time-grid.tsx, MIT) —
* общий движок week/day/N-days: часовая шкала, минутное позиционирование
* событий, all-day строка.
*
* ponytail — сознательно НЕ перенесено в этом проходе (тот же список
* причин, что в EventCalendarMonthView.vue: зависят либо от dnd, либо от
* измерения реального DOM, либо не нужны ни одному текущему кейсу):
* - drag/resize ghosts, slot-draft — ждут `event-calendar-dnd.tsx`.
* - `EventCalendarNowIndicator` — линия "сейчас"; кейс (15 июня 2026)
* сознательно не пересекает реальную дату прогона, поэтому линия и так
* никогда не попала бы в видимый диапазон — добавить вместе с
* `useNow()` (setInterval-тик), когда появится кейс, где это видно.
* - `--ec-scrollbar-w`/`api.scrollToTime`/начальный скролл к `scrollToHour`
* (ResizeObserver + измерение реального DOM) — контейнер просто
* прокручивается нативно; начальная прокрутка к `scrollToHour` не
* выставляется.
* - off-days marking, `renderDayColumnBackground`/`renderTimeGutterSlot`/
* `renderAllDaySection`/`renderDayHeader` рендер-пропы, `asChild`,
* `scrollbars: "native"` ветка (всегда `ScrollArea`, кастомный скролл —
* дефолт оригинала).
* `packTimedSegments` (lib.ts) переиспользован для перепаковки сегментов,
* обрезанных границами часов (`boundsStartMin`/`boundsEndMin`), как и в
* оригинале.
*/
import { computed, provide, type HTMLAttributes } from "vue"
import { addDays, addMinutes, format } from "date-fns"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
EventCalendarViewContextKey,
useEventCalendar,
useEventCalendarSettings,
useEventCalendarViewConfig,
} from "./context"
import { getDayKey, getDayTotalMinutes, packTimedSegments, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarDayBucket } from "./lib"
import type { CalendarView, EventCalendarSegment } from "./types"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
view: Extract<CalendarView, "week" | "day" | "days">
dayStartHour?: number
dayEndHour?: number
showAllDay?: boolean
interval?: number
}>(),
{ showAllDay: true, dayStartHour: undefined, dayEndHour: undefined, interval: undefined }
)
provide(EventCalendarViewContextKey, { view: props.view })
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const visibleRange = computed(() => instance.state.value.visibleRange)
const weekendsOn = computed(() => instance.state.value.viewSettings.weekends ?? true)
const startHour = computed(() => props.dayStartHour ?? settings.dayStartHour)
const endHour = computed(() => props.dayEndHour ?? settings.dayEndHour)
const interval = computed(() => Math.min(Math.max(props.interval ?? viewConfig.interval, 5), 240))
const days = computed(() => {
const result: Date[] = []
let cursor = zonedStartOfDay(visibleRange.value.start, settings.timeZone)
while (cursor < visibleRange.value.end) {
result.push(cursor)
cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone)
}
if (weekendsOn.value || props.view === "day") return result
const filtered = result.filter(
(day) => !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay())
)
return filtered.length ? filtered : result
})
const slots = computed(() => {
const result: number[] = []
for (let m = startHour.value * 60; m < endHour.value * 60; m += interval.value) result.push(m)
return result
})
const gridTemplateColumns = computed(() => `repeat(${days.value.length}, minmax(0, 1fr))`)
function isToday(day: Date): boolean {
return getDayKey(new Date(), settings.timeZone) === getDayKey(day, settings.timeZone)
}
function dayBucket(day: Date): EventCalendarDayBucket {
return (
instance.api.getIndex().byDay.get(getDayKey(day, settings.timeZone)) ?? { allDay: [], timed: [] }
)
}
// All-day bars: consecutive-day segments of the same occurrence merged into
// one bar spanning its columns, lane-packed (same treatment as month view).
const allDayBars = computed(() => {
type Bar = { seg: EventCalendarSegment; colStart: number; colEnd: number; isStart: boolean; isEnd: boolean; lane: number }
const merged = new Map<string, Bar>()
days.value.forEach((day, col) => {
for (const seg of dayBucket(day).allDay) {
const key = seg.occurrence.key
const bar = merged.get(key)
if (bar) {
bar.colEnd = col
bar.isEnd = seg.isEnd
} else {
merged.set(key, { seg, colStart: col, colEnd: col, isStart: seg.isStart, isEnd: seg.isEnd, lane: 0 })
}
}
})
const packed = Array.from(merged.values()).sort(
(a, b) =>
a.colStart - b.colStart ||
b.colEnd - b.colStart - (a.colEnd - a.colStart) ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
const lanes: boolean[][] = []
for (const bar of packed) {
let lane = 0
for (;;) {
const row = (lanes[lane] ??= new Array(days.value.length).fill(false))
let free = true
for (let c = bar.colStart; c <= bar.colEnd; c++) {
if (row[c]) {
free = false
break
}
}
if (free) break
lane++
}
const row = lanes[lane]!
for (let c = bar.colStart; c <= bar.colEnd; c++) row[c] = true
bar.lane = lane
}
return {
bars: packed.map((bar) => ({
...bar.seg,
isStart: bar.isStart,
isEnd: bar.isEnd,
continuesBefore: !bar.isStart,
continuesAfter: !bar.isEnd,
colStart: bar.colStart,
colSpan: bar.colEnd - bar.colStart + 1,
lane: bar.lane,
})),
laneCount: lanes.length,
}
})
interface DayColumnView {
isToday: boolean
boundsStartMin: number
boundsEndMin: number
boundsMinutes: number
packedTimed: EventCalendarSegment[]
}
function dayColumnView(day: Date): DayColumnView {
const totalMinutes = getDayTotalMinutes(day, settings.timeZone)
const boundsStartMin = startHour.value * 60
const boundsEndMin = Math.min(endHour.value * 60, totalMinutes)
const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
const timed = dayBucket(day).timed
const visible = timed.filter((segment) => {
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
return endMin > boundsStartMin && startMin < boundsEndMin
})
let packedTimed = visible
if (visible.length !== timed.length) {
packedTimed = visible.map((segment) => ({ ...segment }))
packTimedSegments(packedTimed)
}
return { isToday: isToday(day), boundsStartMin, boundsEndMin, boundsMinutes, packedTimed }
}
function minuteBlockStyle(startMin: number, endMin: number, boundsStartMin: number) {
const top = (startMin - boundsStartMin) / 60
const height = Math.max((endMin - startMin) / 60, 0.25)
return {
top: `calc(var(--ec-hour-height) * ${top})`,
height: `calc(var(--ec-hour-height) * ${height})`,
}
}
</script>
<template>
<div
data-slot="event-calendar-time-grid"
:data-view="view"
:class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.timeGrid, props.class)"
:style="{ '--ec-hour-height': '4rem' }"
>
<div :class="cn('flex border-b', viewConfig.classNames?.timeGridHeader)">
<div class="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
<div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
<div
v-for="day in days"
:key="day.getTime()"
data-slot="event-calendar-day-header"
:data-today="isToday(day) || undefined"
:class="cn('data-today:text-primary min-w-0 truncate border-e px-2 py-1.5 font-medium last:border-e-0', isToday(day) && viewConfig.todayClassName)"
>
{{ format(toZoned(day, settings.timeZone), settings.i18n.formats.timeGridDayHeader, { locale: settings.locale }) }}
</div>
</div>
</div>
<div v-if="showAllDay" data-slot="event-calendar-all-day-section" :class="cn('flex border-b', viewConfig.classNames?.allDaySection)">
<div :class="cn('text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5', viewConfig.classNames?.allDayLabel)">
<span class="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">{{ settings.i18n.labels.allDay }}</span>
</div>
<div class="relative min-w-0 flex-1">
<div
class="grid h-full"
:style="{ gridTemplateColumns, minHeight: `calc(${Math.max(allDayBars.laneCount, 1)} * var(--ec-month-bar-h, 1.625rem) + 0.625rem)` }"
>
<div v-for="day in days" :key="day.getTime()" class="border-e last:border-e-0" />
</div>
<div
v-if="allDayBars.bars.length > 0"
data-slot="event-calendar-all-day-bar-overlay"
class="pointer-events-none absolute inset-x-0 top-0 grid pt-1.5"
:style="{ gridTemplateColumns, gridAutoRows: 'var(--ec-month-bar-h, 1.625rem)' }"
>
<div
v-for="segment in allDayBars.bars"
:key="segment.occurrence.key"
class="pointer-events-auto min-w-0 px-1"
:style="{ gridColumn: `${(segment.colStart ?? 0) + 1} / span ${segment.colSpan ?? 1}`, gridRow: (segment.lane ?? 0) + 1 }"
>
<EventCalendarEvent :segment="segment" class="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]" />
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1">
<ScrollArea class="h-full">
<div class="relative flex">
<div data-slot="event-calendar-time-gutter" :class="cn('relative w-(--ec-gutter-width,4.5rem) shrink-0 border-e', viewConfig.classNames?.timeGutter)">
<div
v-for="minutes in slots"
:key="minutes"
class="relative"
:style="{ height: `calc(var(--ec-hour-height) * ${interval / 60})` }"
>
<span
v-if="minutes > startHour * 60"
:class="cn('text-muted-foreground absolute end-2.5 -top-2', viewConfig.classNames?.timeGutterLabel)"
>
{{ format(addMinutes(zonedStartOfDay(days[0] ?? new Date(), settings.timeZone), minutes), interval % 60 === 0 ? settings.i18n.formats.timeGutter : settings.i18n.formats.timeGutterMinute, { locale: settings.locale }) }}
</span>
</div>
</div>
<div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
<div
v-for="day in days"
:key="day.getTime()"
data-slot="event-calendar-day-column"
:data-today="dayColumnView(day).isToday || undefined"
:data-ec-day="zonedStartOfDay(day, settings.timeZone).getTime()"
:data-ec-bounds-start="dayColumnView(day).boundsStartMin"
:data-ec-bounds-end="dayColumnView(day).boundsEndMin"
:class="cn('relative min-w-0 border-e last:border-e-0', dayColumnView(day).isToday && viewConfig.todayClassName, viewConfig.classNames?.dayColumn)"
:style="{
height: `calc(var(--ec-hour-height) * ${dayColumnView(day).boundsMinutes / 60})`,
backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
}"
>
<template v-for="segment in dayColumnView(day).packedTimed" :key="segment.occurrence.key">
<div
v-if="!(Math.min(segment.endMin ?? 0, dayColumnView(day).boundsEndMin) <= dayColumnView(day).boundsStartMin || Math.max(segment.startMin ?? 0, dayColumnView(day).boundsStartMin) >= dayColumnView(day).boundsEndMin)"
class="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
:style="{
...minuteBlockStyle(Math.max(segment.startMin ?? 0, dayColumnView(day).boundsStartMin), Math.min(segment.endMin ?? 0, dayColumnView(day).boundsEndMin), dayColumnView(day).boundsStartMin),
left: `${(segment.column ?? 0) * (100 / (segment.columnCount ?? 1))}%`,
width: `${(segment.columnSpan ?? 1) * (100 / (segment.columnCount ?? 1))}%`,
'--ec-z': segment.occurrence.event.zIndex ?? 10 + (segment.column ?? 0),
}"
>
<EventCalendarEvent
:segment="segment"
:class="
cn(
(segment.columnCount ?? 1) > 1 && 'ring-background ring-1',
(segment.endMin ?? 0) - (segment.startMin ?? 0) < viewConfig.compactEventMinutes
? 'h-full gap-1 py-0 leading-4'
: 'h-full flex-col items-start justify-start gap-0 py-1',
viewConfig.classNames?.timedChip
)
"
/>
</div>
</template>
</div>
</div>
</div>
</ScrollArea>
</div>
</div>
</template>
src/reui/event-calendar/EventCalendarWeekView.vue
<script setup lang="ts">
/** Тонкая обёртка над EventCalendarTimeGrid с фиксированным `view="week"`, как в оригинале. */
import type { HTMLAttributes } from "vue"
import EventCalendarTimeGrid from "./EventCalendarTimeGrid.vue"
// showAllDay: undefined в withDefaults — иначе Vue приводит отсутствующий
// boolean-проп к `false` вместо `undefined` и глушит дефолт `true` из
// EventCalendarTimeGrid.vue (та же ловушка, что задокументирована в
// docs/PORTING.md §32 / EventCalendar.vue).
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
dayStartHour?: number
dayEndHour?: number
showAllDay?: boolean
interval?: number
}>(),
{ showAllDay: undefined }
)
</script>
<template>
<EventCalendarTimeGrid
view="week"
:class="props.class"
:day-start-hour="props.dayStartHour"
:day-end-hour="props.dayEndHour"
:show-all-day="props.showAllDay"
:interval="props.interval"
/>
</template>
src/reui/event-calendar/context.ts
/**
* Порт движка ReUI EventCalendar (event-calendar.tsx, MIT) поверх Vue
* reactivity — НЕ порт `useSyncExternalStore`/`createEventCalendarStore`, а
* переписывание (тот же приём, что и DataGrid, см. комментарий в
* `data-grid/context.ts`): в оригинале `getState()`/`subscribe()`/ручной
* `snapshot`/`indexCache`/`rangeCache` существуют только затем, чтобы дать
* React стабильный по ссылке снимок между рендерами — Vue `computed()` делает
* это бесплатно (кэшируется, пока не изменились реактивные зависимости).
*
* ponytail: v1 — только неуправляемый (uncontrolled) режим: `createEventCalendar`
* читает `options` один раз при создании (как React `default*`-пропы), без
* `value`/`onXChange`-пары и без реакции на смену пропов извне. Двусторонний
* `v-model` на каждое поле — задача корневого `EventCalendar.vue` (ещё не
* портирован); добавить, когда он появится.
*
* ponytail: `EventCalendarGestures` (move/resize/create) здесь — заглушка
* (no-op). Настоящая реализация — `event-calendar-dnd.tsx`, портируется
* последним по плану. `EventCalendarEvent.vue` уже вызывает
* `useEventCalendarGestures()`, чтобы не переписывать её потом: корневой
* компонент вызовет `provide(EventCalendarGesturesContextKey, ...)` с боевой
* реализацией, ничего в этом файле/потребителях менять не придётся.
*/
import type { Component, ComputedRef, InjectionKey, VNodeChild } from "vue"
import { computed, inject, shallowReactive } from "vue"
import type { Locale } from "date-fns"
import {
buildEventIndex,
defaultEventOrder,
eventsOverlap,
getDayKey,
getViewDateRange,
stepDate,
toZoned as toZonedLib,
type EventCalendarIndex,
type WeekStartsOn,
} from "./lib"
import {
mergeEventCalendarI18n,
type EventCalendarI18nConfig,
type EventCalendarI18nOverrides,
} from "./i18n"
import type {
CalendarEvent,
CalendarView,
EventCalendarDateRange,
EventCalendarDragState,
EventCalendarEventId,
EventCalendarInteractions,
EventCalendarOccurrence,
EventCalendarOffDaysConfig,
EventCalendarProposedUpdate,
EventCalendarResource,
EventCalendarSegment,
EventCalendarSelection,
EventCalendarSlotDraft,
EventCalendarSlotInfo,
EventCalendarState,
EventCalendarUpdateResult,
EventCalendarViewSettings,
} from "./types"
const BASE_VIEWS: CalendarView[] = ["month", "week", "day", "days", "agenda"]
const ALL_VIEWS: CalendarView[] = [...BASE_VIEWS, "resource"]
const DEFAULT_INTERACTIONS: EventCalendarInteractions = {
drag: true,
resize: true,
selectSlot: true,
}
const EMPTY_SELECTION: EventCalendarSelection = { eventKeys: [], slot: null }
const DEFAULT_WEEKEND_DAYS: number[] = [0, 6]
const EMPTY_RESOURCES: EventCalendarResource[] = []
const DEFAULT_EVENT_PRIORITY = (event: CalendarEvent<never>) =>
event.priority ?? 0
type EventOrder<TData> = (
a: EventCalendarOccurrence<TData>,
b: EventCalendarOccurrence<TData>
) => number
function priorityEventOrder<TData>(
getEventPriority: (event: CalendarEvent<TData>) => number
): EventOrder<TData> {
return (a, b) =>
getEventPriority(b.event) - getEventPriority(a.event) ||
defaultEventOrder(a, b)
}
export interface EventCalendarActivationConfig {
moveDistancePx: number
createDistancePx: number
touchDelayMs: number
touchTolerancePx: number
autoScrollEdgePx: number
autoScrollMaxStepPx: number
}
export interface EventCalendarCallbacks<TData = unknown> {
onEventClick?: (
occurrence: EventCalendarOccurrence<TData>,
e: MouseEvent
) => void
onEventDoubleClick?: (
occurrence: EventCalendarOccurrence<TData>,
e: MouseEvent
) => void
onEventUpdate?: (
update: EventCalendarProposedUpdate<TData>
) => EventCalendarUpdateResult
canDropEvent?: (update: EventCalendarProposedUpdate<TData>) => boolean
onDragBlocked?: (
occurrence: EventCalendarOccurrence<TData>,
info: {
gesture: "move" | "resize"
reason: "readOnly" | "disabled" | "interactions-off"
}
) => void
onSlotClick?: (slot: EventCalendarSlotInfo, e: MouseEvent) => void
onSelectSlot?: (slot: EventCalendarSlotDraft) => void
canSelectSlot?: (slot: EventCalendarSlotDraft) => boolean
onViewChange?: (view: CalendarView) => void
onDateChange?: (date: Date) => void
onMoreClick?: (
day: Date,
segments: EventCalendarOccurrence<TData>[],
e: MouseEvent
) => void | false
}
/** Input to `createEventCalendar` — uncontrolled-only initial values (see file header). */
export interface EventCalendarOptions<TData = unknown>
extends EventCalendarCallbacks<TData> {
events?: CalendarEvent<TData>[]
view?: CalendarView
date?: Date
dayCount?: number
selection?: EventCalendarSelection
interactions?: Partial<EventCalendarInteractions>
viewSettings?: EventCalendarViewSettings
loading?: boolean
views?: CalendarView[]
timeZone?: string
locale?: Locale
weekStartsOn?: WeekStartsOn
dayStartHour?: number
dayEndHour?: number
slotDuration?: number
snapDuration?: number
agendaDayCount?: number
fixedWeeks?: boolean
showOutsideDays?: boolean
i18n?: EventCalendarI18nOverrides
resources?: EventCalendarResource[]
getEventPriority?: (event: CalendarEvent<TData>) => number
eventOrder?: (
a: EventCalendarOccurrence<TData>,
b: EventCalendarOccurrence<TData>
) => number
getOccurrences?: (
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
weekendDays?: number[]
activation?: Partial<EventCalendarActivationConfig>
}
export interface EventCalendarSettings<TData = unknown>
extends EventCalendarCallbacks<TData> {
timeZone: string
locale?: Locale
weekStartsOn: WeekStartsOn
views: CalendarView[]
dayStartHour: number
dayEndHour: number
slotDuration: number
snapDuration: number
agendaDayCount: number
fixedWeeks: boolean
showOutsideDays: boolean
i18n: EventCalendarI18nConfig
resources: EventCalendarResource[]
weekendDays: number[]
activation?: Partial<EventCalendarActivationConfig>
getEventPriority: (event: CalendarEvent<TData>) => number
eventOrder: (
a: EventCalendarOccurrence<TData>,
b: EventCalendarOccurrence<TData>
) => number
getOccurrences?: (
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
}
function resolveSettings<TData>(
options: EventCalendarOptions<TData>
): EventCalendarSettings<TData> {
const {
events: _events,
view: _view,
date: _date,
dayCount: _dayCount,
selection: _selection,
interactions: _interactions,
viewSettings: _viewSettings,
loading: _loading,
...rest
} = options
const getEventPriority =
options.getEventPriority ??
(DEFAULT_EVENT_PRIORITY as (event: CalendarEvent<TData>) => number)
return {
...rest,
timeZone:
options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: options.locale,
weekStartsOn:
options.weekStartsOn ?? options.locale?.options?.weekStartsOn ?? 0,
views:
options.views ?? (options.resources?.length ? ALL_VIEWS : BASE_VIEWS),
dayStartHour: options.dayStartHour ?? 0,
dayEndHour: options.dayEndHour ?? 24,
slotDuration: options.slotDuration ?? 30,
snapDuration: options.snapDuration ?? 15,
agendaDayCount: options.agendaDayCount ?? 30,
fixedWeeks: options.fixedWeeks ?? true,
showOutsideDays: options.showOutsideDays ?? true,
i18n: mergeEventCalendarI18n(options.i18n),
resources: options.resources ?? EMPTY_RESOURCES,
getEventPriority,
eventOrder: options.eventOrder ?? priorityEventOrder(getEventPriority),
getOccurrences: options.getOccurrences,
weekendDays: options.weekendDays ?? DEFAULT_WEEKEND_DAYS,
activation: options.activation,
}
}
export interface EventCalendarApi<TData = unknown> {
next(): void
prev(): void
today(): void
goTo(date: Date): void
setView(view: CalendarView, opts?: { dayCount?: number }): void
setDayCount(count: number): void
getEvents(): CalendarEvent<TData>[]
getEvent(id: EventCalendarEventId): CalendarEvent<TData> | undefined
setEvents(events: CalendarEvent<TData>[]): void
addEvent(event: CalendarEvent<TData>): void
updateEvent(
id: EventCalendarEventId,
patch: Partial<CalendarEvent<TData>>
): void
removeEvent(id: EventCalendarEventId): void
getOccurrences(
range?: EventCalendarDateRange
): EventCalendarOccurrence<TData>[]
getOccurrencesForDay(day: Date): EventCalendarOccurrence<TData>[]
findOverlapping(candidate: {
start: Date
end: Date
excludeEventId?: string
}): EventCalendarOccurrence<TData>[]
select(selection: Partial<EventCalendarSelection>): void
selectEvent(key: string, opts?: { additive?: boolean }): void
clearSelection(): void
setInteractions(patch: Partial<EventCalendarInteractions>): void
setViewSettings(patch: EventCalendarViewSettings): void
getVisibleRange(): EventCalendarDateRange
getActiveRange(): EventCalendarDateRange
/** TZDate in the calendar's display time zone. */
toZoned(date: Date): Date
applyProposedUpdate(
update: EventCalendarProposedUpdate<TData>,
extraPatch?: Partial<CalendarEvent<TData>>
): boolean
getIndex(): EventCalendarIndex<TData>
/** Cross-file plumbing for dnd.ts (live drag/resize preview); not part of the original public API. */
setDrag(drag: EventCalendarDragState<TData> | null): void
setSlotDraft(draft: EventCalendarSlotDraft | null): void
}
export interface EventCalendarInstance<TData = unknown> {
state: ComputedRef<EventCalendarState<TData>>
api: EventCalendarApi<TData>
settings: EventCalendarSettings<TData>
}
/**
* The full engine: reactive internal fields + derived `state`, matching
* `createEventCalendarStore`'s snapshot shape field-for-field (see file
* header for why there is no separate snapshot/subscribe machinery here).
*/
export function createEventCalendar<TData = unknown>(
options: EventCalendarOptions<TData> = {}
): EventCalendarInstance<TData> {
const settings = resolveSettings(options)
const resolveView = (view: CalendarView): CalendarView =>
settings.views.includes(view) ? view : (settings.views[0] ?? "month")
const internal = shallowReactive({
view: resolveView(options.view ?? "month"),
date: options.date ?? new Date(),
dayCount: Math.max(1, options.dayCount ?? 3),
events: (options.events ?? []) as CalendarEvent<TData>[],
selection: options.selection ?? EMPTY_SELECTION,
interactions: { ...DEFAULT_INTERACTIONS, ...options.interactions },
viewSettings: options.viewSettings ?? {},
loading: options.loading ?? false,
drag: null as EventCalendarDragState<TData> | null,
slotDraft: null as EventCalendarSlotDraft | null,
})
const state: ComputedRef<EventCalendarState<TData>> = computed(() => {
const view = resolveView(internal.view)
const dayCount = Math.max(1, internal.dayCount)
const { visibleRange, activeRange } = getViewDateRange(view, internal.date, {
timeZone: settings.timeZone,
weekStartsOn: settings.weekStartsOn,
dayCount,
agendaDayCount: settings.agendaDayCount,
fixedWeeks: settings.fixedWeeks,
})
return {
view,
date: internal.date,
dayCount,
visibleRange,
activeRange,
events: internal.events,
selection: internal.selection,
interactions: internal.interactions,
loading: internal.loading,
drag: internal.drag,
slotDraft: internal.slotDraft,
viewSettings: internal.viewSettings,
}
})
const index = computed(() =>
buildEventIndex(state.value.events, state.value.visibleRange, {
timeZone: settings.timeZone,
weekStartsOn: settings.weekStartsOn,
eventOrder: settings.eventOrder,
getOccurrences: settings.getOccurrences,
})
)
const remapSelectionKey = (
id: EventCalendarEventId,
oldKey: string,
nextStart: Date
) => {
const newKey = `${id}::${nextStart.toISOString()}`
if (newKey === oldKey) return
if (!internal.selection.eventKeys.includes(oldKey)) return
internal.selection = {
...internal.selection,
eventKeys: internal.selection.eventKeys.map((key) =>
key === oldKey ? newKey : key
),
}
}
const applyProposedUpdate = (
update: EventCalendarProposedUpdate<TData>,
extraPatch?: Partial<CalendarEvent<TData>>
): boolean => {
const result = settings.onEventUpdate?.(update)
if (result === false) return false
const adjusted: Partial<CalendarEvent<TData>> =
result && typeof result === "object"
? {
start: result.start ?? update.start,
end: result.end ?? update.end,
allDay: result.allDay ?? update.allDay,
}
: { start: update.start, end: update.end, allDay: update.allDay }
if (update.resourceId !== undefined) adjusted.resourceId = update.resourceId
const stored = internal.events.find((event) => event.id === update.event.id)
const oldKey =
update.occurrence?.key ??
(stored ? `${stored.id}::${stored.start.toISOString()}` : null)
if (oldKey) {
remapSelectionKey(update.event.id, oldKey, adjusted.start ?? update.start)
}
internal.events = internal.events.map((event) =>
event.id === update.event.id
? { ...event, ...extraPatch, ...adjusted }
: event
)
return true
}
const api: EventCalendarApi<TData> = {
next() {
internal.date = stepDate(state.value.view, internal.date, 1, {
timeZone: settings.timeZone,
dayCount: state.value.dayCount,
agendaDayCount: settings.agendaDayCount,
})
settings.onDateChange?.(internal.date)
},
prev() {
internal.date = stepDate(state.value.view, internal.date, -1, {
timeZone: settings.timeZone,
dayCount: state.value.dayCount,
agendaDayCount: settings.agendaDayCount,
})
settings.onDateChange?.(internal.date)
},
today() {
internal.date = new Date()
settings.onDateChange?.(internal.date)
},
goTo(date) {
internal.date = date
settings.onDateChange?.(date)
},
setView(view, opts) {
if (opts?.dayCount !== undefined) {
internal.dayCount = Math.max(1, opts.dayCount)
}
internal.view = resolveView(view)
settings.onViewChange?.(internal.view)
},
setDayCount(count) {
internal.dayCount = Math.max(1, count)
},
getEvents() {
return internal.events
},
getEvent(id) {
return internal.events.find((event) => event.id === id)
},
setEvents(events) {
internal.events = events
},
addEvent(event) {
internal.events = [...internal.events, event]
},
updateEvent(id, patch) {
const event = api.getEvent(id)
if (!event) return
const merged = { ...event, ...patch }
const timingChanged =
patch.start !== undefined ||
patch.end !== undefined ||
patch.allDay !== undefined
if (timingChanged && settings.onEventUpdate) {
const rest = { ...patch }
delete rest.start
delete rest.end
delete rest.allDay
applyProposedUpdate(
{
event: merged,
occurrence: null,
start: merged.start,
end: merged.end,
allDay: merged.allDay ?? false,
source: "api",
},
rest
)
return
}
if (timingChanged) {
remapSelectionKey(id, `${id}::${event.start.toISOString()}`, merged.start)
}
internal.events = internal.events.map((e) => (e.id === id ? merged : e))
},
removeEvent(id) {
internal.events = internal.events.filter((event) => event.id !== id)
},
getOccurrences(range) {
if (!range) return index.value.occurrences
const within =
range.start >= state.value.visibleRange.start &&
range.end <= state.value.visibleRange.end
if (within) {
return index.value.occurrences.filter((occ) => eventsOverlap(occ, range))
}
// ponytail: no throwaway-range cache (see createEventCalendarStore's
// rangeCache) — Vue callers read this through a computed of their own
// when they need referential stability; add a cache here if a caller
// ends up polling getOccurrences(range) every render.
return buildEventIndex(internal.events, range, {
timeZone: settings.timeZone,
weekStartsOn: settings.weekStartsOn,
eventOrder: settings.eventOrder,
getOccurrences: settings.getOccurrences,
}).occurrences
},
getOccurrencesForDay(day) {
const bucket = index.value.byDay.get(getDayKey(day, settings.timeZone))
if (!bucket) return []
const seen = new Set<string>()
const result: EventCalendarOccurrence<TData>[] = []
for (const seg of [...bucket.allDay, ...bucket.timed]) {
if (seen.has(seg.occurrence.key)) continue
seen.add(seg.occurrence.key)
result.push(seg.occurrence)
}
return result
},
findOverlapping({ start, end, excludeEventId }) {
return api
.getOccurrences({ start, end })
.filter((occ) => occ.eventId !== excludeEventId)
},
select(partial) {
internal.selection = {
eventKeys: partial.eventKeys ?? internal.selection.eventKeys,
slot: partial.slot !== undefined ? partial.slot : internal.selection.slot,
}
},
selectEvent(key, opts) {
const current = internal.selection
const eventKeys = opts?.additive
? current.eventKeys.includes(key)
? current.eventKeys.filter((k) => k !== key)
: [...current.eventKeys, key]
: [key]
internal.selection = { ...current, eventKeys }
},
clearSelection() {
internal.selection = EMPTY_SELECTION
},
setInteractions(patch) {
internal.interactions = { ...internal.interactions, ...patch }
},
setViewSettings(patch) {
internal.viewSettings = { ...internal.viewSettings, ...patch }
},
getVisibleRange() {
return state.value.visibleRange
},
getActiveRange() {
return state.value.activeRange
},
toZoned(date) {
return toZonedLib(date, settings.timeZone)
},
applyProposedUpdate,
getIndex() {
return index.value
},
setDrag(drag) {
internal.drag = drag
},
setSlotDraft(draft) {
internal.slotDraft = draft
},
}
return { state, api, settings }
}
export const EventCalendarContextKey: InjectionKey<EventCalendarInstance<any>> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Symbol("EventCalendarContext")
/** Соответствует `useEventCalendar()` оригинала — бросает вне `<EventCalendar>`. */
export function useEventCalendar<TData = unknown>(): EventCalendarInstance<TData> {
const instance = inject(EventCalendarContextKey, undefined)
if (!instance) {
throw new Error("useEventCalendar must be used within <EventCalendar>")
}
return instance as EventCalendarInstance<TData>
}
/** Convenience accessor — `instance.settings` is a plain resolved object (v1: not per-prop reactive, see file header). */
export function useEventCalendarSettings<
TData = unknown,
>(): EventCalendarSettings<TData> {
return useEventCalendar<TData>().settings
}
export const EventCalendarViewContextKey: InjectionKey<{ view: CalendarView }> =
Symbol("EventCalendarViewContext")
/** Соответствует `useEventCalendarViewContext()` оригинала — бросает вне вида. */
export function useEventCalendarViewContext(): { view: CalendarView } {
const ctx = inject(EventCalendarViewContextKey, undefined)
if (!ctx) {
throw new Error(
"useEventCalendarViewContext must be used inside a calendar view"
)
}
return ctx
}
// ---------------------------------------------------------------------------
// View-layer config: display props + render overrides (EventCalendarViewConfig)
// ---------------------------------------------------------------------------
export interface EventCalendarClassNames {
nav?: string
toolbar?: string
content?: string
monthView?: string
monthCell?: string
timeGrid?: string
timeGutter?: string
dayColumn?: string
allDaySection?: string
agendaView?: string
event?: string
eventTooltip?: string
moreIndicator?: string
morePopover?: string
morePopoverHeader?: string
navButton?: string
title?: string
navTooltip?: string
viewSwitcherContent?: string
viewSwitcherLabel?: string
viewShortcut?: string
datePickerContent?: string
monthHeader?: string
monthDayHeader?: string
monthBody?: string
monthRow?: string
weekNumber?: string
monthBarOverlay?: string
monthBar?: string
monthCellContent?: string
monthCellFooter?: string
monthDayNumber?: string
dayAddButton?: string
timeGridHeader?: string
timeGutterLabel?: string
allDayLabel?: string
allDayCell?: string
timedChip?: string
resourceHeader?: string
dragGhost?: string
dragCarry?: string
dragCarryInvalid?: string
dropHint?: string
dropIndicator?: string
slotDraft?: string
resizeHandle?: string
resizeGrip?: string
noEvents?: string
agendaDay?: string
agendaDayHeader?: string
agendaDayGutter?: string
agendaDate?: string
agendaDayToggle?: string
agendaDayContent?: string
agendaItem?: string
agendaItemSurface?: string
agendaItemToggle?: string
agendaDaySummary?: string
agendaSummaryDot?: string
}
export interface EventCalendarRenderEventProps<TData = unknown> {
occurrence: EventCalendarOccurrence<TData>
segment: EventCalendarSegment<TData>
view: CalendarView
isDragging: boolean
isSelected: boolean
}
/** View-layer configuration: display props and render overrides. */
export interface EventCalendarViewConfig<TData = unknown> {
scrollToHour: number
nowIndicator: boolean
interval: number
maxEventsPerCell: number | "auto"
showWeekNumbers: boolean
enableShortcuts: boolean
shortcutsScope: "focus-within" | "global"
scrollMode: "contained" | "page"
stickyNav: boolean
dayClassName?: (day: Date) => string | undefined
todayClassName?: string
showDayAddButton: boolean
scrollbars: "custom" | "native"
navButtonVariant: "ghost" | "outline" | "secondary" | "default"
navButtonSize: "sm" | "default"
offDays?: boolean | EventCalendarOffDaysConfig
classNames?: EventCalendarClassNames
components?: Partial<Record<CalendarView, Component>>
renderEvent?: (props: EventCalendarRenderEventProps<TData>) => VNodeChild
renderAgendaEvent?: (props: EventCalendarRenderEventProps<TData>) => VNodeChild
renderEventTooltip?: (props: {
occurrence: EventCalendarOccurrence<TData>
segment: EventCalendarSegment<TData>
view: CalendarView
label: string | undefined
}) => VNodeChild
renderDragPreview?: (props: { drag: EventCalendarDragState<TData> }) => VNodeChild
renderMonthCell?: (props: {
day: Date
isToday: boolean
isOutside: boolean
overflowCount: number
defaultContent: VNodeChild
}) => VNodeChild
renderDayColumnBackground?: (props: {
day: Date
boundsStartMin: number
boundsEndMin: number
totalMinutes: number
}) => VNodeChild
renderDayHeader?: (props: {
day: Date
view: CalendarView
isToday: boolean
}) => VNodeChild
renderTimeGutterSlot?: (props: {
time: Date
hour: number
minute: number
}) => VNodeChild
renderAllDaySection?: (props: {
days: Date[]
segments: EventCalendarSegment<TData>[]
}) => VNodeChild
renderMoreIndicator?: (props: {
day: Date
count: number
segments: EventCalendarSegment<TData>[]
}) => VNodeChild
renderMoreContent?: (props: {
day: Date
segments: EventCalendarSegment<TData>[]
close: () => void
}) => VNodeChild
renderAgendaEventDetails?: (
occurrence: EventCalendarOccurrence<TData>
) => VNodeChild
renderNowIndicator?: (props: { time: Date }) => VNodeChild
renderNoEvents?: () => VNodeChild
renderResourceHeader?: (props: { resource: EventCalendarResource }) => VNodeChild
renderAgendaDayHeader?: (props: {
day: Date
collapsed: boolean
count: number
toggle: () => void
defaultContent: VNodeChild
}) => VNodeChild
renderAgendaDaySummary?: (props: {
day: Date
occurrences: EventCalendarOccurrence<TData>[]
count: number
expand: () => void
defaultContent: VNodeChild
}) => VNodeChild
dayCountPresets: number[]
navTooltips?:
| false
| {
side?: "top" | "bottom" | "left" | "right"
delay?: number
closeDelay?: number
timeout?: number
}
eventTooltip?:
| boolean
| {
side?: "top" | "bottom" | "left" | "right"
delay?: number
}
compactEventMinutes: number
morePopoverAlign: "start" | "center" | "end"
nowIndicatorInterval: number
agendaSummaryMaxDots: number
}
export const DEFAULT_EVENT_CALENDAR_VIEW_CONFIG: EventCalendarViewConfig = {
scrollToHour: 7,
nowIndicator: true,
interval: 60,
maxEventsPerCell: "auto",
showWeekNumbers: false,
enableShortcuts: true,
shortcutsScope: "focus-within",
scrollMode: "contained",
stickyNav: false,
showDayAddButton: false,
scrollbars: "custom",
navButtonVariant: "ghost",
navButtonSize: "sm",
dayCountPresets: [5],
eventTooltip: false,
compactEventMinutes: 45,
morePopoverAlign: "start",
nowIndicatorInterval: 30_000,
agendaSummaryMaxDots: 6,
}
export const EventCalendarViewConfigContextKey: InjectionKey<
EventCalendarViewConfig<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
> = Symbol("EventCalendarViewConfigContext")
/** Соответствует `useEventCalendarViewConfig()` оригинала — не бросает, дефолт `DEFAULT_EVENT_CALENDAR_VIEW_CONFIG`. */
export function useEventCalendarViewConfig<
TData = unknown,
>(): EventCalendarViewConfig<TData> {
return inject(
EventCalendarViewConfigContextKey,
DEFAULT_EVENT_CALENDAR_VIEW_CONFIG
) as EventCalendarViewConfig<TData>
}
// ---------------------------------------------------------------------------
// Gestures — placeholder until event-calendar-dnd.tsx is ported (see file header)
// ---------------------------------------------------------------------------
export interface EventCalendarGestures<TData = unknown> {
canResize: boolean
beginMove: (e: PointerEvent, segment: EventCalendarSegment<TData>) => void
beginResize: (
e: PointerEvent,
segment: EventCalendarSegment<TData>,
edge: "start" | "end"
) => void
beginCreate: (e: PointerEvent, day: Date, allDay: boolean) => void
/** Suppresses the trailing click after a real drag; see `event-calendar-dnd.tsx`'s `wasRecentDrag`. */
wasRecentDrag: () => boolean
/** Marks a chip pointerdown so a non-drag press does not trigger create-on-click; see `markChipPress`. */
markChipPress: () => void
}
function noopGestures<TData = unknown>(): EventCalendarGestures<TData> {
return {
canResize: false,
beginMove: () => {},
beginResize: () => {},
beginCreate: () => {},
wasRecentDrag: () => false,
markChipPress: () => {},
}
}
export const EventCalendarGesturesContextKey: InjectionKey<
EventCalendarGestures<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
> = Symbol("EventCalendarGesturesContext")
/** Соответствует `useEventCalendarGestures()` оригинала — не бросает, дефолт no-op до портирования dnd. */
export function useEventCalendarGestures<
TData = unknown,
>(): EventCalendarGestures<TData> {
return inject(
EventCalendarGesturesContextKey,
noopGestures,
true
) as EventCalendarGestures<TData>
}
src/reui/event-calendar/dnd.ts
/**
* Порт ReUI event-calendar-dnd.tsx (MIT) — переиспользуемая композиция
* pointer-event хендлеров на голом DOM.
*
* ВАЖНО, отличается от Kanban/Sortable/DataGrid: оригинал `event-calendar-
* dnd.tsx` НЕ использует dnd-kit вообще — это уже framework-агностичный
* движок на `pointerdown`/`pointermove`/`pointerup`, window-листенерах и
* ручной геометрии (`getBoundingClientRect`). ADR-002 (dnd-kit -> Pragmatic
* Drag and Drop) сюда НЕ применяется: заменять нечего, переносится
* оригинальная pointer-математика почти дословно, framework-специфичная
* прослойка — только вызов `instance.api.*` вместо React setState и
* `provide()` вместо React Context.
*
* ponytail — сознательно НЕ перенесено (граница эквивалентности ADR-002
* прямо разрешает расхождение в "физике"/визуальной обратной связи, только
* итоговая дата/модель обязаны совпасть):
* - Курсор-клон события (`createCarry`/`positionCarry`) и подсказка
* "нельзя бросить" (`updateHint`) — vanilla-DOM оверлеи, чисто
* визуальные, не влияют на itogovoe состояние. Добавить как есть из
* оригинала, если понадобится визуальное качество перетаскивания.
* - Автоскролл контейнера (`autoScroll`) — у наших кейсов вся сетка
* помещается без прокрутки; `pointerMinutes` здесь не компенсирует
* scrollTop (дельта всегда 0).
* - Touch/long-press активация (`touchDelayMs`) — интеракционный гейт
* проверяет только мышь; активация происходит сразу по dnd-порогу.
* - `beginBlockedGesture` (визуальный "not-allowed" на readOnly/disabled
* событии) — ни один текущий кейс не имеет readOnly-событий;
* `canDrag`/`canResize` просто отказывают без обратной связи.
* - Drag-create (выделение слота пустым перетаскиванием) — `beginCreate`
* существует и работает, но ни один вид (`MonthView`/`TimeGrid`/
* `ResourceView`) пока не вызывает его из `pointerdown` пустой ячейки
* (там сейчас только `onSlotClick` по клику) — добавить вызов
* `gestures.beginCreate` в `onPointerDown` дня/колонки, когда появится
* кейс с созданием событий перетаскиванием.
* `computeProposal` (слой координата -> дата/время, дискретный по дням и
* непрерывный по минутам) перенесён дословно — это ядро, которое обязано
* дать идентичный итог с оригиналом.
*/
import type {
EventCalendarInstance,
EventCalendarGestures,
} from "./context"
import { snapMinutes, toZoned, zonedStartOfDay } from "./lib"
import type {
EventCalendarProposedUpdate,
EventCalendarSegment,
} from "./types"
import { addDays, addMinutes, differenceInCalendarDays } from "date-fns"
const EVENT_CALENDAR_ACTIVATION = {
moveDistancePx: 5,
createDistancePx: 4,
} as const
type GestureKind = "move" | "resize-start" | "resize-end" | "create"
interface TimeColumnRect {
day: Date
rect: DOMRect
boundsStartMin: number
boundsEndMin: number
resourceId?: string
}
interface DayCellRect {
day: Date
rect: DOMRect
}
interface Surface {
columns: TimeColumnRect[]
cells: DayCellRect[]
}
let lastGestureEndedAt = 0
function wasRecentDrag(): boolean {
return performance.now() - lastGestureEndedAt < 250
}
let lastChipPressAt = 0
function markChipPress(): void {
lastChipPressAt = performance.now()
window.addEventListener(
"pointerup",
() => {
lastChipPressAt = performance.now()
},
{ once: true, capture: true }
)
}
function wasRecentChipPress(): boolean {
return performance.now() - lastChipPressAt < 300
}
const activeGestureCancels = new Set<() => void>()
function cancelActiveEventCalendarGestures(): void {
for (const cancel of [...activeGestureCancels]) cancel()
}
function collectSurface(origin: HTMLElement): Surface {
const root =
origin.closest<HTMLElement>("[data-slot=event-calendar-time-grid]") ??
origin.closest<HTMLElement>("[data-slot=event-calendar-resource-view]") ??
origin.closest<HTMLElement>("[data-slot=event-calendar-month-view]") ??
origin.closest<HTMLElement>("[data-slot=event-calendar]") ??
null
const columns: TimeColumnRect[] = []
const cells: DayCellRect[] = []
if (root) {
for (const el of root.querySelectorAll<HTMLElement>("[data-ec-day]")) {
const day = new Date(Number(el.dataset.ecDay))
if (el.dataset.ecBoundsStart !== undefined) {
columns.push({
day,
rect: el.getBoundingClientRect(),
boundsStartMin: Number(el.dataset.ecBoundsStart),
boundsEndMin: Number(el.dataset.ecBoundsEnd),
resourceId: el.dataset.ecResource,
})
} else {
cells.push({ day, rect: el.getBoundingClientRect() })
}
}
}
return { columns, cells }
}
function findColumn(surface: Surface, clientX: number): TimeColumnRect | undefined {
let best: TimeColumnRect | undefined
for (const col of surface.columns) {
if (clientX >= col.rect.left && clientX < col.rect.right) return col
if (!best) best = col
const bestDist = Math.min(Math.abs(clientX - best.rect.left), Math.abs(clientX - best.rect.right))
const dist = Math.min(Math.abs(clientX - col.rect.left), Math.abs(clientX - col.rect.right))
if (dist < bestDist) best = col
}
return best
}
function findCell(surface: Surface, x: number, y: number): DayCellRect | undefined {
return surface.cells.find(
(cell) => x >= cell.rect.left && x < cell.rect.right && y >= cell.rect.top && y < cell.rect.bottom
)
}
// ponytail: no scroll-delta compensation (see file header) — the grid never
// scrolls in our cases, so the raw client Y maps straight onto the column.
function pointerMinutes(col: TimeColumnRect, clientY: number): number {
const boundsMinutes = col.boundsEndMin - col.boundsStartMin
const pxPerMinute = col.rect.height / Math.max(1, boundsMinutes)
const y = clientY - col.rect.top
return col.boundsStartMin + y / pxPerMinute
}
interface BeginGestureConfig<TData> {
instance: EventCalendarInstance<TData>
kind: GestureKind
origin: HTMLElement
startEvent: PointerEvent
segment?: EventCalendarSegment<TData>
createDay?: Date
createAllDay?: boolean
}
function beginGesture<TData>(config: BeginGestureConfig<TData>) {
const { instance, kind, origin, startEvent, segment } = config
const { settings, api } = instance
const timeZone = settings.timeZone
const snap = settings.snapDuration
const activation = { ...EVENT_CALENDAR_ACTIVATION, ...settings.activation }
const startX = startEvent.clientX
const startY = startEvent.clientY
const pointerId = startEvent.pointerId
let active = kind.startsWith("resize")
let surface: Surface | null = active ? collectSurface(origin) : null
let lastProposalKey = ""
const occurrence = segment?.occurrence
const isBar = occurrence
? occurrence.allDay || occurrence.end.getTime() - occurrence.start.getTime() > 24 * 60 * 60 * 1000
: false
// Preserve the grab offset so the event does not jump to the pointer.
let grabOffsetMin = 0
let grabLeadMs = 0
let grabSegDurationMs = occurrence ? occurrence.end.getTime() - occurrence.start.getTime() : 0
let grabDayOffset = 0
const activationDistance = kind === "create" ? activation.createDistancePx : activation.moveDistancePx
const activate = () => {
if (active) return
active = true
surface = collectSurface(origin)
if (kind === "move" && occurrence) {
const grabCell = findCell(surface, startX, startY)
if (grabCell) {
const originDay = zonedStartOfDay(occurrence.start, timeZone)
const lastDay = zonedStartOfDay(
new Date(Math.max(occurrence.end.getTime() - 1, occurrence.start.getTime())),
timeZone
)
const offset = differenceInCalendarDays(zonedStartOfDay(grabCell.day, timeZone), originDay)
if (offset >= 0 && offset <= differenceInCalendarDays(lastDay, originDay)) {
grabDayOffset = offset
}
}
if (surface.columns.length > 0 && !isBar) {
const col = findColumn(surface, startX)
if (col) {
const colDayStart = zonedStartOfDay(col.day, timeZone)
const segStartMs = Math.max(occurrence.start.getTime(), colDayStart.getTime())
grabLeadMs = segStartMs - occurrence.start.getTime()
grabSegDurationMs = Math.min(occurrence.end.getTime(), addDays(colDayStart, 1).getTime()) - segStartMs
grabOffsetMin = pointerMinutes(col, startY) - (segStartMs - colDayStart.getTime()) / 60000
}
}
}
}
let createAnchorMin: number | null = null
const computeProposal = (
e: PointerEvent
): { start: Date; end: Date; allDay: boolean; dayGranular?: boolean; resourceId?: string } | null => {
if (!surface) return null
// ---- create: select a slot range
if (kind === "create") {
if (config.createAllDay || surface.columns.length === 0) {
const anchor = zonedStartOfDay(config.createDay!, timeZone)
const cell = findCell(surface, e.clientX, e.clientY)
const target = cell ? zonedStartOfDay(cell.day, timeZone) : anchor
const start = anchor <= target ? anchor : target
const end = addDays(anchor <= target ? target : anchor, 1)
return { start, end, allDay: true, dayGranular: true }
}
const col = findColumn(surface, startX)
if (!col) return null
if (createAnchorMin === null) createAnchorMin = snapMinutes(pointerMinutes(col, startY), snap)
const anchorMin = createAnchorMin
const curMin = snapMinutes(pointerMinutes(col, e.clientY), snap)
const lo = Math.max(col.boundsStartMin, Math.min(anchorMin, curMin))
const hi = Math.min(col.boundsEndMin, Math.max(anchorMin, curMin, lo + snap))
const dayStart = zonedStartOfDay(col.day, timeZone)
return { start: addMinutes(dayStart, lo), end: addMinutes(dayStart, hi), allDay: false, resourceId: col.resourceId }
}
if (!occurrence) return null
const durationMs = occurrence.end.getTime() - occurrence.start.getTime()
// ---- day-granularity: month cells and bars in the all-day row
const overCell = findCell(surface, e.clientX, e.clientY)
if (kind === "move" && !isBar && surface.columns.length > 0 && overCell !== undefined) {
const targetDay = zonedStartOfDay(overCell.day, timeZone)
return { start: targetDay, end: addDays(targetDay, 1), allDay: true, dayGranular: true }
}
const useCells = surface.columns.length === 0 || (isBar && overCell !== undefined)
if (useCells) {
const cell = overCell ?? findCell(surface, startX, startY)
if (!cell) return null
const targetDay = zonedStartOfDay(cell.day, timeZone)
if (kind === "move") {
const originDay = zonedStartOfDay(occurrence.start, timeZone)
const delta = differenceInCalendarDays(targetDay, originDay) - grabDayOffset
const start = addDays(toZoned(occurrence.start, timeZone), delta)
return { start, end: new Date(start.getTime() + durationMs), allDay: occurrence.allDay, dayGranular: true }
}
if (kind === "resize-start") {
const time = occurrence.start.getTime() - zonedStartOfDay(occurrence.start, timeZone).getTime()
const start = new Date(targetDay.getTime() + time)
if (start >= occurrence.end) return null
return { start, end: occurrence.end, allDay: occurrence.allDay, dayGranular: true }
}
const time = occurrence.allDay ? 0 : occurrence.end.getTime() - zonedStartOfDay(occurrence.end, timeZone).getTime()
const end = occurrence.allDay
? addDays(targetDay, 1)
: new Date(addDays(targetDay, time > 0 ? 0 : 1).getTime() + time)
if (end <= occurrence.start) return null
return { start: occurrence.start, end, allDay: occurrence.allDay, dayGranular: true }
}
// ---- minute-granularity: time-grid columns
const col = findColumn(surface, e.clientX)
if (!col) return null
const dayStart = zonedStartOfDay(col.day, timeZone)
const rawMin = pointerMinutes(col, e.clientY)
if (kind === "move") {
const newStartMin = snapMinutes(rawMin - grabOffsetMin, snap)
const chipDurationMin = Math.round(grabSegDurationMs / 60000)
const clamped = Math.min(Math.max(newStartMin, col.boundsStartMin), col.boundsEndMin - chipDurationMin)
const start = new Date(addMinutes(dayStart, clamped).getTime() - grabLeadMs)
return { start, end: new Date(start.getTime() + durationMs), allDay: false, resourceId: col.resourceId }
}
const min = snapMinutes(rawMin, snap)
const occStartMinInCol = Math.round((occurrence.start.getTime() - dayStart.getTime()) / 60000)
const occEndMinInCol = Math.round((occurrence.end.getTime() - dayStart.getTime()) / 60000)
if (kind === "resize-start") {
const clamped = Math.min(Math.max(min, col.boundsStartMin), Math.min(occEndMinInCol - snap, col.boundsEndMin))
const start = addMinutes(dayStart, clamped)
if (start >= occurrence.end) return null
return { start, end: occurrence.end, allDay: false }
}
const clamped = Math.max(Math.min(min, col.boundsEndMin), Math.max(occStartMinInCol + snap, col.boundsStartMin))
const end = addMinutes(dayStart, clamped)
if (end <= occurrence.start) return null
return { start: occurrence.start, end, allDay: false }
}
const applyProposal = (e: PointerEvent) => {
const proposal = computeProposal(e)
if (!proposal) return
const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? ""}`
if (key === lastProposalKey) return
lastProposalKey = key
if (kind === "create") {
const draft = { ...proposal, view: instance.state.value.view }
if (settings.canSelectSlot && !settings.canSelectSlot(draft)) return
api.setSlotDraft(draft)
return
}
const update: EventCalendarProposedUpdate<TData> = {
event: occurrence!.event,
occurrence: occurrence!,
...proposal,
source: kind === "move" ? "drag" : (kind as "resize-start" | "resize-end"),
}
const valid = settings.canDropEvent ? settings.canDropEvent(update) : true
api.setDrag({
kind: kind === "move" ? "move" : (kind as "resize-start" | "resize-end"),
occurrence: occurrence!,
proposedStart: proposal.start,
proposedEnd: proposal.end,
proposedAllDay: proposal.allDay,
proposedDayGranular: proposal.dayGranular ?? false,
proposedResourceId: proposal.resourceId,
valid,
})
}
let finished = false
const cleanup = () => {
if (finished) return
finished = true
activeGestureCancels.delete(cancel)
window.removeEventListener("pointermove", onPointerMove)
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onCancel)
window.removeEventListener("blur", onWindowBlur)
window.removeEventListener("keydown", onKeyDown, true)
}
const cancel = () => {
cleanup()
if (active) {
lastGestureEndedAt = performance.now()
api.setDrag(null)
api.setSlotDraft(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation()
cancel()
}
}
const onWindowBlur = () => cancel()
const onPointerMove = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
if (!active) {
const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
if (distance < activationDistance) return
activate()
}
applyProposal(e)
}
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
cleanup()
if (!active) return
lastGestureEndedAt = performance.now()
const state = instance.state.value
if (kind === "create") {
const draft = state.slotDraft
api.setSlotDraft(null)
if (draft) {
api.select({ slot: { start: draft.start, end: draft.end, allDay: draft.allDay } })
settings.onSelectSlot?.(draft)
}
return
}
const drag = state.drag
api.setDrag(null)
if (!drag || !occurrence) return
const unchanged =
drag.proposedStart.getTime() === occurrence.start.getTime() &&
drag.proposedEnd.getTime() === occurrence.end.getTime() &&
(drag.proposedResourceId === undefined || drag.proposedResourceId === occurrence.event.resourceId)
if (unchanged) return
api.applyProposedUpdate({
event: occurrence.event,
occurrence,
start: drag.proposedStart,
end: drag.proposedEnd,
allDay: drag.proposedAllDay,
resourceId: drag.proposedResourceId,
source: kind === "move" ? "drag" : (kind as "resize-start" | "resize-end"),
})
}
const onCancel = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
cancel()
}
window.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
window.addEventListener("pointercancel", onCancel)
if (active) window.addEventListener("blur", onWindowBlur)
window.addEventListener("keydown", onKeyDown, true)
activeGestureCancels.add(cancel)
}
/** Соответствует `useEventCalendarGestures()` оригинала — построен один раз в `EventCalendar.vue` и провайден через `EventCalendarGesturesContextKey`. */
export function createEventCalendarGestures<TData = unknown>(
instance: EventCalendarInstance<TData>
): EventCalendarGestures<TData> {
const canDrag = (segment: EventCalendarSegment<TData>): boolean => {
const { interactions } = instance.state.value
const event = segment.occurrence.event
return interactions.drag && !event.readOnly && event.draggable !== false
}
const canResizeSegment = (segment: EventCalendarSegment<TData>): boolean => {
const { interactions } = instance.state.value
const event = segment.occurrence.event
return interactions.resize && !event.readOnly && event.resizable !== false
}
return {
// ponytail: static `true` (see EventCalendarGestures — Event.vue does not
// read this field; it derives handle visibility from state.interactions
// itself). Kept only to satisfy the interface.
canResize: true,
beginMove(e, segment) {
if (e.button !== 0 || !canDrag(segment)) return
beginGesture({ instance, kind: "move", origin: e.currentTarget as HTMLElement, startEvent: e, segment })
},
beginResize(e, segment, edge) {
if (e.button !== 0 || !canResizeSegment(segment)) return
// Must not bubble: the resize handle is a DOM child of the chip's own
// button, which has its own pointerdown -> beginMove listener (see
// EventCalendarEvent.vue). Without this, grabbing the handle starts a
// move AND a resize gesture at once.
e.stopPropagation()
e.preventDefault()
beginGesture({
instance,
kind: edge === "start" ? "resize-start" : "resize-end",
origin: e.currentTarget as HTMLElement,
startEvent: e,
segment,
})
},
beginCreate(e, day, allDay) {
if (e.button !== 0 || !instance.state.value.interactions.selectSlot) return
beginGesture({ instance, kind: "create", origin: e.currentTarget as HTMLElement, startEvent: e, createDay: day, createAllDay: allDay })
},
wasRecentDrag,
markChipPress,
}
}
export { cancelActiveEventCalendarGestures, wasRecentChipPress }
src/reui/event-calendar/i18n.ts
// Title: Event Calendar I18n
// Description: Default UI texts, date-format strings, and formatter functions for the event calendar, fully overridable per key.
// Port of ReUI event-calendar-i18n.tsx (MIT) — pure TS/date-fns, ported verbatim.
import type { CalendarView, EventCalendarDateRange } from "./types"
import {
format,
isSameMonth,
isSameYear,
subMilliseconds,
type Locale,
} from "date-fns"
interface EventCalendarI18nConfig {
labels: {
today: string
previous: string
next: string
addEvent: string
allDay: string
more: (count: number) => string
noEvents: string
loading: string
event: string
events: (count: number) => string
selectView: string
week: (weekNumber: number) => string
resources: string
goToDate: string
/** Cursor hint while a drag/resize hovers a rejected position. */
dropNotAllowed: string
/** Aria-label suffix on chip segments that continue past the cell. */
continues: string
/** Agenda label for the first day of a multi-day event. */
timeFrom: (time: string) => string
/** Agenda label for the last day of a multi-day event. */
timeUntil: (time: string) => string
/** View-switcher shortcut hint characters, per view. */
viewShortcuts: Record<CalendarView, string>
/** Aria-label of the agenda day collapse/expand toggle. */
toggleDayEvents: (count: number, expanded: boolean) => string
/** Aria-label of the agenda event details toggle. */
eventDetails: (title: string) => string
/** Compact "+N" overflow (agenda summary dot stack). */
moreCompact: (count: number) => string
/** Joins a bounded from-to time span. */
timeRange: (from: string, to: string) => string
}
viewNames: {
month: string
week: string
day: string
days: (count: number) => string
agenda: string
resource: string
}
/** date-fns format strings, applied with the calendar `locale`. */
formats: {
monthTitle: string
/** Undefined = smart cross-month range via functions.formatTitle. */
weekTitle?: string
dayTitle: string
/** Undefined = smart range label via functions.formatTitle. */
agendaTitle?: string
monthDayHeader: string
/** Narrow variant used by the month view below the compact breakpoint. */
monthDayHeaderNarrow: string
timeGridDayHeader: string
agendaDayHeader: string
/** Agenda date-gutter day number. */
agendaDayNumber: string
/** Agenda date-gutter weekday label. */
agendaWeekday: string
/** "+N more" popover day header. */
moreDayHeader: string
/** Month cell aria-label date. */
monthCellAriaLabel: string
/** Time-grid day column aria-label date. */
dayAria: string
/** Undefined = the resource view title falls back to dayTitle. */
resourceTitle?: string
timeGutter: string
/** Sub-hour gutter labels (interval below 60 minutes). */
timeGutterMinute: string
eventTime: string
monthCellDay: string
}
functions: {
formatTitle: (
view: CalendarView,
ctx: {
date: Date
activeRange: EventCalendarDateRange
visibleRange: EventCalendarDateRange
locale?: Locale
}
) => string
formatEventTime: (
start: Date,
end: Date,
allDay: boolean,
/** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
opts?: { locale?: Locale }
) => string
formatDayRange: (
range: EventCalendarDateRange,
opts?: { locale?: Locale }
) => string
/** Chip native tooltip text; return undefined to drop the attribute. */
formatEventLabel?: (title: string, timeLabel: string) => string | undefined
/** Chip aria-label composition. */
formatEventAriaLabel?: (
title: string,
timeLabel: string,
continues: boolean
) => string
}
}
const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
today: "Today",
previous: "Previous",
next: "Next",
addEvent: "Add event",
allDay: "All day",
more: (count) => `+${count} more`,
noEvents: "No events",
loading: "Loading events",
event: "event",
events: (count) => (count === 1 ? "1 event" : `${count} events`),
selectView: "Select view",
week: (weekNumber) => `W${weekNumber}`,
resources: "Resources",
goToDate: "Go to date",
dropNotAllowed: "Can't place here",
continues: "continues",
timeFrom: (time) => `From ${time}`,
timeUntil: (time) => `Until ${time}`,
viewShortcuts: {
month: "M",
week: "W",
day: "D",
days: "5",
agenda: "A",
resource: "G",
},
toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
eventDetails: (title) => title,
moreCompact: (count) => `+${count}`,
timeRange: (from, to) => `${from} - ${to}`,
}
const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
month: "Month",
week: "Week",
day: "Day",
days: (count) => (count === 1 ? "1 day" : `${count} days`),
agenda: "Agenda",
resource: "Time Grid",
}
const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
monthTitle: "MMMM yyyy",
weekTitle: undefined,
dayTitle: "EEEE, MMMM d, yyyy",
agendaTitle: undefined,
monthDayHeader: "EEE",
monthDayHeaderNarrow: "EEEEE",
timeGridDayHeader: "EEE d",
agendaDayHeader: "EEEE, MMMM d",
agendaDayNumber: "d",
agendaWeekday: "EEE",
moreDayHeader: "EEEE, MMMM d",
monthCellAriaLabel: "PPPP",
dayAria: "PPPP",
resourceTitle: undefined,
timeGutter: "h a",
timeGutterMinute: "h:mm a",
eventTime: "h:mm a",
monthCellDay: "d",
}
/**
* Default formatting functions BOUND to a config's labels/formats, so that
* `formats` overrides flow into the default renderers (a consumer overriding
* formats.monthTitle without replacing formatTitle still sees it applied).
*/
function makeDefaultFunctions(
cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
): EventCalendarI18nConfig["functions"] {
return {
formatTitle: (view, { date, activeRange, locale }) => {
const opts = { locale }
if (view === "month") {
return format(date, cfg.formats.monthTitle, opts)
}
if (view === "resource") {
return format(
date,
cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
opts
)
}
if (view === "day") {
return format(date, cfg.formats.dayTitle, opts)
}
if (view === "week" && cfg.formats.weekTitle) {
return format(date, cfg.formats.weekTitle, opts)
}
if (view === "agenda" && cfg.formats.agendaTitle) {
return format(date, cfg.formats.agendaTitle, opts)
}
// week / days / agenda: smart range label, last day is activeRange.end - 1ms.
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
// would flip the label to the machine zone near midnight)
const rangeEnd = subMilliseconds(activeRange.end, 1)
const start = activeRange.start
if (isSameMonth(start, rangeEnd)) {
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
}
if (isSameYear(start, rangeEnd)) {
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
}
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
},
formatEventTime: (start, end, allDay, opts) => {
if (allDay) return cfg.labels.allDay
const fmt = cfg.formats.eventTime
// Multi-day timed events carry the date on both sides. Compare calendar
// days off the last rendered instant (end is exclusive, so a 14:00 to
// midnight event still ends on the start day). Elapsed ms would miss an
// exactly-24h event and a DST day that only runs 23 hours.
const lastInstant =
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
}
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
},
formatDayRange: (range, opts) => {
// subMilliseconds keeps the zoned date type, same reason as formatTitle
const rangeEnd = subMilliseconds(range.end, 1)
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
},
}
}
const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
labels: DEFAULT_LABELS,
viewNames: DEFAULT_VIEW_NAMES,
formats: DEFAULT_FORMATS,
functions: makeDefaultFunctions({
labels: DEFAULT_LABELS,
formats: DEFAULT_FORMATS,
}),
}
/**
* One level deeper than `Partial`, because the merge below is per nested
* section: overriding a single label must not force a consumer to restate the
* other 22. Unknown keys are still rejected by the excess property check.
*/
type EventCalendarI18nOverrides = {
[K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
}
/**
* Shallow merge per nested object, matching the filters.tsx i18n contract:
* a partial override replaces individual keys, never whole sections. Default
* functions are re-bound to the MERGED labels/formats; explicit `functions`
* overrides still win.
*/
function mergeEventCalendarI18n(
overrides?: EventCalendarI18nOverrides
): EventCalendarI18nConfig {
if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
const labels = { ...DEFAULT_LABELS, ...overrides.labels }
const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
return {
labels,
viewNames,
formats,
functions: {
...makeDefaultFunctions({ labels, formats }),
...overrides.functions,
},
}
}
export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }
src/reui/event-calendar/index.ts
// Примечание: lib.ts/recurrence.ts НЕ реэкспортируются целиком отсюда — это
// внутренняя чистая математика (buildEventIndex, expandRecurrence и т.п.),
// имена которой совпадают с одноимёнными помощниками reui/gantt (общее
// происхождение в апстриме). `export *` из обоих барелей дал бы коллизию в
// публичном API @revueui/ui (тот же приём, что уже применён в reui/kanban
// для useIsOverlay/IsOverlayContextKey). Остальные файлы event-calendar
// импортируют их напрямую через относительный путь ("./lib", "./recurrence").
export * from "./types"
export * from "./context"
export { default as EventCalendar } from "./EventCalendar.vue"
export { default as EventCalendarContent } from "./EventCalendarContent.vue"
export { default as EventCalendarNav } from "./EventCalendarNav.vue"
export { default as EventCalendarEvent } from "./EventCalendarEvent.vue"
export { default as EventCalendarMonthView } from "./EventCalendarMonthView.vue"
export { default as EventCalendarTimeGrid } from "./EventCalendarTimeGrid.vue"
export { default as EventCalendarWeekView } from "./EventCalendarWeekView.vue"
export { default as EventCalendarDayView } from "./EventCalendarDayView.vue"
export { default as EventCalendarDaysView } from "./EventCalendarDaysView.vue"
export { default as EventCalendarAgendaView } from "./EventCalendarAgendaView.vue"
export { default as EventCalendarResourceView } from "./EventCalendarResourceView.vue"
src/reui/event-calendar/lib.ts
// Title: Event Calendar Lib
// Description: Pure, framework-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.
// Port of ReUI event-calendar-lib.tsx (MIT) — pure TS/date-fns, ported verbatim.
import { expandRecurrence } from "./recurrence"
import type {
CalendarEvent,
CalendarView,
EventCalendarDateRange,
EventCalendarOccurrence,
EventCalendarOffDaysConfig,
EventCalendarResource,
EventCalendarSegment,
} from "./types"
import { TZDate } from "@date-fns/tz"
import {
addDays,
addMonths,
addWeeks,
differenceInCalendarDays,
differenceInMinutes,
format,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns"
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
const MIN_PACK_SLOT = 30
/** The instant re-expressed in the display time zone (TZDate extends Date). */
function toZoned(date: Date, timeZone: string): TZDate {
return new TZDate(date.getTime(), timeZone)
}
/** Zoned midnight of the day containing the instant. */
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
return startOfDay(toZoned(date, timeZone))
}
/** Stable per-day key in the display time zone. */
function getDayKey(date: Date, timeZone: string): string {
return format(toZoned(date, timeZone), "yyyy-MM-dd")
}
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
const next = zonedStartOfDay(
addDays(toZoned(dayStart, timeZone), 1),
timeZone
)
return differenceInMinutes(next, dayStart)
}
function snapMinutes(minutes: number, snap: number): number {
return Math.round(minutes / snap) * snap
}
interface ViewRangeOptions {
timeZone: string
weekStartsOn: WeekStartsOn
dayCount: number
agendaDayCount: number
fixedWeeks: boolean
}
interface ViewDateRanges {
visibleRange: EventCalendarDateRange
activeRange: EventCalendarDateRange
}
function getViewDateRange(
view: CalendarView,
date: Date,
opts: ViewRangeOptions
): ViewDateRanges {
const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
const zoned = toZoned(date, timeZone)
if (view === "month") {
const activeStart = startOfMonth(zoned)
const activeEnd = startOfMonth(addMonths(zoned, 1))
const visibleStart = startOfWeek(activeStart, { weekStartsOn })
let visibleEnd: Date
if (fixedWeeks) {
visibleEnd = addDays(visibleStart, 42)
} else {
visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
visibleEnd = addWeeks(visibleEnd, 1)
}
return {
activeRange: { start: activeStart, end: activeEnd },
visibleRange: { start: visibleStart, end: visibleEnd },
}
}
if (view === "week") {
const start = startOfWeek(zoned, { weekStartsOn })
const range = { start, end: addWeeks(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (view === "day" || view === "resource") {
const start = startOfDay(zoned)
const range = { start, end: addDays(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (view === "days") {
const start = startOfDay(zoned)
const range = { start, end: addDays(start, Math.max(1, dayCount)) }
return { activeRange: range, visibleRange: range }
}
// agenda
const start = startOfDay(zoned)
const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
return { activeRange: range, visibleRange: range }
}
/** Day of month of the last day of the month containing the zoned date. */
function lastDayOfZonedMonth(date: Date): number {
return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
}
/** The anchor date stepped one period forward or backward for the view. */
function stepDate(
view: CalendarView,
date: Date,
direction: 1 | -1,
opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
): Date {
const zoned = toZoned(date, opts.timeZone)
if (view === "month") {
const stepped = addMonths(zoned, direction)
// addMonths clamps the day down into a shorter month and never restores
// it, so next-then-prev from the 31st would leave the anchor on the 28th.
// Sticking a month end to the target month's end keeps stepping
// invertible, which matters because the anchor is what day and week view
// open on after a month navigation.
if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
}
if (view === "week") return addWeeks(zoned, direction)
if (view === "day" || view === "resource") return addDays(zoned, direction)
if (view === "days")
return addDays(zoned, direction * Math.max(1, opts.dayCount))
return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
}
function rangesIntersect(
a: EventCalendarDateRange,
b: EventCalendarDateRange
): boolean {
return a.start < b.end && a.end > b.start
}
function eventsOverlap(
a: { start: Date; end: Date },
b: { start: Date; end: Date }
): boolean {
return a.start < b.end && a.end > b.start
}
/**
* The one canonical multi-day segmentation. Splits an occurrence into per-day
* segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
* event ending exactly at zoned midnight emits NO segment for that day;
* zero-duration events emit one min-height segment; allDay occurrences walk
* the same absolute instants as timed ones and only drop startMin/endMin, so
* their bounds have to already BE display-zone midnights (see
* CalendarEvent.allDay) or the bar paints on the wrong days.
*/
function segmentOccurrence<TData>(
occurrence: EventCalendarOccurrence<TData>,
range: EventCalendarDateRange,
timeZone: string
): EventCalendarSegment<TData>[] {
const occStart = occurrence.start
const occEnd = occurrence.end
const isZeroLength = occEnd.getTime() === occStart.getTime()
const clampStart = occStart > range.start ? occStart : range.start
const clampEnd = occEnd < range.end ? occEnd : range.end
if (clampEnd < clampStart) return []
if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []
const segments: EventCalendarSegment<TData>[] = []
let cursor = zonedStartOfDay(clampStart, timeZone)
while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
const next = zonedStartOfDay(
addDays(toZoned(cursor, timeZone), 1),
timeZone
)
const segStart = clampStart > cursor ? clampStart : cursor
const segEnd = clampEnd < next ? clampEnd : next
const emptySeg = segEnd.getTime() <= segStart.getTime()
if (!emptySeg || isZeroLength) {
const isStart = segStart.getTime() === occStart.getTime()
const isEnd = segEnd.getTime() === occEnd.getTime()
segments.push({
occurrence,
day: cursor,
isStart,
isEnd,
continuesBefore: !isStart,
continuesAfter: !isEnd,
startMin: occurrence.allDay
? undefined
: differenceInMinutes(segStart, cursor),
endMin: occurrence.allDay
? undefined
: Math.max(
differenceInMinutes(segEnd, cursor),
differenceInMinutes(segStart, cursor)
),
})
}
if (isZeroLength) break
cursor = next
}
return segments
}
/** True when the occurrence should render as a bar (all-day row / month lanes). */
function isBarOccurrence(
occurrence: EventCalendarOccurrence,
timeZone?: string
): boolean {
return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
}
function spansMultipleDays(
occ: { start: Date; end: Date },
timeZone?: string
): boolean {
// An event ending exactly at the next midnight is still single-day
// (exclusive end), so compare against a strictly-later instant. The
// yardstick is the length of the day the event starts on, never a flat 24h:
// a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
// calendar day that belongs in the hour track, not in the all-day row.
// Without a display zone the dates answer in their own frame (TZDate) or in
// the host zone.
const dayStart = startOfDay(
timeZone ? toZoned(occ.start, timeZone) : occ.start
)
const nextDayStart = startOfDay(addDays(dayStart, 1))
return (
occ.end.getTime() - occ.start.getTime() >
nextDayStart.getTime() - dayStart.getTime()
)
}
interface PackedPosition {
column: number
columnCount: number
columnSpan: number
}
/**
* Google-style overlap packing for one day's timed segments.
* Mutates column/columnCount/columnSpan on the segments, in place.
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
*/
function packTimedSegments<TData>(
segments: EventCalendarSegment<TData>[]
): void {
if (segments.length === 0) return
type Working = {
seg: EventCalendarSegment<TData>
startMin: number
effEnd: number
}
const items: Working[] = segments
.map((seg) => {
const startMin = seg.startMin ?? 0
const endMin = seg.endMin ?? startMin
return {
seg,
startMin,
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
}
})
.sort(
(a, b) =>
a.startMin - b.startMin ||
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
// Sweep into connected clusters
const clusters: Working[][] = []
let current: Working[] = []
let clusterEnd = -Infinity
for (const item of items) {
if (item.startMin >= clusterEnd) {
current = []
clusters.push(current)
clusterEnd = -Infinity
}
current.push(item)
clusterEnd = Math.max(clusterEnd, item.effEnd)
}
for (const cluster of clusters) {
// Greedy column assignment
const colEnds: number[] = []
const byColumn = new Map<number, Working[]>()
for (const item of cluster) {
let col = colEnds.findIndex((end) => end <= item.startMin)
if (col === -1) {
col = colEnds.length
colEnds.push(0)
}
colEnds[col] = item.effEnd
item.seg.column = col
const bucket = byColumn.get(col) ?? []
bucket.push(item)
byColumn.set(col, bucket)
}
const columnCount = colEnds.length
// Partial-overlap expansion: widen rightward into free columns
for (const item of cluster) {
let span = 1
const col = item.seg.column ?? 0
while (col + span < columnCount) {
const occupants = byColumn.get(col + span) ?? []
const blocked = occupants.some(
(o) => o.startMin < item.effEnd && o.effEnd > item.startMin
)
if (blocked) break
span++
}
item.seg.columnCount = columnCount
item.seg.columnSpan = span
}
}
}
/**
* Greedy lane packing for bar segments within one week row (7 columns).
* Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
*/
/**
* Build the laned month-row bars for one week: consecutive-day segments of
* the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
* lanes. Returns NEW segment objects - the shared per-day segments (also
* rendered by the all-day rows and day cells) must stay pristine: mutating
* their isEnd/continues flags gave the first-day chip a whole-bar shape and
* a bogus end resize handle in the week all-day row, where dragging it
* collapsed the event to a single day.
*/
function packWeekRowLanes<TData>(
segments: EventCalendarSegment<TData>[],
rowIndex: number,
rowStart: Date,
timeZone: string
): EventCalendarSegment<TData>[] {
type Bar = {
seg: EventCalendarSegment<TData>
colStart: number
colSpan: number
isStart: boolean
isEnd: boolean
lane: number
}
const bars: Bar[] = segments.map((seg) => {
const dayIndex = Math.round(
(zonedStartOfDay(seg.day, timeZone).getTime() -
zonedStartOfDay(rowStart, timeZone).getTime()) /
(24 * 60 * 60 * 1000)
)
return {
seg,
colStart: Math.max(0, Math.min(6, dayIndex)),
colSpan: 1,
isStart: seg.isStart,
isEnd: seg.isEnd,
lane: 0,
}
})
// Merge consecutive-day segments of the same occurrence into one bar per row
const merged = new Map<string, Bar>()
for (const bar of bars) {
const key = bar.seg.occurrence.key
const existing = merged.get(key)
if (existing) {
const start = Math.min(existing.colStart, bar.colStart)
const end = Math.max(
existing.colStart + existing.colSpan,
bar.colStart + bar.colSpan
)
existing.colStart = start
existing.colSpan = end - start
existing.isStart = existing.isStart || bar.isStart
existing.isEnd = existing.isEnd || bar.isEnd
} else {
merged.set(key, bar)
}
}
const rowBars = Array.from(merged.values()).sort(
(a, b) =>
a.colStart - b.colStart ||
b.colSpan - a.colSpan ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
const lanes: boolean[][] = []
for (const bar of rowBars) {
let lane = 0
for (;;) {
const row = (lanes[lane] ??= new Array(7).fill(false))
let free = true
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
if (row[c]) {
free = false
break
}
}
if (free) break
lane++
}
const row = lanes[lane]!
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
row[c] = true
}
bar.lane = lane
}
return rowBars.map((bar) => ({
...bar.seg,
isStart: bar.isStart,
isEnd: bar.isEnd,
continuesBefore: !bar.isStart,
continuesAfter: !bar.isEnd,
lane: bar.lane,
rowIndex,
colStart: bar.colStart,
colSpan: bar.colSpan,
}))
}
interface EventCalendarDayBucket<TData = unknown> {
allDay: EventCalendarSegment<TData>[]
timed: EventCalendarSegment<TData>[]
}
interface EventCalendarWeekRow<TData = unknown> {
rowIndex: number
rowStart: Date
/** Laned bar segments (one per occurrence per row). */
bars: EventCalendarSegment<TData>[]
}
interface EventCalendarIndex<TData = unknown> {
occurrences: EventCalendarOccurrence<TData>[]
byDay: Map<string, EventCalendarDayBucket<TData>>
weekRows: EventCalendarWeekRow<TData>[]
}
interface BuildIndexOptions<TData> {
timeZone: string
weekStartsOn: WeekStartsOn
eventOrder?: (
a: EventCalendarOccurrence<TData>,
b: EventCalendarOccurrence<TData>
) => number
getOccurrences?: (
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
}
function defaultEventOrder(
a: EventCalendarOccurrence,
b: EventCalendarOccurrence
): number {
return (
a.start.getTime() - b.start.getTime() ||
b.end.getTime() -
b.start.getTime() -
(a.end.getTime() - a.start.getTime()) ||
a.key.localeCompare(b.key)
)
}
function buildEventIndex<TData>(
events: CalendarEvent<TData>[],
visibleRange: EventCalendarDateRange,
opts: BuildIndexOptions<TData>
): EventCalendarIndex<TData> {
const { timeZone, weekStartsOn } = opts
const order = opts.eventOrder ?? defaultEventOrder
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
// originalStart is an edited single occurrence of that series. The parent's
// expansion drops the replaced instant; the override renders as its own
// occurrence through the normal path below.
const overrideTimes = new Map<string, Set<number>>()
for (const event of events) {
if (!event.recurringEventId || !event.originalStart) continue
let times = overrideTimes.get(event.recurringEventId)
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
times.add(event.originalStart.getTime())
}
const occurrences: EventCalendarOccurrence<TData>[] = []
for (const event of events) {
const replaced = overrideTimes.get(event.id)
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
if (custom) {
custom.forEach((occ, i) => {
if (replaced?.has(occ.start.getTime())) return
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
return
occurrences.push({
key: `${event.id}::${occ.start.toISOString()}`,
eventId: event.id,
event,
start: occ.start,
end: occ.end,
allDay: event.allDay ?? false,
isRecurring: true,
recurrenceIndex: i,
})
})
continue
}
const expanded = expandRecurrence(event, visibleRange, { timeZone })
occurrences.push(
...(replaced
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
: expanded)
)
}
occurrences.sort(order)
const byDay = new Map<string, EventCalendarDayBucket<TData>>()
const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
weekStartsOn,
})
for (const occurrence of occurrences) {
const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
const bar = isBarOccurrence(occurrence, timeZone)
for (const seg of segments) {
const key = getDayKey(seg.day, timeZone)
let bucket = byDay.get(key)
if (!bucket) {
bucket = { allDay: [], timed: [] }
byDay.set(key, bucket)
}
if (bar) {
bucket.allDay.push(seg)
// calendar-day math, not a fixed 168h divisor: DST transition weeks
// are 167/169h long and the fixed divisor mis-buckets every later
// Sunday one row early (which then clamps into the wrong column)
const rowIndex = Math.floor(
differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
7
)
const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
rowBucket.push(seg)
barSegmentsByRow.set(rowIndex, rowBucket)
} else {
bucket.timed.push(seg)
}
}
}
for (const bucket of byDay.values()) {
packTimedSegments(bucket.timed)
}
const weekRows: EventCalendarWeekRow<TData>[] = []
for (const [rowIndex, segs] of barSegmentsByRow) {
const rowStart = addWeeks(firstRowStart, rowIndex)
weekRows.push({
rowIndex,
rowStart,
bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
})
}
weekRows.sort((a, b) => a.rowIndex - b.rowIndex)
return { occurrences, byDay, weekRows }
}
/** Cache key for index memoization; cheap string compare. */
function getRangeKey(range: EventCalendarDateRange): string {
return `${range.start.getTime()}-${range.end.getTime()}`
}
/** Depth-first flatten of the resource tree (parents included). */
function flattenResources(
resources: EventCalendarResource[],
depth = 0
): Array<{ resource: EventCalendarResource; depth: number }> {
const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
for (const resource of resources) {
rows.push({ resource, depth })
if (resource.children?.length) {
rows.push(...flattenResources(resource.children, depth + 1))
}
}
return rows
}
const DEFAULT_WEEKEND_DAYS = [0, 6]
/**
* Resolves whether a day is an off day (non-working) in the display zone.
* Callers pass the calendar's own weekendDays so the shading cannot contradict
* the weekend the rest of the calendar renders; an explicit offDays.weekendDays
* still wins over it.
*/
function resolveOffDay(
day: Date,
timeZone: string,
config: boolean | EventCalendarOffDaysConfig | undefined,
defaultWeekendDays?: number[]
): boolean {
if (!config) return false
const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
const weekendDays =
resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
const zoned = toZoned(day, timeZone)
if (weekendDays.includes(zoned.getDay())) return true
if (resolved.dates?.length) {
const key = getDayKey(day, timeZone)
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
return true
}
}
return resolved.isOffDay?.(day) ?? false
}
export {
buildEventIndex,
defaultEventOrder,
eventsOverlap,
flattenResources,
getDayKey,
getDayTotalMinutes,
getRangeKey,
getViewDateRange,
isBarOccurrence,
MIN_PACK_SLOT,
packTimedSegments,
packWeekRowLanes,
rangesIntersect,
resolveOffDay,
segmentOccurrence,
snapMinutes,
spansMultipleDays,
stepDate,
toZoned,
zonedStartOfDay,
}
export type {
BuildIndexOptions,
EventCalendarDayBucket,
EventCalendarIndex,
EventCalendarWeekRow,
PackedPosition,
ViewDateRanges,
ViewRangeOptions,
WeekStartsOn,
}
src/reui/event-calendar/recurrence.ts
// Title: Event Calendar Recurrence
// Description: RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.
// Port of ReUI event-calendar-recurrence.tsx (MIT) — pure TS/date-fns, ported verbatim.
import type {
CalendarEvent,
EventCalendarDateRange,
EventCalendarOccurrence,
EventCalendarRecurrenceRule,
EventCalendarWeekday,
} from "./types"
import { TZDate } from "@date-fns/tz"
import { addDays, addMonths, addWeeks, addYears } from "date-fns"
/** Guard: max window-intersecting occurrences per event per expansion. */
const MAX_OCCURRENCES = 1000
/** Runaway guard: absolute cap on period iterations regardless of visibility. */
const MAX_ITERATIONS = 10000
/** Gregorian mean period lengths for the O(1) fast-forward approximation. */
const PERIOD_MS: Record<EventCalendarRecurrenceRule["freq"], number> = {
daily: 86400000,
weekly: 604800000,
monthly: 2629746000, // 365.2425 / 12 days
yearly: 31556952000, // 365.2425 days
}
const WEEKDAYS: EventCalendarWeekday[] = [
"SU",
"MO",
"TU",
"WE",
"TH",
"FR",
"SA",
]
class EventCalendarRecurrenceError extends Error {
constructor(part: string) {
super(
`Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`
)
this.name = "EventCalendarRecurrenceError"
}
}
/**
* Parses a raw RRULE line (with or without the "RRULE:" prefix) into the
* structured subset. Pass the display time zone so Z-less UNTIL values are
* interpreted as wall time in that zone rather than the machine zone.
*/
function parseRRuleString(
input: string,
timeZone?: string
): EventCalendarRecurrenceRule {
const body = input.trim().replace(/^RRULE:/i, "")
const rule: Partial<EventCalendarRecurrenceRule> = {}
for (const pair of body.split(";")) {
if (!pair) continue
const [rawKey, rawValue] = pair.split("=")
const key = rawKey?.toUpperCase()
const value = rawValue ?? ""
switch (key) {
case "FREQ": {
const freq = value.toLowerCase()
if (
freq !== "daily" &&
freq !== "weekly" &&
freq !== "monthly" &&
freq !== "yearly"
) {
throw new EventCalendarRecurrenceError(`FREQ=${value}`)
}
rule.freq = freq
break
}
case "INTERVAL":
rule.interval = Math.max(1, parseInt(value, 10) || 1)
break
case "COUNT":
rule.count = Math.max(1, parseInt(value, 10) || 1)
break
case "UNTIL":
rule.until = parseRRuleDate(value, timeZone)
break
case "BYDAY":
rule.byWeekday = value.split(",").map((token) => {
// RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is
// already folded above - rejecting "mo" here would be inconsistent
const match = /^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(
token.trim().toUpperCase()
)
if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`)
const day = match[2] as EventCalendarWeekday
return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day
})
break
case "BYMONTHDAY":
rule.byMonthDay = value.split(",").map((v) => {
const day = parseInt(v, 10)
// NaN would survive parsing, match no day in any month and leave the
// event permanently invisible with no error anywhere
if (Number.isNaN(day)) {
throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`)
}
return day
})
break
case "BYMONTH":
rule.byMonth = value.split(",").map((v) => parseInt(v, 10))
break
case "WKST": {
const day = value.trim().toUpperCase() as EventCalendarWeekday
if (!WEEKDAYS.includes(day)) {
throw new EventCalendarRecurrenceError(`WKST=${value}`)
}
rule.weekStart = day
break
}
default:
throw new EventCalendarRecurrenceError(key ?? pair)
}
}
if (!rule.freq) throw new EventCalendarRecurrenceError("missing FREQ")
return rule as EventCalendarRecurrenceRule
}
function parseRRuleDate(value: string, timeZone?: string): Date {
// RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z
// designators are case-insensitive too (RFC 5545 3.1), so fold before matching.
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(
value.trim().toUpperCase()
)
if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
const [, y0, m0, d0, hh = "23", mm = "59", ss = "59", z] = match
// y/m/d are always captured (required groups in the regex above); TS can't
// see that, so assert non-null rather than widen the type everywhere below.
const y = y0!
const m = m0!
const d = d0!
// Z-less values (including date-only ones, which mean end of that day
// inclusive) are wall time in the display zone, not the machine zone.
const date = z
? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)
: timeZone
? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())
: new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`)
if (Number.isNaN(date.getTime())) {
throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
}
return date
}
/** Serializes the structured subset back to an RRULE line (without prefix). */
function formatRRuleString(rule: EventCalendarRecurrenceRule): string {
const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]
if (rule.interval && rule.interval > 1)
parts.push(`INTERVAL=${rule.interval}`)
if (rule.count) parts.push(`COUNT=${rule.count}`)
if (rule.until) {
const u = rule.until
const pad = (n: number) => String(n).padStart(2, "0")
parts.push(
`UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`
)
}
if (rule.byWeekday?.length) {
parts.push(
`BYDAY=${rule.byWeekday
.map((d) => (typeof d === "string" ? d : `${d.ordinal}${d.day}`))
.join(",")}`
)
}
if (rule.byMonthDay?.length)
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`)
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`)
if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)
return parts.join(";")
}
function resolveRule(
recurrence: EventCalendarRecurrenceRule | string,
timeZone?: string
): EventCalendarRecurrenceRule {
return typeof recurrence === "string"
? parseRRuleString(recurrence, timeZone)
: recurrence
}
/**
* Expands one event into its occurrences intersecting the range.
* Non-recurring events yield at most one occurrence. Recurrence iteration is
* wall-time based in the display zone (DST-safe day/week/month steps), and a
* span that is a whole number of local days keeps that day count across a DST
* transition (timed spans keep their absolute length instead).
*
* exDates remove exactly-matching instants (after COUNT numbering,
* Google-style: an exception still consumes its COUNT slot); rDates add extra
* instants with the same duration. RECURRENCE-ID override replacement lives
* in buildEventIndex, where the override event and its parent series meet.
*/
function expandRecurrence<TData>(
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
): EventCalendarOccurrence<TData>[] {
const allDay = event.allDay ?? false
if (!event.recurrence) {
// The exclusive `end > start` test is right for anything with duration,
// but it also drops a zero-length milestone pinned to the first visible
// instant - which reads as an event that randomly disappears until you
// page one period back. A point occurrence only has to be inside.
const isPoint = event.end.getTime() === event.start.getTime()
if (
event.start < range.end &&
(event.end > range.start || (isPoint && event.start >= range.start))
) {
return [
{
key: `${event.id}::${event.start.toISOString()}`,
eventId: event.id,
event,
start: event.start,
end: event.end,
allDay,
isRecurring: false,
},
]
}
return []
}
const rule = resolveRule(event.recurrence, ctx.timeZone)
const interval = Math.max(1, rule.interval ?? 1)
const durationMs = event.end.getTime() - event.start.getTime()
const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)
// A span of whole local days is wall time, not an absolute delta: a 3 day
// all-day bar crossing spring forward would otherwise end at 01:00 and
// occupy a fourth day in the month grid. Timed spans stay absolute so a two
// hour meeting is still two hours.
const daySpan = Math.round(durationMs / 86400000)
const wallDaySpan =
daySpan > 0 &&
addDays(zonedStart, daySpan).getTime() === event.end.getTime()
? daySpan
: null
const endFor = (start: Date): Date =>
wallDaySpan === null
? new Date(start.getTime() + durationMs)
: new Date(
addDays(
new TZDate(start.getTime(), ctx.timeZone),
wallDaySpan
).getTime()
)
// Excluded instants matched exactly; filtering happens at push time so an
// exception still consumes its COUNT slot (Google-style numbering).
const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))
const weeklyDays: number[] | null =
rule.freq === "weekly" && rule.byWeekday?.length
? [
...new Set(
rule.byWeekday.map((d) => {
if (typeof d !== "string") {
throw new EventCalendarRecurrenceError(
"BYDAY ordinal outside monthly/yearly"
)
}
return WEEKDAYS.indexOf(d)
})
),
]
: null
// BYDAY resolved inside a month: monthly, and yearly within each BYMONTH
// (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not "the anchor's day")
const monthlyByDay =
(rule.freq === "monthly" || rule.freq === "yearly") &&
rule.byWeekday?.length
? rule.byWeekday
: null
// RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides
// which selected days share a period, so an ignored WKST puts half of every
// biweekly series a week off. Default MO, per the RFC.
const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? "MO")
/** Days from the WKST-aligned week start to `day` (0-6). */
const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7
// yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month
const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? []
const yearlyMonths: number[] =
validByMonth.length > 0
? [...new Set(validByMonth)].sort((a, b) => a - b)
: [zonedStart.getMonth() + 1]
// month-shaped BY* parts make the per-period occurrence count variable
const hasMonthDayParts =
(rule.freq === "monthly" &&
Boolean(rule.byMonthDay?.length || monthlyByDay)) ||
(rule.freq === "yearly" &&
Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay))
const daysInMonth = (year: number, month: number) =>
new Date(year, month + 1, 0).getDate()
// RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the
// set instead of reshaping it. Dropping them silently expanded the series as
// if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering
// here rather than at push time keeps COUNT numbering RFC-correct: a
// candidate the filter removes is not an occurrence and consumes no slot.
const limitByMonth =
rule.freq !== "yearly" && validByMonth.length > 0 ? validByMonth : null
const limitByMonthDay =
rule.freq === "daily" && rule.byMonthDay?.length ? rule.byMonthDay : null
const limitByWeekday =
rule.freq === "daily" && rule.byWeekday?.length
? rule.byWeekday.map((d) =>
WEEKDAYS.indexOf(typeof d === "string" ? d : d.day)
)
: null
const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday)
const passesLimits = (candidate: TZDate): boolean => {
if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {
return false
}
if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {
return false
}
if (limitByMonthDay) {
const total = daysInMonth(candidate.getFullYear(), candidate.getMonth())
const day = candidate.getDate()
// negative BYMONTHDAY counts back from month end, as in monthDays
if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {
return false
}
}
return true
}
/** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */
const zonedDate = (year: number, month: number, day: number) =>
new TZDate(
year,
month,
day,
zonedStart.getHours(),
zonedStart.getMinutes(),
zonedStart.getSeconds(),
zonedStart.getMilliseconds(),
ctx.timeZone
)
/** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */
const monthDays = (year: number, month: number): number[] => {
const total = daysInMonth(year, month)
let days: number[] | null = null
if (rule.byMonthDay?.length) {
// negative BYMONTHDAY counts back from month end; nonexistent days skip
days = rule.byMonthDay
.map((n) => (n < 0 ? total + 1 + n : n))
.filter((n) => n >= 1 && n <= total)
}
if (monthlyByDay) {
const byDayMatches: number[] = []
for (const entry of monthlyByDay) {
const day = typeof entry === "string" ? entry : entry.day
const ordinal = typeof entry === "string" ? 0 : entry.ordinal
const weekday = WEEKDAYS.indexOf(day)
const matches: number[] = []
for (let d = 1; d <= total; d++) {
if (new Date(year, month, d).getDay() === weekday) matches.push(d)
}
if (ordinal === 0) {
byDayMatches.push(...matches) // plain BYDAY: every matching weekday
} else {
// 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip
const pick =
ordinal > 0
? matches[ordinal - 1]
: matches[matches.length + ordinal]
if (pick !== undefined) byDayMatches.push(pick)
}
}
days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches
}
if (!days) days = [Math.min(zonedStart.getDate(), total)] // clamp to month end
return [...new Set(days)].sort((a, b) => a - b)
}
/** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */
const periodCandidates = (period: number): TZDate[] => {
if (rule.freq === "daily") return [addDays(zonedStart, period * interval)]
if (rule.freq === "weekly") {
const base = addWeeks(zonedStart, period * interval)
if (!weeklyDays) return [base]
const week: TZDate[] = []
// walk the WKST-aligned week so candidates stay chronological
const baseOffset = fromWeekStart(base.getDay())
for (let offset = 0; offset < 7; offset++) {
const candidate = addDays(base, offset - baseOffset)
if (!weeklyDays.includes(candidate.getDay())) continue
// days of the DTSTART week before DTSTART are not part of the series
if (candidate.getTime() < zonedStart.getTime()) continue
week.push(candidate)
}
return week
}
if (rule.freq === "monthly") {
const anchor = addMonths(zonedStart, period * interval)
const year = anchor.getFullYear()
const month = anchor.getMonth()
return monthDays(year, month)
.map((day) => zonedDate(year, month, day))
.filter((c) => c.getTime() >= zonedStart.getTime())
}
// yearly
const year = addYears(zonedStart, period * interval).getFullYear()
const dates: TZDate[] = []
for (const month of yearlyMonths) {
for (const day of monthDays(year, month - 1)) {
dates.push(zonedDate(year, month - 1, day))
}
}
return dates.filter((c) => c.getTime() >= zonedStart.getTime())
}
/** Candidates of one period with the LIMIT filters applied. */
const candidatesFor = (period: number): TZDate[] =>
hasLimits
? periodCandidates(period).filter(passesLimits)
: periodCandidates(period)
/** Earliest/latest instant a period can produce - loop bounds without expanding it. */
const periodEdge = (period: number, edge: "first" | "last"): TZDate => {
if (rule.freq === "daily") return addDays(zonedStart, period * interval)
if (rule.freq === "weekly") {
const base = addWeeks(zonedStart, period * interval)
if (!weeklyDays) return base
return addDays(
base,
(edge === "first" ? 0 : 6) - fromWeekStart(base.getDay())
)
}
if (rule.freq === "monthly") {
const anchor = addMonths(zonedStart, period * interval)
const year = anchor.getFullYear()
const month = anchor.getMonth()
return zonedDate(
year,
month,
edge === "first" ? 1 : daysInMonth(year, month)
)
}
const year = addYears(zonedStart, period * interval).getFullYear()
const month =
// yearlyMonths always has >= 1 entry (empty BYMONTH falls back to the
// anchor's month above); TS can't see that through the index access.
(edge === "first"
? yearlyMonths[0]!
: yearlyMonths[yearlyMonths.length - 1]!) - 1
return zonedDate(
year,
month,
edge === "first" ? 1 : daysInMonth(year, month)
)
}
// O(1) fast-forward: land a couple of periods before the window instead of
// iterating from DTSTART, so years-old series still reach the visible range.
const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime()
const stepMs = PERIOD_MS[rule.freq] * interval
let startPeriod =
aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0
// mean-length drift is bounded well under one period - refine forward
while (
periodEdge(startPeriod, "last").getTime() + durationMs <=
range.start.getTime()
) {
startPeriod++
}
// series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact
let index = 0
if (startPeriod > 0) {
if (hasMonthDayParts || hasLimits) {
// per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT
// filter that empties a whole period) - sum them
for (let period = 0; period < startPeriod; period++) {
index += candidatesFor(period).length
}
} else if (weeklyDays) {
// week 0 only counts selected weekdays at/after DTSTART's, inside the
// WKST-aligned week that holds it
const anchorOffset = fromWeekStart(zonedStart.getDay())
const firstWeek = weeklyDays.filter(
(d) => fromWeekStart(d) >= anchorOffset
).length
index = firstWeek + (startPeriod - 1) * weeklyDays.length
} else {
index = startPeriod // one occurrence per period
}
}
const occurrences: EventCalendarOccurrence<TData>[] = []
const pushIfVisible = (rawStart: Date) => {
// normalize to a plain instant so consumers never receive zone-carrying
// TZDate instances (mixed-zone formatting bugs)
const start = new Date(rawStart.getTime())
if (exTimes.has(start.getTime())) return
const end = endFor(start)
if (start < range.end && end > range.start) {
occurrences.push({
key: `${event.id}::${start.toISOString()}`,
eventId: event.id,
event,
start,
end,
allDay,
isRecurring: true,
recurrenceIndex: index,
})
}
}
let iterations = 0
let period = startPeriod
while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {
iterations++
if (rule.count !== undefined && index >= rule.count) break
// whole-period bounds: never break on a mid-period weekday/day-of-month,
// so earlier candidates of the final period are still emitted
const earliest = periodEdge(period, "first")
if (earliest.getTime() >= range.end.getTime()) break
if (rule.until && earliest.getTime() > rule.until.getTime()) break
let ended = false
for (const candidate of candidatesFor(period)) {
if (rule.count !== undefined && index >= rule.count) {
ended = true
break
}
if (rule.until && candidate.getTime() > rule.until.getTime()) {
ended = true
break
}
pushIfVisible(candidate)
index++
if (occurrences.length >= MAX_OCCURRENCES) {
ended = true
break
}
}
if (ended) break
period++
}
// RDATE: extra instants join the set (deduped against generated starts and
// exclusions) with the same wall-time duration. Sorted so direct consumers
// still receive chronological order (buildEventIndex re-sorts regardless).
if (rule.rDates?.length) {
const seen = new Set(occurrences.map((o) => o.start.getTime()))
for (const rDate of rule.rDates) {
const start = new Date(rDate.getTime())
if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue
const end = endFor(start)
if (start >= range.end || end <= range.start) continue
seen.add(start.getTime())
occurrences.push({
key: `${event.id}::${start.toISOString()}`,
eventId: event.id,
event,
start,
end,
allDay,
isRecurring: true,
// keep counting past the generated instants: an RDATE with no index
// would fall back to undefined and break any consumer that identifies
// an instance by its position in the series
recurrenceIndex: index++,
})
}
occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())
}
return occurrences
}
export {
EventCalendarRecurrenceError,
expandRecurrence,
formatRRuleString,
MAX_OCCURRENCES,
parseRRuleString,
}
src/reui/event-calendar/types.ts
// Title: Event Calendar Types
// Description: Public TypeScript contract for the headless event calendar: events, occurrences, segments, state, and callbacks.
// Port of ReUI event-calendar-types.tsx (MIT) — pure TS, ported verbatim (framework-agnostic).
export type EventCalendarEventId = string
export type CalendarView = "month" | "week" | "day" | "days" | "agenda" | "resource"
/**
* A bookable resource (room, person, equipment). Nesting via children renders
* with children; the resource day view shows leaves as booking columns.
*/
export interface EventCalendarResource {
id: string
title: string
/** Token or css color used for subtle row/column accents. */
color?: string
children?: EventCalendarResource[]
}
export interface EventCalendarDateRange {
/** Inclusive instant. */
start: Date
/** Exclusive instant. */
end: Date
}
export type EventCalendarWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"
export interface EventCalendarRecurrenceRule {
freq: "daily" | "weekly" | "monthly" | "yearly"
interval?: number
count?: number
/** Inclusive instant. */
until?: Date
byWeekday?: Array<
EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }
>
byMonthDay?: number[]
byMonth?: number[]
weekStart?: EventCalendarWeekday
exDates?: Date[]
rDates?: Date[]
}
export interface CalendarEvent<TData = unknown> {
id: EventCalendarEventId
title: string
/** Plain instant; consumers parse ISO strings themselves. */
start: Date
/** Exclusive; must be >= start. */
end: Date
/**
* Start and end must be midnights in the calendar's display time zone:
* segmentation walks the raw instants, so a row stored at another zone's
* midnight paints on the wrong days.
*/
allDay?: boolean
/** Structured rule or a raw "RRULE:..." line. */
recurrence?: EventCalendarRecurrenceRule | string
/** This event is an edited single occurrence of that series. */
recurringEventId?: EventCalendarEventId
/** Which occurrence it replaces (RECURRENCE-ID semantics). */
originalStart?: Date
/** Token or css color; flows to the --ec-event-color css var. */
color?: string
/** Excluded from drag and resize regardless of interactions state. */
readOnly?: boolean
/** Per-event override; default comes from interactions.drag. */
draggable?: boolean
/** Per-event override; default comes from interactions.resize. */
resizable?: boolean
/** Packing prominence; feeds getEventPriority ordering. */
priority?: number
/** Explicit stacking override; wins over the computed z. */
zIndex?: number
/** Bookable resource this event belongs to (resource view). */
resourceId?: string
/** Consumer payload, fully generic. */
data?: TData
}
export interface EventCalendarOccurrence<TData = unknown> {
/** Stable per instance: `${event.id}::${startISO}`. */
key: string
eventId: EventCalendarEventId
event: CalendarEvent<TData>
start: Date
end: Date
allDay: boolean
isRecurring: boolean
recurrenceIndex?: number
}
export interface EventCalendarSegment<TData = unknown> {
occurrence: EventCalendarOccurrence<TData>
/** Zoned day this segment belongs to. */
day: Date
isStart: boolean
isEnd: boolean
continuesBefore: boolean
continuesAfter: boolean
/** Timed only: minutes from the zoned day start. */
startMin?: number
endMin?: number
/** Month / all-day lane index. */
lane?: number
/** Time-grid overlap packing. */
column?: number
columnCount?: number
columnSpan?: number
/** Week-row granularity (month bars / all-day lanes). */
rowIndex?: number
colStart?: number
colSpan?: number
}
export interface EventCalendarSelection {
eventKeys: string[]
/** Committed slot selection; see EventCalendarSlotDraft for the in-gesture value. */
slot: { start: Date; end: Date; allDay: boolean } | null
}
export interface EventCalendarInteractions {
drag: boolean
resize: boolean
selectSlot: boolean
}
export interface EventCalendarDragState<TData = unknown> {
kind: "move" | "resize-start" | "resize-end"
occurrence: EventCalendarOccurrence<TData>
proposedStart: Date
proposedEnd: Date
proposedAllDay: boolean
/**
* True when the proposal was resolved on the day-granular surface (month
* cells / the all-day lane) rather than minute columns - the all-day lane
* ghost keys on this, covering timed MULTI-DAY bars whose proposals stay
* timed (proposedAllDay alone misses them).
*/
proposedDayGranular: boolean
/** Target resource when dragging across resource columns/rows. */
proposedResourceId?: string
/** Last canDropEvent verdict; drives data-drop-invalid styling. */
valid: boolean
}
/**
* The in-progress drag-create rectangle ONLY, cleared on commit or cancel.
* The committed slot selection lives in EventCalendarSelection.slot.
*/
export interface EventCalendarSlotDraft {
start: Date
end: Date
allDay: boolean
view: CalendarView
/** Present when the slot was selected inside a resource column/row. */
resourceId?: string
}
/**
* User-adjustable display toggles (the "View settings" submenu). Every field
* is optional; undefined defers to the matching root view-config prop.
*/
export interface EventCalendarViewSettings {
/** Show Saturday/Sunday columns in month, week, and N-day grids. */
weekends?: boolean
/** Week-number gutter in the month view. */
weekNumbers?: boolean
nowIndicator?: boolean
/** Off-day (non-working) background marking. */
offDays?: boolean
}
export interface EventCalendarState<TData = unknown> {
view: CalendarView
/** Anchor date. */
date: Date
/** For the "days" view. */
dayCount: number
/** Full rendered grid incl. outside days - fetch remote data for THIS. */
visibleRange: EventCalendarDateRange
/** The logical period (the month/week itself). */
activeRange: EventCalendarDateRange
events: CalendarEvent<TData>[]
selection: EventCalendarSelection
interactions: EventCalendarInteractions
loading: boolean
drag: EventCalendarDragState<TData> | null
slotDraft: EventCalendarSlotDraft | null
viewSettings: EventCalendarViewSettings
}
export interface EventCalendarRangeInfo {
range: EventCalendarDateRange
activeRange: EventCalendarDateRange
view: CalendarView
date: Date
timeZone: string
}
export interface EventCalendarProposedUpdate<TData = unknown> {
event: CalendarEvent<TData>
/** null when source === "api". */
occurrence: EventCalendarOccurrence<TData> | null
start: Date
end: Date
allDay: boolean
/** Proposed resource when the gesture crossed resource columns/rows. */
resourceId?: string
source: "drag" | "resize-start" | "resize-end" | "keyboard" | "api"
}
/** false = reject/revert; void or true = accept; object = accept with adjustment. */
export type EventCalendarUpdateResult =
| boolean
| void
| { start?: Date; end?: Date; allDay?: boolean }
/** A click is a point, not a range; `end` is present for timed slots. */
export interface EventCalendarSlotInfo {
date: Date
end?: Date
allDay: boolean
view: CalendarView
/** Present when the click happened inside a resource column/row. */
resourceId?: string
}
/**
* Off-day marking (non-working days). `true` uses the defaults: weekends
* with a muted background. Custom weekday sets, explicit dates, a predicate,
* and a custom class are all supported; marked cells carry `data-off` for
* CSS-selector customization.
*/
export interface EventCalendarOffDaysConfig {
/** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */
weekendDays?: number[]
/** Additional explicit off dates (compared by day in the display zone). */
dates?: Date[]
/** Full custom predicate; runs in addition to weekendDays/dates. */
isOffDay?: (day: Date) => boolean
/** Marker classes; default "bg-muted/25". */
className?: string
}
/**
* External-data contract. v1 ships the type plus docs recipes (Google
* events.list / MS Graph calendarView map to CalendarEvent in ~15 lines);
* OAuth, tokens, and sync loops are application backend territory.
*/
export interface EventCalendarDataAdapter<TData = unknown> {
getEvents(
range: EventCalendarDateRange,
signal?: AbortSignal
): Promise<CalendarEvent<TData>[]>
}
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/event-calendar.jsonЗависимости реестра
npm-зависимости
- @date-fns/tz
- date-fns
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)