Перейти до вмісту
tatetUI

Calendar

Вбудована сітка місяця з вибором дати або діапазону і повним клавіатурним контрактом grid.

Сітка місяця, яку видно завжди. Не заміна UiDatePicker: той лишається тонкою обгорткою над нативним <input type="date"> і лишається типовою порадою для однієї дати у формі.

UiCalendar існує для того, чого нативний пікер не вміє: діапазони, предикат недоступних днів, два місяці поруч і завжди відкрита сітка.

серпень 2026 р.
Номер тижня
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" вимагає однакової кількості клітинок у кожному рядку.

Клавіатура

серпень 2026 р.
вересень 2026 р.

Період: 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

НазваТипТиповоОпис
ariaLabelstring"Календар"Доступна назва сітки, коли поруч немає видимого заголовка.
modesinglerange"single"Одна дата чи діапазон.
months1 | 21Скільки місяців показати поруч.
weekStartsOn0 | 11Перший день тижня. `1` — понеділок (uk), `0` — неділя.
localestring"uk-UA"Локаль назв місяців і днів тижня.
modelValueDate | [Date, Date]Обрана дата (`single`) або діапазон (`range`). Через `v-model`. У режимі `range` НАПІВобраний діапазон назовні не емітиться: перший клік живе у внутрішньому стані, а модель оновлюється лише коли відомі обидва кінці. Тому споживач ніколи не отримує кортеж, який нічого не означає.
maxDateНайпізніша доступна дата включно.
monthDateПоказаний місяць. Через `v-model:month`.
minDateНайраніша доступна дата включно.
disabledDate(date: Date) => booleanЯкі дати недоступні. Недоступний день лишається ФОКУСОВНИМ: `aria-disabled`, а не атрибут `disabled` — інакше стрілками неможливо перескочити заблокований тиждень.
todayDateЩо вважати «сьогодні». Існує заради детермінізму: прередер, тест і скріншот мають малювати той самий місяць.
showOutsideDaysbooleantrueПоказувати числа сусідніх місяців. Приховані лишаються порожніми комірками.
disabledboolean
showWeekNumbersbooleanКолонка номерів тижнів за 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 існує заради детермінізму. Без нього прередер малює один місяць, а браузер за добу — інший, і тест із фіксованою датою неможливий.