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

Привет, коллеги! Сегодня делимся историей, которая отлично показывает, как AI ускоряет старт, но человеческий опыт и внимание к деталям делают продукт по-настоящему крутым.

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

Канал с гайдами и контентом по claude code, выкладываем новости (когда режут лимиты в 10 раз) и какие инструменты через claude реализуем для проектов, канал: https://t.me/claudedevolper

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

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

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

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

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

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

  • Если начальная дата задана вне диапазона minDate/maxDate, компонент показывал последний допустимый месяц вместо месяца установленной даты с недоступными слотами. Дезориентирует.
  • В инпуте нельзя было стереть дату - пользователь не мог «обнулить» выбор.

Визуальные недочеты

  • Состояние фокуса на ячейках дат не отображалось. Это критично для пользователей клавиатуры - они буквально не видят, где находятся.
  • Ячейки дат были разного размера - мелочь, но заметно портит UX.

Проблемы с доступностью (самое интересное)

Проблема 1: Нет фокуса при открытии диалога. При открытии календаря фокус не падал на выбранную дату. Скринридер молчал, пока пользователь не начинал двигаться стрелками. Полная дезориентация.

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

Скринридер не сфокусирован на дате при открытии. Пользователь не понимает, где он.

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

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

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

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

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

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

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

  1. AI отлично генерирует каркас. Claude сэкономил нам часы на старте: ARIA-структура, клавиатурная навигация, базовая логика - все это было в первом ответе. Но доступность - территория, где нужно проверять каждую деталь руками и скринридером. AI не заменил экспертизу, он ускорил путь к ней.
  2. Спецификация - ориентир, а не догма. WAI-ARIA APG - отличная отправная точка. Но слепое следование без тестирования на реальных устройствах может привести к худшему результату. Мы отступили от буквы паттерна (вложили <button> в <td>, заменили polite на assertive) и получили более надежный компонент.
  3. Мелочи решают все. Порядок фокуса, корректная разметка элементов, правильный порядок зачитывания - каждая из этих «мелочей» кардинально меняет опыт для пользователей с особыми потребностями. Именно на этих деталях проходит граница между «формально доступным» и «по-настоящему удобным».

Канал с гайдами и контентом по claude code, выкладываем новости (когда режут лимиты в 10 раз) и какие инструменты через claude реализуем для проектов, канал: https://t.me/claudedevolper

Создаем WCAG-доступный DatePicker на React: как Claude пишет основу, а мы доводим до ума (Claude Code гайд и обучение)
Начать дискуссию