Привет, коллеги!Сегодня делимся историей, которая отлично показывает, как 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 на
- без вложенных
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 буквально:
сам по себе базово не является интерактивным элементом, без вложенного . На вешаются onClick, onKeyDown, tabindex и role="gridcell". Формально - все строго по спецификации.
Но когда мы начали тестировать на реальных скринридерах (VoiceOver, NVDA), поняли, что на практике внутри работает надежнее. Вот почему мыосознанно отступили от буквы APG:
Нативная интерактивность: - нативный интерактивный HTML-элемент. Фокус, Enter, Space работают из коробки, без ручной реализации. Когда выступает интерактивным элементом, всю эту логику приходится писать самостоятельно, и она менее предсказуемо ведет себя в разных комбинациях браузер + скринридер.Семантика:Скринридеры автоматически понимают и корректно объявляют его без дополнительных ARIA-атрибутов. Атрибут disabled:На можно использовать нативный disabled, который семантически отключает элемент. На приходится комбинировать aria-disabled="true" с ручным preventDefault - это хрупкая конструкция.Click-событие:На срабатывает одинаково надежно от мыши и от клавиатуры.
Это был первый большой инсайт:спецификация - отличный ориентир, но не догма. Слепое следование без тестирования на реальных устройствах может привести к худшему результату, чем осознанное отступление с обоснованием.
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 - отличная отправная точка. Но слепое следование без тестирования на реальных устройствах может привести к худшему результату. Мы отступили от буквы паттерна (вложили в , заменили polite на assertive) и получили более надежный компонент.Мелочи решают все.Порядок фокуса, корректная разметка элементов, правильный порядок зачитывания - каждая из этих «мелочей» кардинально меняет опыт для пользователей с особыми потребностями. Именно на этих деталях проходит граница между «формально доступным» и «по-настоящему удобным».
Автор материала:Илья Новиков, технический директор Исходного Кода.
Читать оригинал