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.
- package/.playground/app.vue +51 -0
- package/.playground/nuxt.config.ts +3 -0
- package/components/elements/calendar-v2/ACalendarV2.vue +1150 -0
- package/components/elements/calendar-v2/ADatePickerV2.vue +508 -0
- package/components/elements/calendar-v2/README.md +127 -0
- package/components/elements/calendar-v2/date-utils.ts +217 -0
- package/components/elements/calendar-v2/presets.ts +58 -0
- package/components/elements/calendar-v2/types.ts +84 -0
- package/components/elements/select/ASelectV2.vue +32 -8
- package/components/elements/select/ui/select-trigger.vue +53 -10
- package/icons/download-2.vue +16 -0
- package/icons/download-rounded.vue +5 -4
- package/icons/play.vue +14 -0
- package/lang/en.ts +26 -3
- package/lang/kk.ts +26 -3
- package/lang/ru.ts +26 -3
- package/package.json +1 -1
|
@@ -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>
|