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,508 @@
1
+ <script lang="ts" setup>
2
+ import type { CalendarBaseProps, CalendarModelValue, DateRange } from './types'
3
+ import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
4
+ import { onClickOutside } from '@vueuse/core'
5
+ import ACalendarV2 from './ACalendarV2.vue'
6
+ import { formatDate, isSameDay, parseDisplayDate } from './date-utils'
7
+
8
+ defineOptions({ name: 'ADatePickerV2', inheritAttrs: false })
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ label: '',
12
+ placeholder: '',
13
+ required: false,
14
+ disabled: false,
15
+ clearable: true,
16
+ error: undefined,
17
+ variant: 'default',
18
+
19
+ mode: 'single',
20
+ precision: 'day',
21
+ locale: undefined,
22
+ min: null,
23
+ max: null,
24
+ disabledDates: undefined,
25
+ firstDayOfWeek: 1,
26
+ months: 1,
27
+ presets: undefined,
28
+ weekNumbers: false,
29
+ showTime: false,
30
+ minuteStep: 5,
31
+ size: 'md',
32
+ highlightWeekends: false,
33
+
34
+ closeOnSelect: true,
35
+ dropdownStrategy: 'absolute',
36
+ separator: ' — ',
37
+ displayFormat: undefined,
38
+ })
39
+
40
+ const emit = defineEmits<{
41
+ (e: 'change', value: CalendarModelValue): void
42
+ (e: 'open'): void
43
+ (e: 'close'): void
44
+ (e: 'clear'): void
45
+ }>()
46
+
47
+ /* Пропсы сетки наследуются из CalendarBaseProps и прокидываются в
48
+ <a-calendar-v2> как есть; здесь — только то, что относится к полю и поповеру. */
49
+ interface Props extends CalendarBaseProps {
50
+ label?: string
51
+ placeholder?: string
52
+ required?: boolean
53
+ disabled?: boolean
54
+ /** Крестик в поле (когда поле редактируемое) либо «Очистить» в подвале. */
55
+ clearable?: boolean
56
+ error?: string | string[]
57
+ variant?: 'default' | 'gray' | 'blue' | 'canvas'
58
+
59
+ /** Закрывать поповер, когда выбор завершён. */
60
+ closeOnSelect?: boolean
61
+ /** fixed нужен, когда поле лежит внутри контейнера с overflow или transform. */
62
+ dropdownStrategy?: 'absolute' | 'fixed'
63
+ /** Разделитель концов периода в поле. */
64
+ separator?: string
65
+ /** Формат отображения. По умолчанию — числовая дата локали. */
66
+ displayFormat?: Intl.DateTimeFormatOptions
67
+ }
68
+
69
+ const model = defineModel<CalendarModelValue>({ default: null })
70
+
71
+ const { t, locale: appLocale } = useI18n()
72
+
73
+ /* inheritAttrs: false — class/style должны попасть на корень пикера,
74
+ а не на внутренний инпут (иначе ломается позиционирование поповера). */
75
+ const attrs = useAttrs()
76
+ const rootAttrs = computed(() => ({
77
+ class: attrs.class as string | undefined,
78
+ style: attrs.style as string | undefined,
79
+ }))
80
+
81
+ const resolvedLocale = computed(() => props.locale ?? String(appLocale.value ?? 'ru'))
82
+
83
+ const dialogId = `date-picker-v2-${useId()}`
84
+
85
+ /* Вводить руками имеет смысл только одну дату: период и набор набираются
86
+ кликами, а разбор «01.01.2026 — 31.01.2026» из поля даёт больше
87
+ неоднозначностей, чем пользы. */
88
+ const isTypeable = computed(() => props.mode === 'single' && !props.showTime && props.precision === 'day')
89
+
90
+ /* ------------------------------------------------------------- поповер */
91
+
92
+ interface InputExposed {
93
+ focus: () => void
94
+ inputRef: HTMLInputElement | null
95
+ }
96
+
97
+ const isOpen = ref(false)
98
+ const rootRef = ref<HTMLElement | null>(null)
99
+ const floatingRef = ref<HTMLElement | null>(null)
100
+ const inputComponent = ref<InputExposed | null>(null)
101
+ const calendarRef = ref<InstanceType<typeof ACalendarV2> | null>(null)
102
+
103
+ /* Якорь — сама рамка поля, а не корень: под полем может быть текст ошибки,
104
+ и поповер не должен отъезжать вниз вместе с ним. */
105
+ const anchorRef = computed<HTMLElement | null>(() =>
106
+ inputComponent.value?.inputRef?.closest<HTMLElement>('.input-v2__field') ?? rootRef.value)
107
+
108
+ const { x, y, strategy, placement: resolvedPlacement } = useFloating(anchorRef, floatingRef, {
109
+ placement: 'bottom-start',
110
+ strategy: computed(() => props.dropdownStrategy),
111
+ /* autoUpdate живёт ровно пока смонтирован поповер — следит за скроллом,
112
+ ресайзом и сдвигом якоря без ручных watch/cleanup. */
113
+ whileElementsMounted: autoUpdate,
114
+ middleware: [offset(6), flip({ fallbackPlacements: ['top-start'] }), shift({ padding: 8 })],
115
+ })
116
+
117
+ /* Анимация раскрывается из угла, которым поповер прилегает к полю. */
118
+ const transformOrigin = computed(() => {
119
+ const [side, align] = resolvedPlacement.value.split('-')
120
+ return `${side === 'top' ? 'bottom' : 'top'} ${align === 'end' ? 'right' : 'left'}`
121
+ })
122
+
123
+ watch(isOpen, (open) => {
124
+ if (open) emit('open')
125
+ else emit('close')
126
+ })
127
+
128
+ onClickOutside(rootRef, () => {
129
+ if (isOpen.value) close()
130
+ }, { ignore: [floatingRef] })
131
+
132
+ /* ------------------------------------------------------------ отображение */
133
+
134
+ const dateFormat = computed<Intl.DateTimeFormatOptions>(() => {
135
+ if (props.displayFormat) return props.displayFormat
136
+ if (props.precision === 'year') return { year: 'numeric' }
137
+ if (props.precision === 'month') return { month: 'long', year: 'numeric' }
138
+ if (props.showTime) return { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }
139
+ return { day: '2-digit', month: '2-digit', year: 'numeric' }
140
+ })
141
+
142
+ function display(date: Date | null): string {
143
+ return formatDate(date, resolvedLocale.value, dateFormat.value)
144
+ }
145
+
146
+ const displayValue = computed<string>(() => {
147
+ const value = model.value
148
+ if (!value) return ''
149
+
150
+ if (value instanceof Date) return display(value)
151
+
152
+ if (Array.isArray(value)) return value.map(display).join(', ')
153
+
154
+ const { start, end } = value as DateRange
155
+ if (!start && !end) return ''
156
+ /* Выбран только первый конец периода — показываем, что ждём второй. */
157
+ if (start && !end) return `${display(start)}${props.separator}…`
158
+ if (start && end && isSameDay(start, end)) return display(start)
159
+ return `${display(start)}${props.separator}${display(end)}`
160
+ })
161
+
162
+ /* Черновик нужен только для ручного ввода: пока строка неполная, модель не
163
+ трогаем, иначе поле «дёргается» на каждой цифре. */
164
+ const draft = ref('')
165
+
166
+ const fieldValue = computed({
167
+ get: () => (isTypeable.value && draft.value !== '' ? draft.value : displayValue.value),
168
+ set: (value: string) => {
169
+ if (!isTypeable.value) return
170
+ draft.value = value
171
+
172
+ if (value === '') {
173
+ updateValue(null)
174
+ return
175
+ }
176
+
177
+ const parsed = parseDisplayDate(value)
178
+ if (parsed) updateValue(parsed)
179
+ },
180
+ })
181
+
182
+ watch(model, () => {
183
+ draft.value = ''
184
+ })
185
+
186
+ /* ---------------------------------------------------------------- подвал */
187
+
188
+ /* В этих режимах у выбора нет естественного конца — закрыть поповер иначе
189
+ можно только кликом мимо, поэтому даём кнопку «Готово». */
190
+ const needsDone = computed(() => props.showTime || props.mode === 'multiple' || !props.closeOnSelect)
191
+
192
+ /* Крестик в поле AInputV2 показывает только у редактируемого инпута; в
193
+ readonly-режимах (период, месяц, год, время) очистить можно лишь из подвала. */
194
+ const showClearInFooter = computed(() => props.clearable && !isTypeable.value)
195
+
196
+ const showFooter = computed(() => needsDone.value || showClearInFooter.value)
197
+
198
+ /* ---------------------------------------------------------------- события */
199
+
200
+ function updateValue(value: CalendarModelValue) {
201
+ model.value = value
202
+ emit('change', value)
203
+ }
204
+
205
+ function open() {
206
+ if (props.disabled || isOpen.value) return
207
+ isOpen.value = true
208
+ }
209
+
210
+ function close() {
211
+ isOpen.value = false
212
+ }
213
+
214
+ function toggle() {
215
+ if (isOpen.value) close()
216
+ else open()
217
+ }
218
+
219
+ /* Возврат фокуса в поле после закрытия не должен снова открывать поповер —
220
+ open-on-focus гасим на время программного focus(). Событие focus
221
+ синхронное, поэтому флага на один вызов достаточно. */
222
+ let skipFocusOpen = false
223
+
224
+ function focusInput() {
225
+ skipFocusOpen = true
226
+ inputComponent.value?.focus()
227
+ skipFocusOpen = false
228
+ }
229
+
230
+ /* Чем пользователь работает с поповером сейчас. После выбора мышью фокус в
231
+ поле не возвращаем: на телефоне это подняло бы клавиатуру поверх формы. */
232
+ let interaction: 'pointer' | 'keyboard' = 'pointer'
233
+
234
+ function closeAndRestoreFocus() {
235
+ close()
236
+ if (interaction === 'keyboard') focusInput()
237
+ }
238
+
239
+ /* Фокус от указателя не открывает поповер сам — этим занимается click,
240
+ иначе нажатие на readonly-поле открывало бы его в focus и тут же
241
+ закрывало в click. Флаг снимается после click текущего нажатия. */
242
+ let pointerDown = false
243
+
244
+ function onPointerDown() {
245
+ pointerDown = true
246
+ }
247
+
248
+ function onPointerUp() {
249
+ setTimeout(() => {
250
+ pointerDown = false
251
+ }, 0)
252
+ }
253
+
254
+ function onFocus() {
255
+ if (skipFocusOpen || pointerDown) return
256
+ interaction = 'keyboard'
257
+ open()
258
+ }
259
+
260
+ function onFieldClick() {
261
+ if (props.disabled) return
262
+ interaction = 'pointer'
263
+ /* Редактируемое поле: клик ставит курсор и не должен закрывать поповер. */
264
+ if (isTypeable.value) open()
265
+ else toggle()
266
+ }
267
+
268
+ function onToggleClick() {
269
+ interaction = 'pointer'
270
+ toggle()
271
+ if (isOpen.value && isTypeable.value) focusInput()
272
+ }
273
+
274
+ function onFocusOut(event: FocusEvent) {
275
+ const next = event.relatedTarget as Node | null
276
+ /* relatedTarget пуст при клике по нефокусируемому месту — это дело
277
+ onClickOutside, а не наше. */
278
+ if (!next) return
279
+ if (rootRef.value?.contains(next) || floatingRef.value?.contains(next)) return
280
+ close()
281
+ }
282
+
283
+ function onComplete() {
284
+ if (props.closeOnSelect) closeAndRestoreFocus()
285
+ }
286
+
287
+ function onClear() {
288
+ draft.value = ''
289
+ updateValue(props.mode === 'range' ? { start: null, end: null } : props.mode === 'multiple' ? [] : null)
290
+ emit('clear')
291
+ }
292
+
293
+ function onInputKeydown(event: KeyboardEvent) {
294
+ interaction = 'keyboard'
295
+
296
+ if (event.key === 'Escape') {
297
+ if (!isOpen.value) return
298
+ event.stopPropagation()
299
+ close()
300
+ return
301
+ }
302
+
303
+ if (event.key === 'ArrowDown') {
304
+ event.preventDefault()
305
+ if (isOpen.value) calendarRef.value?.focus()
306
+ else open()
307
+ return
308
+ }
309
+
310
+ if (event.key === 'Enter') {
311
+ if (!isOpen.value) {
312
+ event.preventDefault()
313
+ open()
314
+ }
315
+ else if (isTypeable.value) {
316
+ /* Дата набрана руками — Enter подтверждает и убирает поповер. */
317
+ close()
318
+ }
319
+ }
320
+ }
321
+
322
+ function onDropdownKeydown(event: KeyboardEvent) {
323
+ interaction = 'keyboard'
324
+
325
+ if (event.key === 'Escape') {
326
+ event.stopPropagation()
327
+ closeAndRestoreFocus()
328
+ }
329
+ }
330
+
331
+ defineExpose({ open, close, toggle, focus: focusInput })
332
+ </script>
333
+
334
+ <template>
335
+ <div
336
+ ref="rootRef"
337
+ class="date-picker-v2 relative w-full"
338
+ :class="rootAttrs.class"
339
+ :style="rootAttrs.style"
340
+ @pointerdown="onPointerDown"
341
+ @pointerup="onPointerUp"
342
+ @pointercancel="onPointerUp"
343
+ @click="onFieldClick"
344
+ @focusout="onFocusOut"
345
+ >
346
+ <a-input-v2
347
+ ref="inputComponent"
348
+ v-model="fieldValue"
349
+ :label="label"
350
+ :placeholder="placeholder"
351
+ :required="required"
352
+ :disabled="disabled"
353
+ :readonly="!isTypeable"
354
+ :clearable="clearable"
355
+ :error="error"
356
+ :size="size"
357
+ :variant="variant"
358
+ :mask="isTypeable ? '##.##.####' : undefined"
359
+ inputmode="numeric"
360
+ autocomplete="off"
361
+ aria-haspopup="dialog"
362
+ :aria-expanded="isOpen"
363
+ :aria-controls="isOpen ? dialogId : undefined"
364
+ @focus="onFocus"
365
+ @keydown="onInputKeydown"
366
+ @clear="onClear"
367
+ >
368
+ <!-- Иконка — настоящая кнопка: открыть/закрыть, не трогая курсор в поле.
369
+ mousedown гасим, чтобы AInputV2 не перевёл фокус в инпут раньше клика.
370
+ Без hover-состояния: выглядит как обычная иконка поля, а не как ещё один контрол. -->
371
+ <template #endButton="{ iconOffsetClass }">
372
+ <button
373
+ type="button"
374
+ tabindex="-1"
375
+ class="flex shrink-0 items-center justify-center text-icon-disabled disabled:pointer-events-none"
376
+ :class="iconOffsetClass"
377
+ :disabled="disabled"
378
+ :aria-label="t('calendar.open')"
379
+ @mousedown.prevent.stop
380
+ @click.stop="onToggleClick"
381
+ >
382
+ <a-icon-calendar class="size-4" />
383
+ </button>
384
+ </template>
385
+ </a-input-v2>
386
+
387
+ <teleport to="body">
388
+ <transition
389
+ enter-active-class="date-picker-v2__enter-active"
390
+ enter-from-class="date-picker-v2__enter-from"
391
+ enter-to-class="date-picker-v2__enter-to"
392
+ leave-active-class="date-picker-v2__leave-active"
393
+ leave-from-class="date-picker-v2__leave-from"
394
+ leave-to-class="date-picker-v2__leave-to"
395
+ >
396
+ <div
397
+ v-if="isOpen"
398
+ :id="dialogId"
399
+ ref="floatingRef"
400
+ role="dialog"
401
+ :aria-label="label || undefined"
402
+ class="date-picker-v2__dropdown z-10001 shadow-lg shadow-surface-dark/10 dark:shadow-surface-dark/30"
403
+ :style="{
404
+ position: strategy,
405
+ left: x != null ? `${x}px` : '',
406
+ top: y != null ? `${y}px` : '',
407
+ transformOrigin,
408
+ }"
409
+ @keydown="onDropdownKeydown"
410
+ @pointerdown="interaction = 'pointer'"
411
+ @focusout="onFocusOut"
412
+ >
413
+ <a-calendar-v2
414
+ ref="calendarRef"
415
+ v-model="model"
416
+ :mode="mode"
417
+ :precision="precision"
418
+ :locale="locale"
419
+ :min="min"
420
+ :max="max"
421
+ :disabled-dates="disabledDates"
422
+ :first-day-of-week="firstDayOfWeek"
423
+ :months="months"
424
+ :presets="presets"
425
+ :week-numbers="weekNumbers"
426
+ :show-time="showTime"
427
+ :minute-step="minuteStep"
428
+ :show-footer="showFooter"
429
+ :size="size"
430
+ :highlight-weekends="highlightWeekends"
431
+ @change="emit('change', $event)"
432
+ @complete="onComplete"
433
+ @clear="emit('clear')"
434
+ >
435
+ <template v-if="$slots.day" #day="slotProps">
436
+ <slot name="day" v-bind="slotProps" />
437
+ </template>
438
+ <template v-if="$slots.footer || showFooter" #footer="{ clear }">
439
+ <slot
440
+ name="footer"
441
+ :close="close"
442
+ :clear="clear"
443
+ >
444
+ <button
445
+ v-if="showClearInFooter"
446
+ type="button"
447
+ class="rounded-md px-2 py-1 text-body-md text-quaternary outline-hidden transition-colors hover:bg-action-secondary-hover hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--border-color-focus)"
448
+ @click="clear"
449
+ >
450
+ {{ t('calendar.clear') }}
451
+ </button>
452
+ <button
453
+ v-if="needsDone"
454
+ type="button"
455
+ class="ml-auto rounded-md px-3 py-1 text-body-md-medium text-brand outline-hidden transition-colors hover:bg-brand-subtle focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--border-color-focus)"
456
+ @click="closeAndRestoreFocus"
457
+ >
458
+ {{ t('calendar.done') }}
459
+ </button>
460
+ </slot>
461
+ </template>
462
+ </a-calendar-v2>
463
+ </div>
464
+ </transition>
465
+ </teleport>
466
+ </div>
467
+ </template>
468
+
469
+ <style>
470
+ /* Не scoped: поповер телепортирован в body. Кривые и тайминги — как у
471
+ ADropdownV2/ATooltipV2, чтобы все выпадашки ДС двигались одинаково. */
472
+ .date-picker-v2__enter-active {
473
+ transition:
474
+ opacity 200ms ease,
475
+ transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
476
+ }
477
+
478
+ .date-picker-v2__leave-active {
479
+ pointer-events: none;
480
+ transition:
481
+ opacity 140ms ease,
482
+ transform 160ms cubic-bezier(0.4, 0, 1, 1);
483
+ }
484
+
485
+ .date-picker-v2__enter-from,
486
+ .date-picker-v2__leave-to {
487
+ opacity: 0;
488
+ transform: translateY(-8px) scale(0.96);
489
+ }
490
+
491
+ .date-picker-v2__enter-to,
492
+ .date-picker-v2__leave-from {
493
+ opacity: 1;
494
+ transform: translateY(0) scale(1);
495
+ }
496
+
497
+ @media (prefers-reduced-motion: reduce) {
498
+ .date-picker-v2__enter-active,
499
+ .date-picker-v2__leave-active {
500
+ transition: opacity 120ms ease;
501
+ }
502
+
503
+ .date-picker-v2__enter-from,
504
+ .date-picker-v2__leave-to {
505
+ transform: none;
506
+ }
507
+ }
508
+ </style>
@@ -0,0 +1,127 @@
1
+ # Календарь v2
2
+
3
+ Два компонента поверх одной сетки, без сторонних date-библиотек: даты — нативный
4
+ `Date`, названия месяцев и дней недели — `Intl.DateTimeFormat` (локаль берётся из
5
+ i18n приложения, можно переопределить пропом `locale`).
6
+
7
+ | Компонент | Что это | Когда брать |
8
+ | --- | --- | --- |
9
+ | `<a-calendar-v2>` | Сама сетка: дни / месяцы / годы, период, набор дат, пресеты, время. Рендерится инлайн, там где стоит. | Календарь как часть страницы или своей выпадашки; дашборды, фильтры с постоянно видимым календарём. |
10
+ | `<a-date-picker-v2>` | Поле `<a-input-v2>` + поповер с `<a-calendar-v2>` внутри. Позиционирование — `@floating-ui/vue` (`flip`/`shift`/`autoUpdate`), поповер телепортирован в `body`. | Дата в форме. Это то, что нужно в 95 % случаев. |
11
+
12
+ Старый `<a-calendar>` (`components/elements/calendar/ACalendar.vue`) — обёртка над
13
+ `v-calendar` из модуля `@samk-dev/nuxt-vcalendar` с его собственным поповером на
14
+ Popper. В новом коде не использовать.
15
+
16
+ ## Быстрый старт
17
+
18
+ ```vue
19
+ <a-date-picker-v2 v-model="date" label="Дата выдачи" :max="new Date()" required />
20
+
21
+ <a-date-picker-v2
22
+ v-model="period"
23
+ mode="range"
24
+ :months="2"
25
+ :presets="getRangePresets()"
26
+ label="Период"
27
+ />
28
+
29
+ <a-date-picker-v2 v-model="month" precision="month" label="Месяц отчёта" />
30
+
31
+ <a-calendar-v2 v-model="date" highlight-weekends />
32
+ ```
33
+
34
+ ```ts
35
+ import type { CalendarModelValue, DateRange } from '#adata-ui/components/elements/calendar-v2/types'
36
+ import { toISODate } from '#adata-ui/components/elements/calendar-v2/date-utils'
37
+ import { getRangePresets } from '#adata-ui/components/elements/calendar-v2/presets'
38
+ ```
39
+
40
+ ## Значение (`v-model`)
41
+
42
+ Тип зависит от `mode`:
43
+
44
+ | `mode` | Тип модели | Пустое значение |
45
+ | --- | --- | --- |
46
+ | `single` (по умолчанию) | `Date \| null` | `null` |
47
+ | `range` | `{ start: Date \| null, end: Date \| null }` | `{ start: null, end: null }` |
48
+ | `multiple` | `Date[]` | `[]` |
49
+
50
+ Даты — локальная полночь (`00:00` в зоне пользователя). В API отдавать через
51
+ `toISODate(date)` → `'ГГГГ-ММ-ДД'`, а не `toISOString()`: последний сдвигает
52
+ дату на сутки для зон восточнее Гринвича.
53
+
54
+ В `range` после первого клика модель получает `{ start, end: null }` — форма
55
+ должна считать такое значение незавершённым.
56
+
57
+ ## Пропсы
58
+
59
+ Общие для обоих компонентов — интерфейс `CalendarBaseProps` в `types.ts`
60
+ (`mode`, `precision`, `min`/`max`, `disabledDates`, `firstDayOfWeek`, `months`,
61
+ `presets`, `weekNumbers`, `showTime`/`minuteStep`, `size`, `highlightWeekends`,
62
+ `locale`). Новый проп сетки добавляется туда один раз и появляется у обоих;
63
+ в шаблоне пикера его нужно пробросить в `<a-calendar-v2>`.
64
+
65
+ Только у пикера: всё про поле (`label`, `placeholder`, `required`, `disabled`,
66
+ `clearable`, `error`, `variant`), `closeOnSelect`, `dropdownStrategy`
67
+ (`fixed`, если поле внутри контейнера с `overflow`/`transform`), `separator`,
68
+ `displayFormat`.
69
+
70
+ Только у календаря: `showFooter` — подвал с «Очистить».
71
+
72
+ ## Поведение пикера
73
+
74
+ - **Ручной ввод** доступен только для `single` + `precision="day"` без времени:
75
+ маска `дд.мм.гггг`, модель обновляется, когда набрана существующая дата,
76
+ календарь при этом перематывается к ней. В остальных режимах поле readonly.
77
+ - **Открытие**: клик по полю или иконке, фокус с клавиатуры (Tab), `ArrowDown`,
78
+ `Enter`. Фокус от клика поповер сам не открывает — это делает click, иначе
79
+ readonly-поле открывалось бы и сразу закрывалось.
80
+ - **Закрытие**: завершённый выбор (если `closeOnSelect`), `Escape`, клик мимо,
81
+ уход фокуса за пределы поля и поповера (Tab), «Готово» в подвале.
82
+ Фокус возвращается в поле только если работали с клавиатуры — после выбора
83
+ мышью на телефоне это подняло бы клавиатуру.
84
+ - **Подвал**: «Очистить» показывается, когда крестика в поле нет (readonly-режимы);
85
+ «Готово» — когда у выбора нет естественного конца (`showTime`, `multiple`,
86
+ `closeOnSelect=false`). Свой подвал — слот `footer` с `{ close, clear }`.
87
+ - **Анимация**: раскрытие из угла, которым поповер прилегает к полю
88
+ (`transformOrigin` по фактическому placement от floating-ui); внутри — сдвиг
89
+ при перелистывании и zoom при подъёме/спуске между днями, месяцами и годами.
90
+ `prefers-reduced-motion` — только opacity.
91
+
92
+ ## Клавиатура в сетке
93
+
94
+ | Клавиши | Действие |
95
+ | --- | --- |
96
+ | `←` `→` `↑` `↓` | По дням (±1, ±7); по месяцам/годам (±1, ±3), за краем — соседняя страница |
97
+ | `Home` / `End` | Начало / конец недели |
98
+ | `PageUp` / `PageDown` | ±месяц, с `Shift` — ±год |
99
+ | `Enter` / `Space` | Выбрать |
100
+ | Заголовок (`Enter`) | На уровень выше: дни → месяцы → годы |
101
+ | `Escape` | Закрыть поповер |
102
+
103
+ Tab-стопов внутри сетки один (roving tabindex): выбранная дата, иначе сегодня.
104
+
105
+ ## Слоты
106
+
107
+ - `day="{ day: CalendarDayCell }"` — своя ячейка дня (метки событий, цены).
108
+ - `footer="{ close, clear }"` (пикер) / `footer="{ clear }"` (календарь).
109
+
110
+ ## Стили
111
+
112
+ Только семантические токены ДС: `bg-main`/`border-default` у коробки,
113
+ `bg-action-primary` + `text-action-primary-fg` у выбранного, `bg-brand-subtle` +
114
+ `text-brand` у периода и активного пресета, `hover:bg-action-secondary-hover`,
115
+ рамка «сегодня» — `ring-(--border-color-brand)`, фокус —
116
+ `outline-(--border-color-focus)`. Коробка панелей держится по размеру панели дней
117
+ (самой крупной): её меряет `ResizeObserver` (`daysPanelSize`), поэтому при
118
+ переходе к месяцам и годам выпадашка не прыгает и с двумя месяцами, и с номерами
119
+ недель; до первого замера работает расчёт из `CELL_PX` для одного месяца.
120
+
121
+ ## Файлы
122
+
123
+ - `ACalendarV2.vue` — сетка.
124
+ - `ADatePickerV2.vue` — поле + поповер.
125
+ - `types.ts` — типы модели, ячеек, `CalendarBaseProps`.
126
+ - `date-utils.ts` — чистые date-хелперы (без Vue, покрываются тестами).
127
+ - `presets.ts` — `getRangePresets()`: сегодня, вчера, 7/30 дней, месяц, прошлый месяц, год.