Calendar
Вбудована сітка місяця з вибором дати або діапазону і повним клавіатурним контрактом grid.
Сітка місяця, яку видно завжди. Не заміна
UiDatePicker: той лишається тонкою
обгорткою над нативним <input type="date"> і лишається типовою порадою
для однієї дати у формі.
UiCalendar існує для того, чого нативний пікер не вміє: діапазони,
предикат недоступних днів, два місяці поруч і завжди відкрита сітка.
| Номер тижня | |||||||
|---|---|---|---|---|---|---|---|
| 31 | |||||||
| 32 | |||||||
| 33 | |||||||
| 34 | |||||||
| 35 | |||||||
| 36 |
Обрано: 17 серпня 2026 р.
Дати без бібліотеки дат
Бібліотека не має зовнішніх залежностей, тож уся арифметика — у
app/utils/calendar.ts. Там діє одне жорстке правило: нова дата
будується конструктором new Date(рік, місяць, день), ніколи додаванням
мілісекунд.
date.getTime() + 86_400_000 — це не «наступний день», а «рівно 24
години». У ніч переходу на зимовий час доба триває 25 годин, і такий
«наступний день» лишається у вчорашньому; навесні — перестрибує. Сітка
місяця тоді втрачає або дублює день двічі на рік, і відтворити це
можна лише в ті самі вихідні.
Друга пастка того ж роду — setMonth. Для 31 січня плюс місяць він дає
3 березня, бо 31 лютого не існує. addMonths затискає день до останнього в
місяці, інакше PageDown із січня вів би в березень, оминаючи лютий.
Завжди шість рядків
Сітка — це завжди 42 комірки. Місяць, що вміщається у п'ять тижнів, усе одно малює шість рядків.
Причина не косметична: змінна висота панелі під час гортання всередині
закріпленого попапа щоразу перезапускає логіку перевороту, і панель видимо
стрибає над полем і під нього. З showOutsideDays: false зайві дні
лишаються порожніми комірками, а не зникають із DOM — role="grid"
вимагає однакової кількості клітинок у кожному рядку.
Клавіатура
Період: 10 серп. 2026 р. — 19 серп. 2026 р.
Рівно один день у всьому віджеті має tabindex="0" — той, що у фокусі;
решта отримують -1. Початковий фокус: обрана дата → початок діапазону →
сьогодні (якщо воно в показаному місяці) → перший доступний день.
| Клавіші | Дія |
|---|---|
← → | ∓1 день |
↑ ↓ | ∓1 тиждень |
Home End | початок і кінець тижня, у якому фокус |
PageUp PageDown | ∓1 місяць |
Shift+PageUp Shift+PageDown | ∓1 рік |
Enter Space | обрати день |
Escape | скасувати незавершений діапазон |
Вихід фокуса за межі показаних місяців гортає їх сам і емітить
update:month. Рух затискається по min/max, а не блокується: інакше
межа перетворюється на невидиму стіну, з якої не зрозуміло, як вибратись.
Недоступні дні позначені aria-disabled, а не атрибутом disabled —
інакше стрілками неможливо перескочити заблокований тиждень.
Escape обробляється лише тоді, коли є що скасовувати. Якщо незавершеного
діапазону немає, подія йде далі — і панель, у якій календар лежить,
закривається сама.
API
Props
| Назва | Тип | Типово | Опис |
|---|---|---|---|
ariaLabel | string | "Календар" | Доступна назва сітки, коли поруч немає видимого заголовка. |
mode | singlerange | "single" | Одна дата чи діапазон. |
months | 1 | 2 | 1 | Скільки місяців показати поруч. |
weekStartsOn | 0 | 1 | 1 | Перший день тижня. `1` — понеділок (uk), `0` — неділя. |
locale | string | "uk-UA" | Локаль назв місяців і днів тижня. |
modelValue | Date | [Date, Date] | — | Обрана дата (`single`) або діапазон (`range`). Через `v-model`. У режимі `range` НАПІВобраний діапазон назовні не емітиться: перший клік живе у внутрішньому стані, а модель оновлюється лише коли відомі обидва кінці. Тому споживач ніколи не отримує кортеж, який нічого не означає. |
max | Date | — | Найпізніша доступна дата включно. |
month | Date | — | Показаний місяць. Через `v-model:month`. |
min | Date | — | Найраніша доступна дата включно. |
disabledDate | (date: Date) => boolean | — | Які дати недоступні. Недоступний день лишається ФОКУСОВНИМ: `aria-disabled`, а не атрибут `disabled` — інакше стрілками неможливо перескочити заблокований тиждень. |
today | Date | — | Що вважати «сьогодні». Існує заради детермінізму: прередер, тест і скріншот мають малювати той самий місяць. |
showOutsideDays | boolean | true | Показувати числа сусідніх місяців. Приховані лишаються порожніми комірками. |
disabled | boolean | — | — |
showWeekNumbers | boolean | — | Колонка номерів тижнів за ISO 8601. |
Події
| Назва | Payload | Опис |
|---|---|---|
update:modelValue | [value: Date | [Date, Date]] | Обрана дата або кортеж `[початок, кінець]`. У режимі діапазону приходить лише коли відомі ОБИДВА кінці. |
update:month | [value: Date] | Показаний місяць змінився — гортанням або виходом фокуса за його межі. |
Слоти
| Назва | Опис |
|---|---|
day | Власний рендер дня. Стан приходить готовим — рахувати його вдруге не треба. |
header | Шапка з навігацією замість стандартної. |
footer | Рядок під сіткою: кнопки «Сьогодні», «Очистити». |
Доступно через ref
| Назва | Тип | Опис |
|---|---|---|
focus | () => void | Ставить фокус на активну клітинку сітки. |
goToMonth | (date: Date) => void | Перемотує показ до місяця вказаної дати. |
Коли використовувати
- Вибір діапазону дат.
- Форми, де корисно бачити місяць цілком: бронювання, розклад, зайняті дні.
- Коли частину днів треба заблокувати за правилом.
Коли НЕ використовувати
- Для однієї дати у звичайній формі — беріть
UiDatePicker: нативний пікер дає безкоштовний мобільний інтерфейс і локаль системи. - Для дати народження: рік зручніше набрати з клавіатури, ніж догортати.
- Для періоду з пресетами над таблицею — це вже
UiDateRangePicker.
Доступність
Сітка — справжня <table role="grid"> з <caption>-заголовком через
aria-labelledby. Підпис місяця має aria-live="polite": без нього
PageUp/PageDown міняє місяць беззвучно.
Кожен день — кнопка з aria-label у форматі «12 серпня 2026 р., середа».
Саме число сховано через aria-hidden. Голе «12» у сітці, яку читають
клітинка за клітинкою, не несе нічого, а зв'язок із заголовками рядка й
колонки в role="grid" оголошується ненадійно в різних парах
браузер + скрінрідер.
Кінці діапазону додають до назви « — початок періоду» і « — кінець
періоду»: візуального виділення самого по собі не чути. aria-selected
стоїть на обох кінцях і на всіх днях між ними — це і є виділення.
Сьогоднішній день несе aria-current="date".
Деталі реалізації
Назви місяців і днів тижня беруться з Intl.DateTimeFormat за locale.
Заголовок колонки має повну назву в aria-label і скорочену — видиму:
«пн» на око, «понеділок» на слух.
Props today існує заради детермінізму. Без нього прередер малює один
місяць, а браузер за добу — інший, і тест із фіксованою датою неможливий.