Попросили Claude создать WCAG-доступный DataPicker на React и потратили 3 дня на доработки
- понедельник, 29 июня 2026 г. в 00:00:08
Казалось, что Datapicker от Cloude сразу был готов в prod, но:
Я запустил NVDA, переключился клавишей Tab по нашему новому DataPicker'у, и фокус выскочил за пределы диалогового окна. В Storybook все работало нормально. Календарь открывался, даты менялись, состояние выбора срабатывало, и Claude написал приличную структуру на React, но как только в дело вмешался пользователь со screen reader'ом, все это перестало казаться готовым в prod.
Привет, коллеги!
Меня зовут Илья, я технический директор в «Исходном коде». Наша frontend-команда последние шесть месяцев занималась улучшением доступности компонентов React (a11y). Этот DataPicker стал одним из лучших напоминаний о том, что AI может сэкономить время на шаблонном коде, но он по-прежнему не понимает пользовательского опыта, скрытого за aria‑label, поведением клавиатуры и фокусом.
Приготовьтесь к инсайтам, багам и победам. Ну и, конечно, не без эксперимента: «А что, если Claude напишет основу?». Claude дал нам хороший каркас, мы сохранили большую его часть, но потом мы потратили три дня на то, чтобы превратить работающий компонент в WCAG-доступный.
Недавно нам для одного из проектов (в области медицины) понадобился DatePicker, пациентам нужно было выбрать дату и записаться на прием. Сам компонент под NDA, но специально для этой статьи мы собрали похожий open-source концепт с возможностью потыкать вживую (ссылка ждет в конце), чтобы честно поделиться с вами процессом.
Пациенты с нарушениями зрения должны были записываться на прием с такой же уверенностью, как и все остальные.
Очевидным решением было использовать готовый компонент выбора даты.
Мы внимательно изучили @react-aria/datepicker от Adobe — это отличный вариант, ориентированный в первую очередь на доступность, и во многих проектах я бы предпочел использовать именно его, а не создавать собственный календарь, но в данном случае ограничения не позволили нам пойти по этому пути.
Ограничения:
У нас был собственный макет с горизонтальной прокруткой месяцев.
Система дизайна клиента предъявляла строгие требования к макету и визуальному поведению.
Кроме того, мы не хотели тянуть 25KB react-aria с несколькими абстракциями ради одного компонента, если можно было сохранить реализацию компактной и контролируемой.
Приняли решение написать собственный компонент, но в качестве основного ориентира следовать строгому паттерну WAI-ARIA APG «Date Picker Dialog».
Уже здесь к игре присоединился Claude.
Наша гипотеза носила практический характер.
Claude должен был обеспечить первые 70%: структуру компонента, логику календаря, типы TypeScript, базовое состояние и все те скучные моменты, которые обычно отнимают время, но не требуют особых решений, связанных с продуктом.
Остальные 30% оставались за нами: ARIA-атрибуты, keyboard navigation, focus management, тестирование с помощью screen reader'ов и все те моменты, в которых компонент должен корректно работать для реального пользователя.
Эта оценка оказалась верной в целом, но такое разделение ввело в заблуждение. Claude не подвел в видимых 70%, но подвел в невидимой части, где на самом деле и скрывается доступность.
Начали с малого: дали Claude детальный promt с требованиями WAI-ARIA APG «Date Picker Dialog» и попросили сгенерировать фундамент компонента: React, TypeScript, WCAG-доступность, базовая структура.
Создай React- и TypeScript-компонент DatePicker без внешних зависимостей, следуя шаблону WAI-ARIA APG «Date Picker Dialog». 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
Важной деталью здесь является ссылка на конкретный шаблон APG. Без нее Claude, как правило, генерирует сырой DataPicker без учета пользовательского опыта. С ней же Claude по крайней мере пытается следовать известной модели взаимодействия.
Первый ответ был обнадеживающим (полный ответ по ссылке). Claude выдал вполне рабочую структуру: input с aria-describedby для формата, кнопка-триггер с динамическим aria-label, popover с role="dialog" и aria-modal="true", календарная сетка (table с role="grid"). Реакция команды: «почти готово» — есть даже keyboard navigation, но главные испытания ждали нас впереди.
Запускаем:

Файл /public/index.html пришлось добавлять самостоятельно — Claude про него забыл.

Он обеспечил разумное разделение между DatePicker, CalendarGrid и DayCell, не создал один огромный компонент, в котором все аспекты были бы собраны в одном файле.
Скелет ARIA также был частично правильным. Сетка, строки и ячейки были на месте. Метки дат не были просто цифрами. Claude сгенерировал метки, ближе к полным датам, что и было нужно screen reader'у.
Для первого прохода пропсы TypeScript были вполне приемлемы: value, onChange, minDate, maxDate, disabledDates и locale.
Логика календаря также работала в обычных сценариях взаимодействия с мышью. Расчет месяца, заполнение ячеек, состояние выбранной даты и базовое переключение — все это было работоспособно.
Мы сохранили примерно 60% этой базы. Затем я запустил NVDA.
Первой серьезной проблемой оказался фокус. Я открыл диалоговое окно, нажал Tab, и фокус покинул календарь — этого не должно было произойти. Модальное диалоговое окно должно удерживать фокус внутри, пока пользователь его не закроет.
Клавиша | Статус |
← → ↑ ↓ | Работало |
Home | Не работало |
End | Не работало |
PageUp и PageDown | Не работало |
Shift + PageUp | Не работало |
Shift + PageDown | Не работало |
Клавиша «Esc» закрывала диалоговое окно, но фокус не всегда надежно возвращался к элементу-триггеру. В одном случае он оказался на элементе body, что является вежливым способом сказать, что пользователь оказался в тупике.
Проблемы со screen reader'ом были еще хуже.
Заголовок месяца не имел атрибута aria-live="polite", поэтому NVDA не объявляла об изменении месяца. Зрячий пользователь видит, как меняется месяц. Пользователь screen reader'а слышит только тишину.
Claude также добавил атрибут aria-selected="false" ко всем невыбранным дням. Это выглядит безобидно, если просто просматривать DOM, но это не так, ведь выбранная дата должна иметь атрибут aria-selected, а другие даты не должны снова и снова повторять, что они не выбраны. В сгенерированной версии навигация быстро стала «шумной».
В поле ввода также отсутствовал атрибут aria-describedby, поэтому экранный считыватель не озвучивал ожидаемый формат даты.
Была также одна ошибка, не связанная с доступностью: при keyboard navigation использовались индексации числового массива — это работало до тех пор, пока фокус не пересекал границы месяцев или не касался ячеек отступа. 31 января плюс один день должен превратиться в 1 февраля. Индекс массива этого не понимает, а объект Date — понимает.
Вот в чем заключалась суть проблемы: компонент работал для демонстрации, но не соответствовал модели взаимодействия. Дальше три этапа: как мы это чинили.
«Ловушка фокуса» Claude собирала элементы, на которые можно перевести фокус, только один раз — при открытии диалогового окна. В календаре такой подход ненадежен, при смене отображаемого месяца DOM изменяется, ячейки дней создаются заново, а «ловушка», основанная на старых узлах, начинает удерживать «призраки».
Мы изменили логику так, чтобы элементы, на которые можно перевести фокус, пересчитывались при каждом событии Tab.
function useFocusTrap( containerRef: React.RefObject<HTMLDivElement>, isOpen: boolean ) { const triggerRef = useRef<HTMLElement | null>(null); useEffect(() => { if (!isOpen) return; const container = containerRef.current; if (!container) return; triggerRef.current = document.activeElement as HTMLElement; function getFocusable() { return container!.querySelectorAll<HTMLElement>( 'td[tabindex="0"], button:not([disabled])' ); } function handleKeyDown(e: KeyboardEvent) { if (e.key !== 'Tab') return; const focusable = getFocusable(); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } container.addEventListener('keydown', handleKeyDown); container.querySelector<HTMLElement>('td[tabindex="0"]')?.focus(); return () => { container.removeEventListener('keydown', handleKeyDown); triggerRef.current?.focus(); }; }, [isOpen, containerRef]); }
Механизм важнее самого фрагмента кода. Ловушка фокуса в динамическом календаре не может исходить из того, что список элементов, на которые можно установить фокус, остается неизменным. Месяц меняется, DOM меняется, и ловушка должна работать с тем, что имеется в данный момент.
В первоначальной реализации дни рассматривались как ячейки массива.
Это приводит к сбоям при работе с заполняющими ячейками и на границах месяцев. Выбор даты — это не электронная таблица, а календарь. В календаре уже есть подходящий механизм для перемещения по месяцам и годам.
function useCalendarNavigation( focusedDate: Date, setFocusedDate: (date: Date) => void, minDate?: Date, maxDate?: Date ) { return useCallback((e: React.KeyboardEvent) => { const next = new Date(focusedDate); switch (e.key) { case 'ArrowRight': next.setDate(next.getDate() + 1); break; case 'ArrowLeft': next.setDate(next.getDate() - 1); break; case 'ArrowDown': next.setDate(next.getDate() + 7); break; case 'ArrowUp': next.setDate(next.getDate() - 7); break; case 'Home': next.setDate(next.getDate() - ((next.getDay() + 6) % 7)); break; case 'End': next.setDate(next.getDate() + ((7 - next.getDay()) % 7)); break; case 'PageDown': e.shiftKey ? next.setFullYear(next.getFullYear() + 1) : next.setMonth(next.getMonth() + 1); break; case 'PageUp': e.shiftKey ? next.setFullYear(next.getFullYear() - 1) : next.setMonth(next.getMonth() - 1); break; default: return; } e.preventDefault(); if (minDate && next < minDate) return; if (maxDate && next > maxDate) return; setFocusedDate(next); }, [focusedDate, setFocusedDate, minDate, maxDate]); }
Благодаря этому поведение в крайних случаях стало предсказуемым, а это именно то, чего я и хочу от логики работы с датами.
31 января плюс один день — это 1 февраля. 1 февраля минус один день — это 31 января. Кнопки «PageUp» и «PageDown» работают по той же схеме. Компоненту больше не нужно угадывать, где именно находится ячейка в сгенерированной сетке.
Динамический tabindex остался простым: одна активная ячейка td получает tabIndex={0}, все остальные дни — -1.
Третья группа исправлений казалась небольшой по объему кода, но имела значительный эффект.
<h2 aria-live="polite"> {new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(displayedMonth)} </h2>
Благодаря этому экранный диктор озвучивает смену месяца. Без этого при keyboard navigation состояние изменяется визуально, но скрывается от пользователя.
<td role="gridcell" tabIndex={isFocused ? 0 : -1} aria-selected={isSelected || undefined} aria-disabled={isDisabled || undefined} aria-label={new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(day)} > {day.getDate()} </td>
Важно то, что значение не определено, а не то, что оно равно false.
Атрибут aria-selected присваивается только выбранной дате. Даты, доступ к которым заблокирован, получают атрибут aria-disabled только в том случае, если они действительно заблокированы. DOM становится «чище», а экранный считыватель перестает озвучивать ненужные отрицательные состояния.
<button aria-label={ selectedDate ? `Change date, ${formatDate(selectedDate, locale)}` : 'Choose date' } aria-expanded={isOpen} > 📅 </button>
После выбора даты надпись на кнопке также должна измениться. Пользователь должен понимать не только то, что эта кнопка открывает календарь, но и какая дата выбрана в данный момент.
<span id="date-format-hint" className={styles.srOnly}> Format: DD.MM.YYYY </span> <input type="text" aria-describedby="date-format-hint" />
Это небольшая строчка, которую люди часто пропускают. Она важна, поскольку форматы даты не являются универсальными. Пользователь не должен гадать, в каком порядке следует вводить данные в поле: сначала день, сначала месяц или как-то иначе.
В системе непрерывной интеграции мы использовали jest-axe.
import { render, fireEvent } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; expect.extend(toHaveNoViolations); test('closed state has no axe violations', async () => { const { container } = render( <DatePicker value={null} onChange={() => {}} locale="en-US" /> ); expect(await axe(container)).toHaveNoViolations(); }); test('open state has no axe violations', async () => { const { container, getByRole } = render( <DatePicker value={new Date()} onChange={() => {}} locale="en-US" /> ); fireEvent.click(getByRole('button', { name: /choose date/i })); expect(await axe(container)).toHaveNoViolations(); });
Axe-Core на раннем этапе выявил четыре проблемы. Это помогло, но он не выявил самых серьезных проблем, а вот NVDA и VoiceOver — да.
NVDA оказался самым полезным инструментом в данном случае, он прямой, строгий и порой до боли честен. NVDA быстро показал нам, что реализация keyboard navigation была неполной и что некоторые элементы ARIA выглядели корректно лишь с точки зрения разработчика.
VoiceOver в Safari обнаружил более скрытую проблему. Он озвучивал каждый день дважды: сначала видимое число, затем полный атрибут aria-label. Поскольку в шаблоне APG используется элемент td вместо вложенной кнопки, VoiceOver объединял textContent и aria-label.
Мы потратили около 40 минут на тестирование различных вариантов и в итоге добавили пустой атрибут aria-roledescription именно в этом месте. Это устранило дублирование в VoiceOver, не нарушив работу NVDA и JAWS.
Я не ожидал, что Claude придумает такое решение, его даже было не так просто найти в Google. Оно появилось благодаря тому, что мы прислушались к компоненту так, как это сделал бы пользователь.
Режим высокой контрастности Windows также потребовал еще одного исправления. Без forced-colors: active выбранный день мог стать невидимым, поскольку свойство background-color игнорировалось.
@media (forced-colors: active) { .daySelected { forced-color-adjust: none; border: 2px solid ButtonText; } .dayFocused { outline-color: Highlight; } }
Это одна из тех вещей, которые не выявляются в ходе обычной проверки. Компонент выглядит нормально, служба контроля качества проверяет «идеальный сценарий», а затем у реального пользователя возникает сбой в отображении.
Работа над доступностью полна таких мелких ловушек.
После всех доработок и трех ночей, у нас есть финальный результат: доступный, красивый и функциональный DatePicker.

Попробовать финальную версию можно по ссылке. Перейти к репозиторию Github можно по этой ссылке.
Claude стал «спарринг-партнером» по архитектуре. Я просматривал сгенерированный код и вынужден был задавать вопросы: почему была выбрана именно такая структура, где скрыты допущения и какие части можно смело оставить без изменений.
Этот анализ сделал команду более внимательной. Мы обнаружили такие ошибки, как атрибут aria-selected="false" в каждой ячейке. Если бы мы писали все с нуля, мы могли бы допустить ту же ошибку и дольше не замечать ее.
AI не лишил процесс экспертных знаний. Он сделал их еще более важными, потому что теперь опасные ошибки могут быть спрятаны в коде, который выглядит чистым.
Это все еще концепт, поэтому кое-чего не хватает: пока нельзя листать годы сразу и выбирать интервал — только одну дату. Но рабочая база есть, и она уже пригодна для реальных проектов.
Что мы из этого вынесли:
Claude дал нам прочную отправную точку: ARIA-атрибуты, базовую keyboard navigation, расположение компонентов и логику работы календаря. Это сэкономило нам время, но не заменило экспертных знаний в области доступности.
Вид выбора даты может выглядеть корректно в коде, но при этом не работать для пользователя screen reader'а. Порядок фокусировки, озвучивание элементов, активные области и поведение клавиатуры необходимо тестировать вручную.
WAI-ARIA APG послужила полезным ориентиром, но не готовым решением. Мы следовали шаблону, тестировали его на реальных устройствах и вносили изменения в реализацию там, где пользовательский опыт оказывался лучше, чем в формальной версии.
AI помогает быстрее добраться до сложной части работы. Конечное качество по-прежнему зависит от мелких деталей, ручного тестирования и ответственности разработчика перед людьми, которые будут использовать этот компонент.