adata-ui 2.1.41-beta.21 → 2.1.41-beta.23
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/composables/useHeaderNavigationLinks.ts +14 -28
- package/icons/download-2.vue +16 -0
- package/icons/download-rounded.vue +5 -4
- package/icons/play.vue +14 -0
- package/lang/en.ts +34 -19
- package/lang/kk.ts +34 -19
- package/lang/ru.ts +34 -19
- package/package.json +1 -1
- package/shared/constans/pages.ts +4 -6
|
@@ -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 дней, месяц, прошлый месяц, год.
|