javascript

Создаем WCAG-доступный DatePicker на React: как Claude пишет основу, а мы доводим до ума

  • вторник, 14 апреля 2026 г. в 00:00:16
https://habr.com/ru/articles/1022918/

Привет, коллеги! Сегодня делимся историей, которая отлично показывает, как 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. Допиливаем руками: Путь к рабочему компоненту (и через баги!)

После первых правок мы, воодушевленные, попытались запустить проект. И тут же получили… ошибки компиляции.

Классика жанра. Claude дал каркас, а TypeScript и сборщик потребовали доработки.
Классика жанра. Claude дал каркас, а TypeScript и сборщик потребовали доработки.
Структура проекта. Файл /public/index.html пришлось добавлять самостоятельно - Claude про него забыл.
Структура проекта. Файл /public/index.html пришлось добавлять самостоятельно - Claude про него забыл.

Пришлось пройтись по всему коду и привести его в компилируемое состояние. Когда компонент наконец-то заработал, мы начали тестировать - дотошно и с пристрастием. И вот что обнаружили в сырой версии:

Функциональные проблемы

  • Если начальная дата задана вне диапазона 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 справляется отлично. На инклюзивность это не влияет, а интерфейс стал чище.

Финальный результат: доступный, красивый и функциональный DatePicker.
Финальный результат: доступный, красивый и функциональный DatePicker.

Попробовать финальную версию можно тут: https://vocal-gumption-6cd6d6.netlify.app/

Ссылка на репозиторий: https://github.com/Codesrc-public-ru/datepicker

6. Итоги: Что мы вынесли из этой истории

Это все еще концепт, поэтому кое-чего не хватает: пока нельзя листать годы сразу и выбирать интервал - только одну дату. Но рабочая база есть, и она уже пригодна для реальных проектов.

Три главных урока:

  1. AI отлично генерирует каркас. Claude сэкономил нам часы на старте: ARIA-структура, клавиатурная навигация, базовая логика - все это было в первом ответе. Но доступность - территория, где нужно проверять каждую деталь руками и скринридером. AI не заменил экспертизу, он ускорил путь к ней.

  2. Спецификация - ориентир, а не догма. WAI-ARIA APG - отличная отправная точка. Но слепое следование без тестирования на реальных устройствах может привести к худшему результату. Мы отступили от буквы паттерна (вложили <button> в <td>, заменили polite на assertive) и получили более надежный компонент.

  3. Мелочи решают все. Порядок фокуса, корректная разметка элементов, правильный порядок зачитывания - каждая из этих «мелочей» кардинально меняет опыт для пользователей с особыми потребностями. Именно на этих деталях проходит граница между «формально доступным» и «по-настоящему удобным».

Автор материала: Илья Новиков @inova99, технический директор Исходного Кода.