DateRangePicker
Період із пресетами над таблицею чи звітом — два місяці на десктопі, один на телефоні.
«Період: 01.08 — 17.08» над таблицею чи звітом. Найчастіший фільтр у CRM і єдиний, де користувач майже ніколи не хоче гортати календар: у дев'яти випадках із десяти йому потрібні «останні 30 днів».
Пресети ліворуч, а на телефоні — смугою над сіткою
- Обрано
- 1 серп. 2026 р. — 17 серп. 2026 р.
- Останній пресет
- —
Об'єкт, а не кортеж
v-model — це { start, end } | null. Кортеж [Date, Date] компактніший,
але range[0] у шаблоні не читається, а range.start читається. Конверсія
до кортежа, з яким працює UiCalendar, — один
рядок в обидва боки.
Половинчастого стану не буває: поки другий кінець невідомий, модель
лишається попередньою. Тому v-if="period" завжди чесний, і споживачу не
доводиться відповідати на питання «чи вважати порожнім об'єкт, у якого
заповнено лише start».
Пресети рахуються від «сьогодні»
DateRangePreset.range — це функція від сьогоднішньої дати, а не
готовий кортеж. Пресети, обчислені під час імпорту модуля, назавжди
застигли б на даті збірки; на прередереному сайті «Останні 7 днів» стали б
неправильними вже наступного дня.
Пресет, який не влазить у min/max, зі списку зникає. Запропонувати
«Останні 30 днів», коли min — десять днів тому, означає запропонувати
період, який сам календар відхилить.
Подія presetSelect існує окремо від update:modelValue, бо з самого
значення факт «користувач обрав пресет» не відновити: вручну зведений
однаковий період виглядає так само.
Два місяці — рішенням JS, а не класом
Кількість місяців перемикає matchMedia, а не md:hidden.
Прихований класом другий місяць лишається в DOM. Це 42 зайві gridcell
для скрінрідера і — головне — другий елемент із tabindex="0", який
руйнує roving tabindex першого: Tab починає зупинятись у календарі двічі.
Типове значення — один місяць. Прередер віддає мобільний варіант, і найвужчий клієнт отримує розмітку без стрибка після гідрації.
Нижче md пресети стають горизонтальною смугою над сіткою: колонка
пресетів (≈160px) поруч із місяцем (≈300px) не влазить у 375px екрана.
Смуга — ряд пігулок із межею, що прокручується вбік і виходить під краї
панелі: обрізаний край читається як «далі є ще». Ширину панелі обмежує
вікно — без цього max-content смуги (усі пресети в рядок) робив панель
690px завширшки на 375px телефоні.
Значення у формі
З name компонент кладе у форму прихований інпут зі значенням
2026-09-24/2026-09-30 — календарні дні за локальним часом.
toISOString() тут не годиться: він переводить локальну опівніч в UTC, і
для будь-якого поясу на схід від Гринвіча дата з'їжджає на вчорашню —
сервер, що бере перші десять символів, отримав би 2026-09-23.
API
Props
| Назва | Тип | Типово | Опис |
|---|---|---|---|
modelValue | DateRange | null | Обраний період або `null`. Через `v-model`. Об'єкт, а не кортеж: `range.start` у шаблоні читається, `range[0]` — ні. Половинчастого стану тут не буває: поки другий кінець невідомий, модель лишається попередньою. |
presets | DateRangePreset[] | defaultDateRangePresets | Пресети ліворуч від календаря. Порожній масив ховає колонку. |
displayFormat | shortlong | "short" | Формат періоду в тригері. |
placement | bottom-startbottom-endtop-starttop-end | "bottom-start" | Куди відкривати панель відносно поля. |
weekStartsOn | 0 | 1 | 1 | Перший день тижня. `1` — понеділок (uk). |
locale | string | "uk-UA" | Локаль підписів. |
size | FieldSize | "md" | Висота поля. На мобільному кожен розмір вищий за десктопний. |
placeholder | string | "Оберіть період" | — |
min | Date | — | Найраніша доступна дата включно. |
max | Date | — | Найпізніша доступна дата включно. |
disabledDate | (date: Date) => boolean | — | Які дати недоступні. Проксується в `UiCalendar`. |
today | Date | — | Що вважати «сьогодні» — для детермінізму прередеру й тестів. |
error | string | — | Текст помилки. Стан помилки вмикає САМА наявність тексту. |
hint | string | — | Підказка під полем. Ховається, коли показано помилку. |
label | string | — | — |
id | string | — | — |
name | string | — | Ім'я поля форми. Значення — локальні дати `YYYY-MM-DD/YYYY-MM-DD`. |
clearable | boolean | true | Хрестик, що скидає період. |
autoClose | boolean | true | Закривати панель, щойно обрано обидва кінці. |
disabled | boolean | — | — |
required | boolean | — | — |
Події
| Назва | Payload | Опис |
|---|---|---|
update:modelValue | [value: DateRange] | Обраний період або `null`. Використовуйте через `v-model`. |
presetSelect | [preset: DateRangePreset] | Обрано готовий пресет. Із самого значення це відновити неможливо - вручну зведений однаковий період виглядає так само. |
open | [] | Панель відкрито. |
close | [] | Панель закрито. |
Слоти
| Назва | Опис |
|---|---|
trigger | Власний тригер. `text` — уже відформатований період або плейсхолдер. |
preset | Власний рендер пресету. |
footer | Рядок під календарем: «Скасувати», «Застосувати». |
Доступно через ref
| Назва | Тип | Опис |
|---|---|---|
open | () => void | Відкриває панель. |
close | (returnFocus?: boolean) => void | Закриває панель. |
focus | () => void | Ставить фокус на тригер. |
Коли використовувати
- Фільтр періоду над таблицею, звітом або дашбордом.
- Будь-де, де «останні 30 днів» — типова відповідь.
- Порівняння періодів, якщо потрібні два таких поля поруч.
Коли НЕ використовувати
- Для однієї дати —
UiDatePickerабоUiCalendarу режиміsingle. - Коли календар має бути видимий завжди — беріть
UiCalendarнапряму. - Коли періодів лише кілька фіксованих: тоді це
UiToggleGroupбез календаря взагалі.
Доступність
Тригер — кнопка з aria-haspopup="dialog", aria-expanded і
aria-controls. Панель має role="dialog" і власну назву.
Пастки фокуса тут немає навмисно. Панель немодальна, тієї самої родини,
що UiPopover і
UiMenu; пастка зламала б Tab до наступного поля
форми. Замість неї — закриття по виходу фокуса за межі тригера й панелі,
по кліку зовні та по Escape із поверненням фокуса на тригер.
Відкриття переносить фокус у календар — на обрану дату, початок діапазону
чи сьогодні. Без цього панель була недосяжна з клавіатури: вона
телепортована в кінець <body>, тож Tab із тригера вів до наступного поля
форми, а вихід фокуса закривав панель. На краях панелі Tab повертає фокус
на тригер: Shift+Tab із першого елемента зупиняється на ньому, а Tab з
останнього йде далі вже від тригера — до наступного поля, а не за межі
сторінки. Після вибору періоду чи пресету фокус так само повертається на
тригер.
На телефоні пресети — смуга, що гортається вбік. Tab докручує її до пресету цілим: браузер докручує лише пресет, схований повністю, а частково видимий лишав обрізаним краєм панелі. Тап смугу не зсуває — пресет не тікає з-під пальця.
Escape обробляється на самому компоненті зі stopPropagation. Слухач на
document не міг зупинити слухача UiModal на
тому ж document, і всередині модалки один Escape закривав і панель, і
модалку.
Z-index береться з getOverlayChildZIndex, тож панель коректно лягає
поверх модалки, якщо фільтр живе всередині неї.
Уся клавіатура сітки — з UiCalendar.