Привет, коллеги! Сегодня делимся историей, которая отлично показывает, как AI ускоряет старт, но человеческий опыт и внимание к деталям делают продукт по-настоящему крутым.
Недавно нам для одного из проектов понадобился DatePicker. Сам компонент под NDA, поэтому показать его не можем. Но чтобы поделиться процессом, мы специально для статьи собрали похожий концепт — с открытым кодом и возможностью потыкать вживую (ссылка ждет в конце).
Так вот, казалось бы, компонент простой, но мы решили не просто взять готовую библиотеку. Во-первых, готовые компоненты обычно ограничены в плане модификации, а во-вторых — поставить себе планку: сделать его по-настоящему доступным по всем канонам WCAG. Ну и, конечно, не без эксперимента: «А что, если Claude напишет основу?»
Так началось наше приключение с созданием полностью доступного компонента выбора даты с использованием React и Typescript, следуя строгому паттерну WAI-ARIA APG «Date Picker Dialog»
1. AI на старте: «Claude, напиши мне DatePicker!»
Начали мы, как и многие сейчас, с малого. Дали Claude детальный промт с требованиями WAI-ARIA APG «Date Picker Dialog» и попросили сгенерировать фундамент компонента: React, TypeScript, WCAG-доступность, базовая структура.
Первый ответ был обнадеживающим (полный ответ доступен в файле по ссылке).
Изначальный промт к Claude:
Проанализируй и доработай требования к React-компонент DatePicker на TypeScript, строго следуя паттерну WAI-ARIA APG «Date Picker Dialog«
Приготовьтесь к инсайтам, багам и победам!
Требования:
1. WCAG 2.1/2.2 Level AA
2. Структура: input + aria-describedby для формата
+ кнопка-триггер с динамическим aria-label
+ popover (role=»dialog», aria-modal=»true»)
+ calendar grid (table role=»grid»)
3. Roving tabindex на <td role=»gridcell»> — без вложенных <button>
4. aria-live=»polite» на заголовке месяца
5. aria-selected только на выбранной дате
6. aria-disabled=»true» на недоступных датах
7. Полная keyboard navigation: стрелки, Home/End,
PageUp/PageDown, Shift+PageUp/Down, Enter/Space, Escape
8. Focus trap внутри dialog
9. При закрытии — фокус на триггер, aria-label обновляется
10. Props: value, onChange, minDate?, maxDate?, disabledDates?, locale?
11. CSS Modules, контрастность ≥ 4.5:1
12. Без внешних зависимостей кроме React
Claude выдал вполне рабочую структуру: input с aria-describedby для формата, кнопка-триггер с динамическим aria-label, popover с role=»dialog» и aria-modal=»true», календарная сетка (table с role=»grid»). На первый взгляд — почти готово. Есть даже клавиатурная навигация. Мы подумали: «Ух ты, осталось немного допилить!» Но главные испытания ждали впереди.
2. Наши требования: Зачем нам WCAG 2.1/2.2 Level AA?
Прежде чем углубляться в код, давайте проясним: почему WCAG 2.1/2.2 Level AA — это не прихоть, а необходимость? Для нас, как для команды, создающей продукты для тысяч пользователей, доступность — не просто «фича». Это гарантия, что каждый пользователь, независимо от своих особенностей, сможет полноценно взаимодействовать с интерфейсом. К тому же этот уровень все чаще требуется законодательно.
Наш чек-лист:
-
WCAG 2.1/2.2 Level AA: Покрывает потребности подавляющего большинства пользователей с ограниченными возможностями. Есть еще более строгий уровень ААА, но для нашего проекта он был не нужен.
-
Четкая ARIA-структура: input, кнопка-триггер для открытия поповера, popover (role=»dialog», aria-modal=»true»), calendar grid (table role=»grid»). Чтобы скринридеры точно понимали, что перед ними.
-
Полная клавиатурная навигация: Стрелки, Home/End, PageUp/Down, Shift+PageUp/Down, Enter/Space, Escape. Без этого пользователь, не использующий мышь, просто потеряется.
-
Focus trap: Чтобы фокус не «улетал» за пределы открытого диалога с календарем.
-
Динамический aria-label и aria-selected: Для понятного объявления выбранной даты и статуса элементов.
-
aria-disabled на недоступных датах: Чтобы скринридеры сообщали об их недоступности.
-
Контрастность ≥ 4.5:1: Для читаемости всех элементов.
-
Никаких внешних зависимостей, кроме React: Полный контроль над кодом и минимальный бандл. Вся математика с датами — нативный Date, форматирование — Intl.
Наш главный ориентир — паттерн WAI-ARIA APG «Date Picker Dialog». Это не просто рекомендации, а детальные инструкции, как должен себя вести доступный компонент.
3. Первое решение: внутри — почему мы отступили от APG
Первое интересное решение, которое нам пришлось принять, касалось структуры ячеек календаря.
Claude следовал паттерну WAI-ARIA APG буквально: <td> сам по себе базово не является интерактивным элементом, без вложенного <button>. На <td> вешаются onClick, onKeyDown, tabindex и role=»gridcell». Формально — все строго по спецификации.
Но когда мы начали тестировать на реальных скринридерах (VoiceOver, NVDA), поняли, что на практике <button> внутри <td> работает надежнее. Вот почему мы осознанно отступили от буквы APG:
-
Нативная интерактивность: <button> — нативный интерактивный HTML-элемент. Фокус, Enter, Space работают из коробки, без ручной реализации. Когда <td> выступает интерактивным элементом, всю эту логику приходится писать самостоятельно, и она менее предсказуемо ведет себя в разных комбинациях браузер + скринридер.
-
Семантика: Скринридеры автоматически понимают <button> и корректно объявляют его без дополнительных ARIA-атрибутов.
-
Атрибут disabled: На <button> можно использовать нативный disabled, который семантически отключает элемент. На <td> приходится комбинировать aria-disabled=»true» с ручным preventDefault — это хрупкая конструкция.
-
Click-событие: На <button> срабатывает одинаково надежно от мыши и от клавиатуры.
Это был первый большой инсайт: спецификация — отличный ориентир, но не догма. Слепое следование без тестирования на реальных устройствах может привести к худшему результату, чем осознанное отступление с обоснованием.
4. Допиливаем руками: Путь к рабочему компоненту (и через баги!)
После первых правок мы, воодушевленные, попытались запустить проект. И тут же получили… ошибки компиляции.
Пришлось пройтись по всему коду и привести его в компилируемое состояние. Когда компонент наконец-то заработал, мы начали тестировать — дотошно и с пристрастием. И вот что обнаружили в сырой версии:
Функциональные проблемы
-
Если начальная дата задана вне диапазона minDate/maxDate, компонент показывал последний допустимый месяц вместо месяца установленной даты с недоступными слотами. Дезориентирует.
-
В инпуте нельзя было стереть дату — пользователь не мог «обнулить» выбор.
Визуальные недочеты
-
Состояние фокуса на ячейках дат не отображалось. Это критично для пользователей клавиатуры — они буквально не видят, где находятся.
-
Ячейки дат были разного размера — мелочь, но заметно портит UX.
Проблемы с доступностью (самое интересное)
Проблема 1: Нет фокуса при открытии диалога. При открытии календаря фокус не падал на выбранную дату. Скринридер молчал, пока пользователь не начинал двигаться стрелками. Полная дезориентация.

Проблема 2: aria-live=»polite» на заголовке месяца. Мы изначально использовали polite-режим для объявления смены месяца. aria-live=»polite» означает, что скринридер дождется завершения текущего объявления, прежде чем сообщит об изменении.
На практике это оказалось неудобно: при быстром переключении месяцев сообщения «накапливались в очереди», и скринридер все еще зачитывал предыдущие, пока пользователь уже ушел далеко вперед.
Проблема 3: «Моргающий» диалог. При открытом диалоге при нажатии на кнопку-триггер диалог скрывался (отрабатывал blur) и тут же открывался (отрабатывало нажатие на кнопку-триггер), вместо обычного закрытия.
5. Доводим до ума: Как мы все починили
Взяли «сырой» компонент и начали планомерно докручивать каждую проблему.
Фокус при открытии. Сделали так, чтобы при открытии диалога фокус сразу вставал на выбранную дату, а если даты нет — на сегодняшнюю. Скринридер при этом зачитывает полный контекст: день недели, число, месяц, год, статус.
Попробовать живую версию вы сможете по ссылке в конце статьи.
Исправление aria-live. Перенесли объявления о смене месяца в отдельный скрытый aria-live=»assertive» регион. Да, assertive прерывает текущее объявление скринридера, но для навигации по месяцам это оправдано: пользователь должен сразу понимать, куда он попал, а не ждать очереди из накопившихся сообщений.
Возврат фокуса после выбора. После выбора даты фокус возвращается на кнопку-триггер, и скринридер зачитывает обновленный aria-label с выбранной датой. Это корректное поведение по APG.
Установка даты вне диапазона. Компонент теперь показывает месяц установленной даты с недоступными днями, а не перескакивает на последний допустимый месяц.
Верстка и дизайн. Поправили CSS Modules: равномерный размер ячеек, видимый фокус, проверенная контрастность ≥ 4.5:1, адаптация под наш фирменный дизайн.
OK и Cancel. Если посмотреть на сырой календарь, то там есть кнопки OK и Cancel. В итоговом же мы их убрали. Почему? Кажется, что толку от них меньше, чем пользы. Выбор даты можно сделать сразу по Enter без дополнительного нажатия «ОК», а Cancel по сути просто закрывает календарь — с этим Esc справляется отлично. На инклюзивность это не влияет, а интерфейс стал чище.
Попробовать финальную версию можно тут: https://vocal-gumption-6cd6d6.netlify.app/
Ссылка на репозиторий: https://github.com/Codesrc-public-ru/datepicker
6. Итоги: Что мы вынесли из этой истории
Это все еще концепт, поэтому кое-чего не хватает: пока нельзя листать годы сразу и выбирать интервал — только одну дату. Но рабочая база есть, и она уже пригодна для реальных проектов.
Три главных урока:
-
AI отлично генерирует каркас. Claude сэкономил нам часы на старте: ARIA-структура, клавиатурная навигация, базовая логика — все это было в первом ответе. Но доступность — территория, где нужно проверять каждую деталь руками и скринридером. AI не заменил экспертизу, он ускорил путь к ней.
-
Спецификация — ориентир, а не догма. WAI-ARIA APG — отличная отправная точка. Но слепое следование без тестирования на реальных устройствах может привести к худшему результату. Мы отступили от буквы паттерна (вложили <button> в <td>, заменили polite на assertive) и получили более надежный компонент.
-
Мелочи решают все. Порядок фокуса, корректная разметка элементов, правильный порядок зачитывания — каждая из этих «мелочей» кардинально меняет опыт для пользователей с особыми потребностями. Именно на этих деталях проходит граница между «формально доступным» и «по-настоящему удобным».
Автор материала: Илья Новиков, технический директор Исходного Кода.
ссылка на оригинал статьи https://habr.com/ru/articles/1022918/