adata-ui 2.1.41-beta.21 → 2.1.41-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1150 @@
1
+ <script lang="ts" setup>
2
+ import type {
3
+ CalendarBaseProps,
4
+ CalendarDayCell,
5
+ CalendarModelValue,
6
+ CalendarPanel,
7
+ CalendarPreset,
8
+ CalendarSize,
9
+ CalendarUnitCell,
10
+ DateRange,
11
+ } from './types'
12
+ import { useResizeObserver } from '@vueuse/core'
13
+ import {
14
+ addDays,
15
+ addMonths,
16
+ addYears,
17
+ buildMonthMatrix,
18
+ clampDate,
19
+ compareDays,
20
+ daysInMonth,
21
+ endOfMonth,
22
+ endOfYear,
23
+ formatDate,
24
+ getISOWeek,
25
+ getMonthNames,
26
+ getWeekdayNames,
27
+ isDateDisabled,
28
+ isSameDay,
29
+ isSameMonth,
30
+ isWithinDays,
31
+ normalizeRange,
32
+ startOfDay,
33
+ startOfMonth,
34
+ startOfYear,
35
+ startOfYearPage,
36
+ toDate,
37
+ } from './date-utils'
38
+
39
+ defineOptions({ name: 'ACalendarV2' })
40
+
41
+ const props = withDefaults(defineProps<Props>(), {
42
+ mode: 'single',
43
+ precision: 'day',
44
+ locale: undefined,
45
+ min: null,
46
+ max: null,
47
+ disabledDates: undefined,
48
+ firstDayOfWeek: 1,
49
+ months: 1,
50
+ presets: undefined,
51
+ weekNumbers: false,
52
+ showTime: false,
53
+ minuteStep: 5,
54
+ size: 'md',
55
+ highlightWeekends: false,
56
+ showFooter: false,
57
+ })
58
+
59
+ const emit = defineEmits<{
60
+ (e: 'change', value: CalendarModelValue): void
61
+ (e: 'panelChange', value: Date): void
62
+ /** Пользователь завершил выбор: клик по дате в single, второй клик в range. */
63
+ (e: 'complete', value: CalendarModelValue): void
64
+ (e: 'clear'): void
65
+ }>()
66
+
67
+ interface Props extends CalendarBaseProps {
68
+ /** Подвал с «Очистить». Слот footer показывается и без этого флага. */
69
+ showFooter?: boolean
70
+ }
71
+
72
+ const model = defineModel<CalendarModelValue>({ default: null })
73
+
74
+ const { t, te, locale: appLocale } = useI18n()
75
+
76
+ const YEAR_PAGE_SIZE = 12
77
+ /** Сетки месяцев и годов — по три в ряд; от этого зависит шаг стрелок ↑/↓. */
78
+ const UNIT_COLUMNS = 3
79
+
80
+ const resolvedLocale = computed(() => props.locale ?? String(appLocale.value ?? 'ru'))
81
+ const minDate = computed(() => toDate(props.min))
82
+ const maxDate = computed(() => toDate(props.max))
83
+ const isRange = computed(() => props.mode === 'range')
84
+ const isMultiple = computed(() => props.mode === 'multiple')
85
+ const monthCount = computed(() => Math.max(1, Math.min(props.months, 2)))
86
+
87
+ /* ---------------------------------------------------------------- значение */
88
+
89
+ /** Выбранные даты плоским списком — на нём строится подсветка ячеек. */
90
+ const selectedDates = computed<Date[]>(() => {
91
+ const value = model.value
92
+ if (!value) return []
93
+ if (Array.isArray(value)) return value.filter(Boolean)
94
+ if (value instanceof Date) return [value]
95
+ return [value.start, value.end].filter(Boolean) as Date[]
96
+ })
97
+
98
+ const selectedRange = computed<DateRange>(() => {
99
+ const value = model.value
100
+ if (value && !Array.isArray(value) && !(value instanceof Date)) return value
101
+ return { start: null, end: null }
102
+ })
103
+
104
+ /* Первый клик периода: пока он есть, сетка подсвечивает «резинку» до курсора. */
105
+ const rangeAnchor = ref<Date | null>(null)
106
+ const hoverDate = ref<Date | null>(null)
107
+
108
+ /** Период, который надо подсветить: либо выбранный, либо тянущийся за курсором. */
109
+ const previewRange = computed<DateRange>(() => {
110
+ if (rangeAnchor.value) {
111
+ return normalizeRange({ start: rangeAnchor.value, end: hoverDate.value ?? rangeAnchor.value })
112
+ }
113
+ return normalizeRange(selectedRange.value)
114
+ })
115
+
116
+ /* ------------------------------------------------------------- навигация */
117
+
118
+ function initialFocusDate(): Date {
119
+ const first = selectedDates.value[0]
120
+ return startOfDay(first ? new Date(first) : clampDate(new Date(), minDate.value, maxDate.value))
121
+ }
122
+
123
+ const panelDate = ref<Date>(startOfMonth(initialFocusDate()))
124
+
125
+ const basePanel = computed<CalendarPanel>(() => {
126
+ if (props.precision === 'year') return 'years'
127
+ if (props.precision === 'month') return 'months'
128
+ return 'days'
129
+ })
130
+
131
+ const panel = ref<CalendarPanel>(basePanel.value)
132
+ const yearPageStart = ref(startOfYearPage(panelDate.value.getFullYear(), YEAR_PAGE_SIZE))
133
+
134
+ /* Клавиатурный фокус — отдельно от выбора: roving tabindex по сетке.
135
+ Стартует с выбранной даты либо с сегодня, чтобы Tab сразу попадал в нужное место. */
136
+ const focusedDate = ref<Date>(initialFocusDate())
137
+ /** Текущая (keyed) панель — старая, уходящая в анимации, сюда уже не попадает. */
138
+ const panelRef = ref<HTMLElement | null>(null)
139
+ /** Контейнер сетки дней — по нему меряется коробка панелей (см. panelBoxStyle). */
140
+ const daysGridRef = ref<HTMLElement | null>(null)
141
+ const daysPanelSize = ref<{ width: number, height: number } | null>(null)
142
+
143
+ /* Панель дней — самая крупная из трёх; пока она в DOM, запоминаем её размер,
144
+ а когда уходит (месяцы/годы), коробка держится по этому замеру. */
145
+ useResizeObserver(daysGridRef, (entries) => {
146
+ const target = entries[0]?.target as HTMLElement | undefined
147
+ if (!target) return
148
+ daysPanelSize.value = { width: target.offsetWidth, height: target.offsetHeight }
149
+ })
150
+
151
+ /**
152
+ * Анимация смены панели. Перелистывание — сдвиг в сторону движения,
153
+ * подъём к месяцам/годам — «отдаление», спуск к дням — «приближение».
154
+ * Каждая мутация навигации выставляет motion сама (см. setPanel*), последняя
155
+ * в тике выигрывает — так спуск «год → месяц → дни» даёт один zoom-in.
156
+ */
157
+ type PanelMotion = 'slide-left' | 'slide-right' | 'zoom-in' | 'zoom-out'
158
+ const motion = ref<PanelMotion>('zoom-in')
159
+
160
+ const PANEL_DEPTH: Record<CalendarPanel, number> = { days: 0, months: 1, years: 2 }
161
+
162
+ function setPanel(next: CalendarPanel) {
163
+ if (next === panel.value) return
164
+ motion.value = PANEL_DEPTH[next] > PANEL_DEPTH[panel.value] ? 'zoom-out' : 'zoom-in'
165
+ panel.value = next
166
+ }
167
+
168
+ function setPanelDate(date: Date) {
169
+ const next = startOfMonth(date)
170
+ if (isSameMonth(next, panelDate.value)) return
171
+ motion.value = next > panelDate.value ? 'slide-left' : 'slide-right'
172
+ panelDate.value = next
173
+ }
174
+
175
+ function setYearPage(start: number) {
176
+ if (start === yearPageStart.value) return
177
+ motion.value = start > yearPageStart.value ? 'slide-left' : 'slide-right'
178
+ yearPageStart.value = start
179
+ }
180
+
181
+ /** Ключ анимируемой панели: меняется ровно тогда, когда должна проиграться анимация. */
182
+ const panelKey = computed(() => {
183
+ if (panel.value === 'years') return `years:${yearPageStart.value}`
184
+ if (panel.value === 'months') return `months:${panelDate.value.getFullYear()}`
185
+ return `days:${panelDate.value.getFullYear()}-${panelDate.value.getMonth()}`
186
+ })
187
+
188
+ watch(basePanel, setPanel)
189
+
190
+ /* ------------------------------------------------------------------ сетка */
191
+
192
+ const weekdayNames = computed(() => getWeekdayNames(resolvedLocale.value, props.firstDayOfWeek))
193
+ const monthNames = computed(() => getMonthNames(resolvedLocale.value))
194
+ const shortMonthNames = computed(() => getMonthNames(resolvedLocale.value, 'short'))
195
+
196
+ const visibleMonths = computed(() =>
197
+ Array.from({ length: monthCount.value }, (_, index) => addMonths(panelDate.value, index)))
198
+
199
+ /* Перелистывание стрелками не должно терять клавиатурный фокус за кадром:
200
+ иначе в новом месяце ни одна ячейка не tabbable и Tab пролетает сетку. */
201
+ watch(panelDate, (value) => {
202
+ emit('panelChange', value)
203
+
204
+ if (visibleMonths.value.some(month => isSameMonth(focusedDate.value, month))) return
205
+
206
+ const day = Math.min(focusedDate.value.getDate(), daysInMonth(value.getFullYear(), value.getMonth()))
207
+ focusedDate.value = clampDate(new Date(value.getFullYear(), value.getMonth(), day), minDate.value, maxDate.value)
208
+ })
209
+
210
+ /* Внешняя смена значения (пресет, v-model снаружи) перематывает панель.
211
+ Клик по дате, которая и так видна, панель не двигает — иначе при двух
212
+ месяцах выбор во втором из них перепрыгивал бы вид. */
213
+ watch(model, () => {
214
+ const first = selectedDates.value[0]
215
+ if (!first) return
216
+ if (visibleMonths.value.some(month => isSameMonth(first, month))) return
217
+
218
+ setPanelDate(first)
219
+ yearPageStart.value = startOfYearPage(first.getFullYear(), YEAR_PAGE_SIZE)
220
+ focusedDate.value = startOfDay(first)
221
+ })
222
+
223
+ function dayDisabled(date: Date): boolean {
224
+ return isDateDisabled(date, {
225
+ min: minDate.value,
226
+ max: maxDate.value,
227
+ disabledDates: props.disabledDates,
228
+ })
229
+ }
230
+
231
+ function buildDayCell(date: Date, month: Date): CalendarDayCell {
232
+ const range = previewRange.value
233
+ const isRangeMode = isRange.value
234
+ const isStart = isRangeMode && isSameDay(date, range.start)
235
+ const isEnd = isRangeMode && isSameDay(date, range.end)
236
+
237
+ return {
238
+ date,
239
+ key: `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
240
+ label: date.getDate(),
241
+ isOutside: !isSameMonth(date, month),
242
+ isToday: isSameDay(date, new Date()),
243
+ isSelected: isRangeMode
244
+ ? isStart || isEnd
245
+ : selectedDates.value.some(item => isSameDay(item, date)),
246
+ isRangeStart: isStart,
247
+ isRangeEnd: isEnd,
248
+ isInRange: isRangeMode && !isStart && !isEnd && isWithinDays(date, range.start, range.end),
249
+ isDisabled: dayDisabled(date),
250
+ isWeekend: props.highlightWeekends && (date.getDay() === 0 || date.getDay() === 6),
251
+ isFocused: isSameDay(date, focusedDate.value),
252
+ }
253
+ }
254
+
255
+ const monthGrids = computed(() => visibleMonths.value.map((month) => {
256
+ const weeks = buildMonthMatrix(month.getFullYear(), month.getMonth(), props.firstDayOfWeek)
257
+ return {
258
+ month,
259
+ caption: `${monthNames.value[month.getMonth()]} ${month.getFullYear()}`,
260
+ weeks: weeks.map(week => ({
261
+ key: week[0].toISOString(),
262
+ weekNumber: getISOWeek(week[0]),
263
+ days: week.map(date => buildDayCell(date, month)),
264
+ })),
265
+ }
266
+ }))
267
+
268
+ const monthCells = computed<CalendarUnitCell[]>(() => monthNames.value.map((label, month) => {
269
+ const date = new Date(panelDate.value.getFullYear(), month, 1)
270
+ const disabled = (minDate.value && compareDays(endOfMonth(date), minDate.value) < 0)
271
+ || (maxDate.value && compareDays(date, maxDate.value) > 0)
272
+
273
+ return {
274
+ value: month,
275
+ label,
276
+ isSelected: selectedDates.value.some(item => isSameMonth(item, date)),
277
+ isCurrent: isSameMonth(date, new Date()),
278
+ isDisabled: Boolean(disabled),
279
+ isInRange: props.precision === 'month'
280
+ && isWithinDays(date, previewRange.value.start, previewRange.value.end),
281
+ }
282
+ }))
283
+
284
+ const yearCells = computed<CalendarUnitCell[]>(() =>
285
+ Array.from({ length: YEAR_PAGE_SIZE }, (_, index) => {
286
+ const year = yearPageStart.value + index
287
+ const date = new Date(year, 0, 1)
288
+ const disabled = (minDate.value && compareDays(endOfYear(date), minDate.value) < 0)
289
+ || (maxDate.value && compareDays(date, maxDate.value) > 0)
290
+
291
+ return {
292
+ value: year,
293
+ label: String(year),
294
+ isSelected: selectedDates.value.some(item => item.getFullYear() === year),
295
+ isCurrent: year === new Date().getFullYear(),
296
+ isDisabled: Boolean(disabled),
297
+ isInRange: props.precision === 'year'
298
+ && isWithinDays(date, previewRange.value.start, previewRange.value.end),
299
+ }
300
+ }))
301
+
302
+ /** Индекс единственной tabbable-ячейки сетки месяцев/годов: выбранная → текущая → первая доступная. */
303
+ function unitTabStop(cells: CalendarUnitCell[]): number {
304
+ const selected = cells.findIndex(cell => cell.isSelected && !cell.isDisabled)
305
+ if (selected >= 0) return selected
306
+ const current = cells.findIndex(cell => cell.isCurrent && !cell.isDisabled)
307
+ if (current >= 0) return current
308
+ return Math.max(0, cells.findIndex(cell => !cell.isDisabled))
309
+ }
310
+
311
+ const monthTabStop = computed(() => unitTabStop(monthCells.value))
312
+ const yearTabStop = computed(() => unitTabStop(yearCells.value))
313
+
314
+ const headerLabel = computed(() => {
315
+ if (panel.value === 'years') {
316
+ return `${yearPageStart.value} — ${yearPageStart.value + YEAR_PAGE_SIZE - 1}`
317
+ }
318
+ if (panel.value === 'months') return String(panelDate.value.getFullYear())
319
+ return monthGrids.value[0]?.caption ?? ''
320
+ })
321
+
322
+ /**
323
+ * Самые широкие варианты заголовка: «<месяц> <год>» для панели дней и диапазон
324
+ * для панели годов. Нужны как распорки — иначе ширина выпадашки прыгает и при
325
+ * перелистывании месяцев («май» против «сентября»), и при смене панели.
326
+ * Ширину меряет браузер, так что локаль значения не имеет.
327
+ */
328
+ const headerWidthProbes = computed(() => [
329
+ ...monthNames.value.map(name => `${name} 0000`),
330
+ `0000 — 0000`,
331
+ ])
332
+
333
+ /* ------------------------------------------------------------ перелистывание */
334
+
335
+ function shiftPanel(direction: 1 | -1) {
336
+ if (panel.value === 'years') {
337
+ setYearPage(yearPageStart.value + direction * YEAR_PAGE_SIZE)
338
+ return
339
+ }
340
+ if (panel.value === 'months') {
341
+ setPanelDate(addYears(panelDate.value, direction))
342
+ return
343
+ }
344
+ setPanelDate(addMonths(panelDate.value, direction))
345
+ }
346
+
347
+ function shiftPanelYear(direction: 1 | -1) {
348
+ if (panel.value === 'years') {
349
+ setYearPage(yearPageStart.value + direction * YEAR_PAGE_SIZE)
350
+ return
351
+ }
352
+ setPanelDate(addYears(panelDate.value, direction))
353
+ }
354
+
355
+ /** Заголовок ведёт на уровень выше: дни → месяцы → годы. */
356
+ function drillUp() {
357
+ if (panel.value === 'days') {
358
+ setPanel('months')
359
+ }
360
+ else if (panel.value === 'months') {
361
+ setYearPage(startOfYearPage(panelDate.value.getFullYear(), YEAR_PAGE_SIZE))
362
+ setPanel('years')
363
+ }
364
+ focusPanel()
365
+ }
366
+
367
+ /* ------------------------------------------------------------------ выбор */
368
+
369
+ /** Переносит время уже выбранного значения на новую дату. */
370
+ function withTimeOf(date: Date, source: Date | null): Date {
371
+ const next = startOfDay(date)
372
+ if (props.showTime && source) {
373
+ next.setHours(source.getHours(), source.getMinutes(), 0, 0)
374
+ }
375
+ return next
376
+ }
377
+
378
+ function commit(value: CalendarModelValue, complete: boolean) {
379
+ model.value = value
380
+ emit('change', value)
381
+ if (complete) emit('complete', value)
382
+ }
383
+
384
+ function selectDay(cell: CalendarDayCell) {
385
+ if (cell.isDisabled) return
386
+
387
+ focusedDate.value = new Date(cell.date)
388
+
389
+ if (isMultiple.value) {
390
+ const current = Array.isArray(model.value) ? model.value : []
391
+ const exists = current.some(item => isSameDay(item, cell.date))
392
+ const next = exists
393
+ ? current.filter(item => !isSameDay(item, cell.date))
394
+ : [...current, withTimeOf(cell.date, null)].sort((a, b) => compareDays(a, b))
395
+ commit(next, false)
396
+ return
397
+ }
398
+
399
+ if (isRange.value) {
400
+ /* Первый клик ставит якорь, второй закрывает период. Клик по уже готовому
401
+ периоду начинает новый — иначе непонятно, какой из концов двигать. */
402
+ if (!rangeAnchor.value) {
403
+ rangeAnchor.value = withTimeOf(cell.date, selectedRange.value.start)
404
+ commit({ start: rangeAnchor.value, end: null }, false)
405
+ return
406
+ }
407
+
408
+ const range = normalizeRange({
409
+ start: rangeAnchor.value,
410
+ end: withTimeOf(cell.date, selectedRange.value.end),
411
+ })
412
+ rangeAnchor.value = null
413
+ hoverDate.value = null
414
+ commit(range, true)
415
+ return
416
+ }
417
+
418
+ commit(withTimeOf(cell.date, model.value instanceof Date ? model.value : null), true)
419
+ }
420
+
421
+ function selectMonth(cell: CalendarUnitCell) {
422
+ if (cell.isDisabled) return
423
+
424
+ setPanelDate(new Date(panelDate.value.getFullYear(), cell.value, 1))
425
+
426
+ /* Точность «месяц» — выбор закончен; иначе просто спускаемся к дням. */
427
+ if (props.precision === 'month') {
428
+ selectDay(buildDayCell(startOfMonth(panelDate.value), panelDate.value))
429
+ return
430
+ }
431
+
432
+ setPanel('days')
433
+ focusedDate.value = clampDate(startOfMonth(panelDate.value), minDate.value, maxDate.value)
434
+ focusPanel()
435
+ }
436
+
437
+ function selectYear(cell: CalendarUnitCell) {
438
+ if (cell.isDisabled) return
439
+
440
+ setPanelDate(new Date(cell.value, panelDate.value.getMonth(), 1))
441
+
442
+ if (props.precision === 'year') {
443
+ selectDay(buildDayCell(startOfYear(panelDate.value), panelDate.value))
444
+ return
445
+ }
446
+
447
+ setPanel('months')
448
+ focusPanel()
449
+ }
450
+
451
+ function onDayHover(cell: CalendarDayCell) {
452
+ if (!isRange.value || cell.isDisabled) return
453
+ hoverDate.value = cell.date
454
+ }
455
+
456
+ function onGridLeave() {
457
+ hoverDate.value = null
458
+ }
459
+
460
+ /* -------------------------------------------------------------- клавиатура */
461
+
462
+ /**
463
+ * Переводит фокус на tabbable-ячейку текущей панели (после перерисовки).
464
+ * preventScroll обязателен: поповер может торчать за край окна, и focus()
465
+ * без него прокручивал бы страницу при каждой смене панели.
466
+ */
467
+ function focusPanel() {
468
+ nextTick(() => {
469
+ panelRef.value?.querySelector<HTMLElement>('[tabindex="0"]')?.focus({ preventScroll: true })
470
+ })
471
+ }
472
+
473
+ function moveFocus(days: number) {
474
+ const next = clampDate(addDays(focusedDate.value, days), minDate.value, maxDate.value)
475
+ focusedDate.value = next
476
+
477
+ if (!visibleMonths.value.some(month => isSameMonth(next, month))) {
478
+ setPanelDate(next)
479
+ }
480
+
481
+ focusPanel()
482
+ }
483
+
484
+ function onDaysKeydown(event: KeyboardEvent) {
485
+ const step: Record<string, number> = {
486
+ ArrowLeft: -1,
487
+ ArrowRight: 1,
488
+ ArrowUp: -7,
489
+ ArrowDown: 7,
490
+ }
491
+
492
+ if (event.key in step) {
493
+ event.preventDefault()
494
+ moveFocus(step[event.key])
495
+ return
496
+ }
497
+
498
+ if (event.key === 'Home' || event.key === 'End') {
499
+ event.preventDefault()
500
+ const offset = (focusedDate.value.getDay() - props.firstDayOfWeek + 7) % 7
501
+ moveFocus(event.key === 'Home' ? -offset : 6 - offset)
502
+ return
503
+ }
504
+
505
+ if (event.key === 'PageUp' || event.key === 'PageDown') {
506
+ event.preventDefault()
507
+ const direction = event.key === 'PageUp' ? -1 : 1
508
+ const next = event.shiftKey
509
+ ? addYears(focusedDate.value, direction)
510
+ : addMonths(focusedDate.value, direction)
511
+ focusedDate.value = clampDate(next, minDate.value, maxDate.value)
512
+ setPanelDate(focusedDate.value)
513
+ focusPanel()
514
+ return
515
+ }
516
+
517
+ if (event.key === 'Enter' || event.key === ' ') {
518
+ event.preventDefault()
519
+ const month = visibleMonths.value.find(item => isSameMonth(item, focusedDate.value)) ?? panelDate.value
520
+ selectDay(buildDayCell(focusedDate.value, month))
521
+ }
522
+ }
523
+
524
+ /* Стрелки по сетке месяцев/годов. За краем сетки — соседняя страница
525
+ (следующий год / следующая дюжина лет), фокус переносится на ту же позицию
526
+ с другого края. Enter/Space срабатывают как обычный клик по кнопке. */
527
+ function onUnitKeydown(event: KeyboardEvent) {
528
+ const step: Record<string, number> = {
529
+ ArrowLeft: -1,
530
+ ArrowRight: 1,
531
+ ArrowUp: -UNIT_COLUMNS,
532
+ ArrowDown: UNIT_COLUMNS,
533
+ }
534
+ if (!(event.key in step)) return
535
+
536
+ event.preventDefault()
537
+
538
+ const buttons = unitButtons()
539
+ const index = Math.max(0, buttons.indexOf(document.activeElement as HTMLButtonElement))
540
+ const next = index + step[event.key]
541
+
542
+ if (next < 0 || next >= buttons.length) {
543
+ shiftPanel(next < 0 ? -1 : 1)
544
+ const wrapped = (next + buttons.length) % buttons.length
545
+ nextTick(() => unitButtons()[wrapped]?.focus({ preventScroll: true }))
546
+ return
547
+ }
548
+
549
+ buttons[next]?.focus({ preventScroll: true })
550
+ }
551
+
552
+ function unitButtons(): HTMLButtonElement[] {
553
+ return Array.from(panelRef.value?.querySelectorAll<HTMLButtonElement>('[data-calendar-unit]') ?? [])
554
+ }
555
+
556
+ /* ----------------------------------------------------------------- время */
557
+
558
+ const showTimeControls = computed(() => props.showTime && !isMultiple.value)
559
+
560
+ const hourOptions = Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, '0'))
561
+ const minuteOptions = computed(() => {
562
+ const step = Math.max(1, Math.min(props.minuteStep, 60))
563
+ return Array.from({ length: Math.ceil(60 / step) }, (_, index) => String(index * step).padStart(2, '0'))
564
+ })
565
+
566
+ /** Контролы времени для single — один, для range — по одному на каждый конец. */
567
+ const timeTargets = computed<{ key: 'value' | 'start' | 'end', label: string, date: Date | null }[]>(() => {
568
+ if (!showTimeControls.value) return []
569
+
570
+ if (isRange.value) {
571
+ return [
572
+ { key: 'start', label: t('calendar.from'), date: selectedRange.value.start },
573
+ { key: 'end', label: t('calendar.to'), date: selectedRange.value.end },
574
+ ]
575
+ }
576
+
577
+ return [{ key: 'value', label: t('calendar.time'), date: model.value instanceof Date ? model.value : null }]
578
+ })
579
+
580
+ function setTime(target: 'value' | 'start' | 'end', unit: 'hours' | 'minutes', raw: string) {
581
+ const amount = Number(raw)
582
+
583
+ if (target === 'value') {
584
+ if (!(model.value instanceof Date)) return
585
+ const next = new Date(model.value)
586
+ if (unit === 'hours') next.setHours(amount)
587
+ else next.setMinutes(amount)
588
+ commit(next, false)
589
+ return
590
+ }
591
+
592
+ const range = selectedRange.value
593
+ const source = range[target]
594
+ if (!source) return
595
+
596
+ const next = new Date(source)
597
+ if (unit === 'hours') next.setHours(amount)
598
+ else next.setMinutes(amount)
599
+
600
+ commit({ ...range, [target]: next }, false)
601
+ }
602
+
603
+ /* ------------------------------------------------------------- действия */
604
+
605
+ function applyPreset(preset: CalendarPreset) {
606
+ rangeAnchor.value = null
607
+ hoverDate.value = null
608
+ commit(preset.value(), true)
609
+ }
610
+
611
+ function isPresetActive(preset: CalendarPreset): boolean {
612
+ const value = preset.value()
613
+ if (value instanceof Date) return isSameDay(value, selectedDates.value[0] ?? null)
614
+ if (Array.isArray(value)) return false
615
+
616
+ const range = selectedRange.value
617
+ return isSameDay(value.start, range.start) && isSameDay(value.end, range.end)
618
+ }
619
+
620
+ function clear() {
621
+ rangeAnchor.value = null
622
+ hoverDate.value = null
623
+ const empty: CalendarModelValue = isRange.value
624
+ ? { start: null, end: null }
625
+ : isMultiple.value ? [] : null
626
+ commit(empty, false)
627
+ emit('clear')
628
+ }
629
+
630
+ function localize(label: string) {
631
+ return te(label) ? t(label) : label
632
+ }
633
+
634
+ /* ------------------------------------------------------------------ стили */
635
+
636
+ /* Видимый фокус только с клавиатуры. outline, а не ring: ring занят рамкой
637
+ «сегодня», а на выбранной (залитой brand) ячейке ring того же цвета не виден. */
638
+ const FOCUS_RING = 'outline-hidden focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--border-color-focus)'
639
+
640
+ const NAV_BUTTON_CLASS = `flex size-8 shrink-0 items-center justify-center rounded-md text-icon-default transition hover:bg-action-secondary-hover hover:text-icon-strong ${FOCUS_RING}`
641
+
642
+ const FOOTER_BUTTON_CLASS = `rounded-md px-2 py-1 text-body-md transition-colors hover:bg-action-secondary-hover ${FOCUS_RING}`
643
+
644
+ const TIME_SELECT_CLASS = `rounded-md border border-default bg-canvas px-2 py-1 text-body-md text-primary disabled:text-disabled ${FOCUS_RING}`
645
+
646
+ const CELL_SIZE: Record<CalendarSize, string> = {
647
+ sm: 'size-8 text-body-sm',
648
+ md: 'size-9 text-body-md',
649
+ }
650
+
651
+ /** Сторона ячейки дня в px — должна совпадать с size-* из CELL_SIZE. */
652
+ const CELL_PX: Record<CalendarSize, number> = {
653
+ sm: 32,
654
+ md: 36,
655
+ }
656
+
657
+ /**
658
+ * Панели дней, месяцев и годов имеют разную натуральную величину, из-за чего
659
+ * выпадашка прыгала при переключении (289×375 → 218×279 → 199×279).
660
+ * Держим коробку по самой крупной — панели дней. Её размер берём из живого
661
+ * замера (daysPanelSize): так учитываются два месяца, подписи месяцев, колонка
662
+ * номеров недель и size. До первого замера (SSR, первый кадр) — расчёт для
663
+ * одного месяца: 7 колонок в ширину и 7 рядов в высоту (строка дней недели +
664
+ * всегда 6 недель из buildMonthMatrix).
665
+ */
666
+ const panelBoxStyle = computed(() => {
667
+ const measured = daysPanelSize.value
668
+ if (measured) return { minWidth: `${measured.width}px`, minHeight: `${measured.height}px` }
669
+
670
+ if (monthCount.value > 1 || props.weekNumbers) return undefined
671
+
672
+ const side = `${CELL_PX[props.size] * 7}px`
673
+ return { minWidth: side, minHeight: side }
674
+ })
675
+
676
+ function dayClass(cell: CalendarDayCell): string[] {
677
+ const classes = [
678
+ 'relative flex items-center justify-center transition-colors duration-100',
679
+ CELL_SIZE[props.size],
680
+ FOCUS_RING,
681
+ ]
682
+
683
+ if (cell.isDisabled) {
684
+ classes.push('cursor-not-allowed text-icon-disabled')
685
+ }
686
+ else if (cell.isSelected) {
687
+ classes.push('bg-action-primary font-semibold text-action-primary-fg')
688
+ }
689
+ else if (cell.isInRange) {
690
+ classes.push('bg-brand-subtle text-brand')
691
+ }
692
+ else if (cell.isOutside) {
693
+ classes.push('text-disabled hover:bg-action-secondary-hover')
694
+ }
695
+ else if (cell.isWeekend) {
696
+ classes.push('text-quaternary hover:bg-action-secondary-hover')
697
+ }
698
+ else {
699
+ classes.push('text-primary hover:bg-action-secondary-hover')
700
+ }
701
+
702
+ /* Внутри периода скругляются только его концы — середина сливается в полосу. */
703
+ if (cell.isInRange) classes.push('rounded-none')
704
+ else if (cell.isRangeStart && previewRange.value.end) classes.push('rounded-md rounded-r-none')
705
+ else if (cell.isRangeEnd && previewRange.value.start) classes.push('rounded-md rounded-l-none')
706
+ else classes.push('rounded-md')
707
+
708
+ if (cell.isToday && !cell.isSelected) {
709
+ classes.push('ring-1 ring-(--border-color-brand) ring-inset')
710
+ }
711
+
712
+ return classes
713
+ }
714
+
715
+ /* Ячейка месяца/года занимает весь слот сетки 3×4, растянутой на коробку панели
716
+ дней — так три панели одинаково заполняют пространство и переход между ними
717
+ не читается как «прыжок» контента. Подсветка при этом на внутренней
718
+ «таблетке» (unitLabelClass), а не на всём слоте. */
719
+ function unitClass(cell: CalendarUnitCell): string[] {
720
+ const classes = ['group flex items-center justify-center rounded-md', FOCUS_RING]
721
+ if (cell.isDisabled) classes.push('cursor-not-allowed')
722
+ return classes
723
+ }
724
+
725
+ function unitLabelClass(cell: CalendarUnitCell): string[] {
726
+ const classes = ['flex h-9 min-w-16 items-center justify-center rounded-md px-3 text-body-md capitalize transition-colors duration-100']
727
+
728
+ if (cell.isDisabled) classes.push('text-icon-disabled')
729
+ else if (cell.isSelected) classes.push('bg-action-primary font-semibold text-action-primary-fg')
730
+ else if (cell.isInRange) classes.push('bg-brand-subtle text-brand')
731
+ else classes.push('text-primary group-hover:bg-action-secondary-hover')
732
+
733
+ if (cell.isCurrent && !cell.isSelected) classes.push('ring-1 ring-(--border-color-brand) ring-inset')
734
+
735
+ return classes
736
+ }
737
+
738
+ defineExpose({
739
+ /** Перемотать панель на произвольный месяц — например, из родительской формы. */
740
+ goToDate(date: Date) {
741
+ setPanelDate(date)
742
+ focusedDate.value = startOfDay(date)
743
+ },
744
+ /** Перевести клавиатурный фокус в сетку (пикер делает это по ArrowDown из поля). */
745
+ focus: focusPanel,
746
+ clear,
747
+ })
748
+ </script>
749
+
750
+ <template>
751
+ <div class="calendar-v2 flex flex-col overflow-hidden rounded-lg border border-default bg-main sm:flex-row">
752
+ <!-- Быстрый выбор -->
753
+ <div
754
+ v-if="presets?.length"
755
+ class="flex shrink-0 gap-1 overflow-x-auto border-default p-2 max-sm:border-b sm:w-40 sm:flex-col sm:overflow-y-auto sm:border-r"
756
+ >
757
+ <button
758
+ v-for="preset in presets"
759
+ :key="preset.key"
760
+ type="button"
761
+ class="shrink-0 rounded-md px-3 py-1.5 text-start text-body-md whitespace-nowrap transition-colors duration-100"
762
+ :class="[
763
+ FOCUS_RING,
764
+ isPresetActive(preset)
765
+ ? 'bg-brand-subtle font-medium text-brand'
766
+ : 'text-secondary hover:bg-action-secondary-hover hover:text-primary',
767
+ ]"
768
+ :aria-pressed="isPresetActive(preset)"
769
+ @click="applyPreset(preset)"
770
+ >
771
+ {{ localize(preset.label) }}
772
+ </button>
773
+ </div>
774
+
775
+ <div class="flex min-w-0 flex-col gap-3 p-3">
776
+ <!-- Шапка: перелистывание и подъём на уровень выше -->
777
+ <div class="flex items-center gap-1">
778
+ <button
779
+ type="button"
780
+ :class="NAV_BUTTON_CLASS"
781
+ :aria-label="t('calendar.prevYear')"
782
+ @click="shiftPanelYear(-1)"
783
+ >
784
+ <a-icon-double-chevron-right class="size-4 rotate-180" />
785
+ </button>
786
+ <!-- На панели годов помесячные стрелки не нужны, но убирать их из потока
787
+ нельзя: шапка сузится и выпадашка снова начнёт прыгать по ширине.
788
+ Поэтому прячем визуально и отключаем — место остаётся. -->
789
+ <button
790
+ type="button"
791
+ :class="[NAV_BUTTON_CLASS, { 'pointer-events-none opacity-0': panel === 'years' }]"
792
+ :disabled="panel === 'years'"
793
+ :aria-label="t('calendar.prevMonth')"
794
+ @click="shiftPanel(-1)"
795
+ >
796
+ <a-icon-chevron-left class="size-4" />
797
+ </button>
798
+
799
+ <!-- Заголовок — кнопка «на уровень выше»: дни → месяцы → годы.
800
+ Шеврон нужен, чтобы это было видно: без него кликабельность
801
+ заголовка не читается. -->
802
+ <button
803
+ type="button"
804
+ class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5 text-body-md-strong text-primary capitalize transition-colors hover:bg-action-secondary-hover disabled:hover:bg-transparent"
805
+ :class="FOCUS_RING"
806
+ :disabled="panel === 'years'"
807
+ aria-live="polite"
808
+ @click="drillUp"
809
+ >
810
+ <span class="calendar-v2__stack grid min-w-0">
811
+ <transition name="calendar-v2-fade">
812
+ <span :key="headerLabel" class="truncate">{{ headerLabel }}</span>
813
+ </transition>
814
+ <!-- Распорки не видны и не занимают высоту, но держат ширину ячейки. -->
815
+ <span
816
+ v-for="probe in headerWidthProbes"
817
+ :key="probe"
818
+ aria-hidden="true"
819
+ class="invisible h-0 overflow-hidden whitespace-pre"
820
+ >{{ probe }}</span>
821
+ </span>
822
+ <a-icon-chevron-down
823
+ class="size-4 shrink-0 text-icon-default transition-opacity"
824
+ :class="{ 'opacity-0': panel === 'years' }"
825
+ />
826
+ </button>
827
+
828
+ <button
829
+ type="button"
830
+ :class="[NAV_BUTTON_CLASS, { 'pointer-events-none opacity-0': panel === 'years' }]"
831
+ :disabled="panel === 'years'"
832
+ :aria-label="t('calendar.nextMonth')"
833
+ @click="shiftPanel(1)"
834
+ >
835
+ <a-icon-chevron-right class="size-4" />
836
+ </button>
837
+ <button
838
+ type="button"
839
+ :class="NAV_BUTTON_CLASS"
840
+ :aria-label="t('calendar.nextYear')"
841
+ @click="shiftPanelYear(1)"
842
+ >
843
+ <a-icon-double-chevron-right class="size-4" />
844
+ </button>
845
+ </div>
846
+
847
+ <!-- Панели дней/месяцев/годов в общей коробке (см. panelBoxStyle).
848
+ grid + одна ячейка (calendar-v2__stack): уходящая и входящая панели
849
+ лежат друг на друге, пока идёт анимация, и не раздвигают раскладку. -->
850
+ <div class="calendar-v2__stack grid" :style="panelBoxStyle">
851
+ <transition :name="`calendar-v2-${motion}`">
852
+ <div
853
+ :key="panelKey"
854
+ ref="panelRef"
855
+ class="flex flex-col justify-center"
856
+ >
857
+ <!-- Сетка дней -->
858
+ <div
859
+ v-if="panel === 'days'"
860
+ ref="daysGridRef"
861
+ class="flex flex-col gap-4 self-center sm:flex-row"
862
+ @keydown="onDaysKeydown"
863
+ @mouseleave="onGridLeave"
864
+ >
865
+ <div
866
+ v-for="grid in monthGrids"
867
+ :key="grid.caption"
868
+ class="flex flex-col gap-1"
869
+ >
870
+ <div
871
+ v-if="monthCount > 1"
872
+ class="pb-1 text-center text-body-md-medium text-secondary capitalize"
873
+ >
874
+ {{ grid.caption }}
875
+ </div>
876
+
877
+ <div
878
+ role="grid"
879
+ :aria-label="grid.caption"
880
+ class="flex flex-col"
881
+ >
882
+ <div role="row" class="flex">
883
+ <div
884
+ v-if="weekNumbers"
885
+ class="flex items-center justify-center text-body-sm text-disabled"
886
+ :class="CELL_SIZE[size]"
887
+ >
888
+ #
889
+ </div>
890
+ <div
891
+ v-for="weekday in weekdayNames"
892
+ :key="weekday"
893
+ role="columnheader"
894
+ class="flex items-center justify-center text-body-sm text-quaternary capitalize"
895
+ :class="CELL_SIZE[size]"
896
+ >
897
+ {{ weekday }}
898
+ </div>
899
+ </div>
900
+
901
+ <div
902
+ v-for="week in grid.weeks"
903
+ :key="week.key"
904
+ role="row"
905
+ class="flex"
906
+ >
907
+ <div
908
+ v-if="weekNumbers"
909
+ class="flex items-center justify-center text-body-sm text-disabled"
910
+ :class="CELL_SIZE[size]"
911
+ >
912
+ {{ week.weekNumber }}
913
+ </div>
914
+
915
+ <button
916
+ v-for="day in week.days"
917
+ :key="day.key"
918
+ type="button"
919
+ role="gridcell"
920
+ :class="dayClass(day)"
921
+ :disabled="day.isDisabled"
922
+ :tabindex="day.isFocused ? 0 : -1"
923
+ :aria-selected="day.isSelected"
924
+ :aria-current="day.isToday ? 'date' : undefined"
925
+ :aria-label="formatDate(day.date, resolvedLocale, { dateStyle: 'long' })"
926
+ @click="selectDay(day)"
927
+ @mouseenter="onDayHover(day)"
928
+ @focus="focusedDate = day.date"
929
+ >
930
+ <slot name="day" :day="day">
931
+ {{ day.label }}
932
+ </slot>
933
+ </button>
934
+ </div>
935
+ </div>
936
+ </div>
937
+ </div>
938
+
939
+ <!-- Сетка месяцев -->
940
+ <div
941
+ v-else-if="panel === 'months'"
942
+ role="grid"
943
+ :aria-label="headerLabel"
944
+ class="grid flex-1 grid-cols-3 grid-rows-4 gap-1"
945
+ @keydown="onUnitKeydown"
946
+ >
947
+ <button
948
+ v-for="(cell, index) in monthCells"
949
+ :key="cell.value"
950
+ type="button"
951
+ role="gridcell"
952
+ data-calendar-unit
953
+ :class="unitClass(cell)"
954
+ :disabled="cell.isDisabled"
955
+ :tabindex="index === monthTabStop ? 0 : -1"
956
+ :aria-selected="cell.isSelected"
957
+ :aria-label="`${cell.label} ${panelDate.getFullYear()}`"
958
+ @click="selectMonth(cell)"
959
+ >
960
+ <span :class="unitLabelClass(cell)">{{ shortMonthNames[cell.value] }}</span>
961
+ </button>
962
+ </div>
963
+
964
+ <!-- Сетка лет -->
965
+ <div
966
+ v-else
967
+ role="grid"
968
+ :aria-label="headerLabel"
969
+ class="grid flex-1 grid-cols-3 grid-rows-4 gap-1"
970
+ @keydown="onUnitKeydown"
971
+ >
972
+ <button
973
+ v-for="(cell, index) in yearCells"
974
+ :key="cell.value"
975
+ type="button"
976
+ role="gridcell"
977
+ data-calendar-unit
978
+ :class="unitClass(cell)"
979
+ :disabled="cell.isDisabled"
980
+ :tabindex="index === yearTabStop ? 0 : -1"
981
+ :aria-selected="cell.isSelected"
982
+ @click="selectYear(cell)"
983
+ >
984
+ <span :class="unitLabelClass(cell)">{{ cell.label }}</span>
985
+ </button>
986
+ </div>
987
+ </div>
988
+ </transition>
989
+ </div>
990
+
991
+ <!-- Время -->
992
+ <div
993
+ v-if="showTimeControls"
994
+ class="flex flex-wrap items-center gap-3 border-t border-default pt-3"
995
+ >
996
+ <div
997
+ v-for="target in timeTargets"
998
+ :key="target.key"
999
+ class="flex items-center gap-2"
1000
+ >
1001
+ <span class="text-body-sm text-quaternary">{{ target.label }}</span>
1002
+ <div class="flex items-center gap-1">
1003
+ <select
1004
+ :class="TIME_SELECT_CLASS"
1005
+ :disabled="!target.date"
1006
+ :value="target.date ? String(target.date.getHours()).padStart(2, '0') : ''"
1007
+ :aria-label="t('calendar.hours')"
1008
+ @change="setTime(target.key, 'hours', ($event.target as HTMLSelectElement).value)"
1009
+ >
1010
+ <option
1011
+ v-for="hour in hourOptions"
1012
+ :key="hour"
1013
+ :value="hour"
1014
+ >
1015
+ {{ hour }}
1016
+ </option>
1017
+ </select>
1018
+ <span class="text-quaternary">:</span>
1019
+ <select
1020
+ :class="TIME_SELECT_CLASS"
1021
+ :disabled="!target.date"
1022
+ :value="target.date ? String(target.date.getMinutes()).padStart(2, '0') : ''"
1023
+ :aria-label="t('calendar.minutes')"
1024
+ @change="setTime(target.key, 'minutes', ($event.target as HTMLSelectElement).value)"
1025
+ >
1026
+ <option
1027
+ v-for="minute in minuteOptions"
1028
+ :key="minute"
1029
+ :value="minute"
1030
+ >
1031
+ {{ minute }}
1032
+ </option>
1033
+ </select>
1034
+ </div>
1035
+ </div>
1036
+ </div>
1037
+
1038
+ <!-- Подвал. Слот получает clear — чтобы обёртки (пикер) могли собрать
1039
+ свой набор кнопок, не дублируя логику очистки. -->
1040
+ <div
1041
+ v-if="showFooter || $slots.footer"
1042
+ class="flex items-center justify-end gap-2 border-t border-default pt-3"
1043
+ >
1044
+ <slot name="footer" :clear="clear">
1045
+ <button
1046
+ type="button"
1047
+ class="text-quaternary hover:text-primary"
1048
+ :class="[FOOTER_BUTTON_CLASS]"
1049
+ @click="clear"
1050
+ >
1051
+ {{ t('calendar.clear') }}
1052
+ </button>
1053
+ </slot>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+ </template>
1058
+
1059
+ <style scoped>
1060
+ /* Все дети стека — в одной grid-ячейке. Правило структурное, поэтому живёт
1061
+ здесь, а не в утилитах col-start-1/row-start-1: если консьюмер собирает
1062
+ Tailwind, не сканируя этот слой (например, импортирует CSS кита из другой
1063
+ версии в node_modules), утилиты не сгенерируются, панели встанут друг под
1064
+ другом и коробка начнёт прыгать по высоте на каждой смене панели. */
1065
+ .calendar-v2__stack > * {
1066
+ grid-area: 1 / 1;
1067
+ }
1068
+
1069
+ /* Смена панели: вход мягкий и чуть длиннее, уход быстрый. Уходящая панель
1070
+ не должна ловить клики, пока растворяется. */
1071
+ .calendar-v2-slide-left-enter-active,
1072
+ .calendar-v2-slide-right-enter-active,
1073
+ .calendar-v2-zoom-in-enter-active,
1074
+ .calendar-v2-zoom-out-enter-active {
1075
+ transition:
1076
+ opacity 180ms ease,
1077
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
1078
+ }
1079
+
1080
+ .calendar-v2-slide-left-leave-active,
1081
+ .calendar-v2-slide-right-leave-active,
1082
+ .calendar-v2-zoom-in-leave-active,
1083
+ .calendar-v2-zoom-out-leave-active {
1084
+ pointer-events: none;
1085
+ transition:
1086
+ opacity 120ms ease,
1087
+ transform 140ms cubic-bezier(0.4, 0, 1, 1);
1088
+ }
1089
+
1090
+ /* Вперёд: новое въезжает справа, старое уезжает влево. Назад — зеркально. */
1091
+ .calendar-v2-slide-left-enter-from,
1092
+ .calendar-v2-slide-right-leave-to {
1093
+ opacity: 0;
1094
+ transform: translateX(12px);
1095
+ }
1096
+
1097
+ .calendar-v2-slide-left-leave-to,
1098
+ .calendar-v2-slide-right-enter-from {
1099
+ opacity: 0;
1100
+ transform: translateX(-12px);
1101
+ }
1102
+
1103
+ /* Спуск к дням — «приближение», подъём к месяцам/годам — «отдаление». */
1104
+ .calendar-v2-zoom-in-enter-from,
1105
+ .calendar-v2-zoom-out-leave-to {
1106
+ opacity: 0;
1107
+ transform: scale(0.94);
1108
+ }
1109
+
1110
+ .calendar-v2-zoom-in-leave-to,
1111
+ .calendar-v2-zoom-out-enter-from {
1112
+ opacity: 0;
1113
+ transform: scale(1.04);
1114
+ }
1115
+
1116
+ /* Кроссфейд заголовка шапки: старая и новая подписи лежат в одной grid-ячейке. */
1117
+ .calendar-v2-fade-enter-active,
1118
+ .calendar-v2-fade-leave-active {
1119
+ transition: opacity 160ms ease;
1120
+ }
1121
+
1122
+ .calendar-v2-fade-enter-from,
1123
+ .calendar-v2-fade-leave-to {
1124
+ opacity: 0;
1125
+ }
1126
+
1127
+ @media (prefers-reduced-motion: reduce) {
1128
+ .calendar-v2-slide-left-enter-active,
1129
+ .calendar-v2-slide-right-enter-active,
1130
+ .calendar-v2-zoom-in-enter-active,
1131
+ .calendar-v2-zoom-out-enter-active,
1132
+ .calendar-v2-slide-left-leave-active,
1133
+ .calendar-v2-slide-right-leave-active,
1134
+ .calendar-v2-zoom-in-leave-active,
1135
+ .calendar-v2-zoom-out-leave-active {
1136
+ transition: opacity 120ms ease;
1137
+ }
1138
+
1139
+ .calendar-v2-slide-left-enter-from,
1140
+ .calendar-v2-slide-right-enter-from,
1141
+ .calendar-v2-zoom-in-enter-from,
1142
+ .calendar-v2-zoom-out-enter-from,
1143
+ .calendar-v2-slide-left-leave-to,
1144
+ .calendar-v2-slide-right-leave-to,
1145
+ .calendar-v2-zoom-in-leave-to,
1146
+ .calendar-v2-zoom-out-leave-to {
1147
+ transform: none;
1148
+ }
1149
+ }
1150
+ </style>