Попросили Claude создать WCAG-доступный DataPicker на React и потратили 3 дня на доработки

от автора

Введение

Казалось, что 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 — это отличный вариант, ориентированный в первую очередь на доступность, и во многих проектах я бы предпочел использовать именно его, а не создавать собственный календарь, но в данном случае ограничения не позволили нам пойти по этому пути.

Ограничения:

  1. У нас был собственный макет с горизонтальной прокруткой месяцев.

  2. Система дизайна клиента предъявляла строгие требования к макету и визуальному поведению.

  3. Кроме того, мы не хотели тянуть 25KB react-aria с несколькими абстракциями ради одного компонента, если можно было сохранить реализацию компактной и контролируемой.

Приняли решение написать собственный компонент, но в качестве основного ориентира следовать строгому паттерну WAI-ARIA APG «Date Picker Dialog».

Уже здесь к игре присоединился Claude.

Гипотеза

Наша гипотеза носила практический характер.

Claude должен был обеспечить первые 70%: структуру компонента, логику календаря, типы TypeScript, базовое состояние и все те скучные моменты, которые обычно отнимают время, но не требуют особых решений, связанных с продуктом.

Остальные 30% оставались за нами: ARIA-атрибуты, keyboard navigation, focus management, тестирование с помощью screen reader’ов и все те моменты, в которых компонент должен корректно работать для реального пользователя.

Эта оценка оказалась верной в целом, но такое разделение ввело в заблуждение. Claude не подвел в видимых 70%, но подвел в невидимой части, где на самом деле и скрывается доступность.

AI на старте: «Claude, напиши мне DatePicker!»

Начали с малого: дали Claude детальный promt с требованиями WAI-ARIA APG «Date Picker Dialog» и попросили сгенерировать фундамент компонента: React, TypeScript, WCAG-доступность, базовая структура.

Promt к Claude

Создай 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, Escape8. Focus trap внутри dialog9. При закрытии — фокус на триггер, aria-label обновляется10. Props: value, onChange, minDate?, maxDate?, disabledDates?, locale?11. CSS Modules, контрастность ≥ 4.5:112. Без внешних зависимостей кроме React

Важной деталью здесь является ссылка на конкретный шаблон APG. Без нее Claude, как правило, генерирует сырой DataPicker без учета пользовательского опыта. С ней же Claude по крайней мере пытается следовать известной модели взаимодействия.

Первый ответ был обнадеживающим (полный ответ по ссылке). Claude выдал вполне рабочую структуру: input с aria-describedby для формата, кнопка-триггер с динамическим aria-label, popover с role="dialog" и aria-modal="true", календарная сетка (table с role="grid"). Реакция команды: «почти готово» — есть даже keyboard navigation, но главные испытания ждали нас впереди.

Запускаем:

Первый запуск DatePicker'а с ошибками

Первый запуск DatePicker’а с ошибками

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

Структура проекта

Структура проекта

Что у Claude получилось хорошо?

Он обеспечил разумное разделение между DatePicker, CalendarGrid и DayCell, не создал один огромный компонент, в котором все аспекты были бы собраны в одном файле.

Скелет ARIA также был частично правильным. Сетка, строки и ячейки были на месте. Метки дат не были просто цифрами. Claude сгенерировал метки, ближе к полным датам, что и было нужно screen reader’у.

Для первого прохода пропсы TypeScript были вполне приемлемы: value, onChange, minDate, maxDate, disabledDates и locale.

Логика календаря также работала в обычных сценариях взаимодействия с мышью. Расчет месяца, заполнение ячеек, состояние выбранной даты и базовое переключение — все это было работоспособно.

Мы сохранили примерно 60% этой базы. Затем я запустил NVDA.

Что у Claude не получилось?

Первой серьезной проблемой оказался фокус. Я открыл диалоговое окно, нажал Tab, и фокус покинул календарь — этого не должно было произойти. Модальное диалоговое окно должно удерживать фокус внутри, пока пользователь его не закроет.

Клавиша

Статус

← → ↑ ↓

Работало

Home

Не работало

End

Не работало

PageUp и PageDown

Не работало

Shift + PageUp

Не работало

Shift + PageDown

Не работало

Клавиша «Esc» закрывала диалоговое окно, но фокус не всегда надежно возвращался к элементу-триггеру. В одном случае он оказался на элементе body, что является вежливым способом сказать, что пользователь оказался в тупике.

Проблемы со screen reader’ом были еще хуже.

  1. Заголовок месяца не имел атрибута aria-live="polite", поэтому NVDA не объявляла об изменении месяца. Зрячий пользователь видит, как меняется месяц. Пользователь screen reader’а слышит только тишину.

  2. Claude также добавил атрибут aria-selected="false" ко всем невыбранным дням. Это выглядит безобидно, если просто просматривать DOM, но это не так, ведь выбранная дата должна иметь атрибут aria-selected, а другие даты не должны снова и снова повторять, что они не выбраны. В сгенерированной версии навигация быстро стала «шумной».

  3. В поле ввода также отсутствовал атрибут aria-describedby, поэтому экранный считыватель не озвучивал ожидаемый формат даты.

Была также одна ошибка, не связанная с доступностью: при keyboard navigation использовались индексации числового массива — это работало до тех пор, пока фокус не пересекал границы месяцев или не касался ячеек отступа. 31 января плюс один день должен превратиться в 1 февраля. Индекс массива этого не понимает, а объект Date — понимает.

Вот в чем заключалась суть проблемы: компонент работал для демонстрации, но не соответствовал модели взаимодействия. Дальше три этапа: как мы это чинили.

Этап 1. Переработать «ловушки фокуса» вокруг текущего DOM

«Ловушка фокуса» 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 меняется, и ловушка должна работать с тем, что имеется в данный момент.

Этап 2. Перенести keyboard navigation из индексов в объекты Date

В первоначальной реализации дни рассматривались как ячейки массива.

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

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.

Этап 3. Рассматривать ARIA как функциональность, а не как декоративный элемент

Третья группа исправлений казалась небольшой по объему кода, но имела значительный эффект.

<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. Оно появилось благодаря тому, что мы прислушались к компоненту так, как это сделал бы пользователь.

Проблема с CSS, которая обычно обнаруживается слишком поздно

Режим высокой контрастности Windows также потребовал еще одного исправления. Без forced-colors: active выбранный день мог стать невидимым, поскольку свойство background-color игнорировалось.

@media (forced-colors: active) {  .daySelected {forced-color-adjust: none;border: 2px solid ButtonText;  }   .dayFocused {outline-color: Highlight;  }}

Это одна из тех вещей, которые не выявляются в ходе обычной проверки. Компонент выглядит нормально, служба контроля качества проверяет «идеальный сценарий», а затем у реального пользователя возникает сбой в отображении.

Работа над доступностью полна таких мелких ловушек.

Заключение

DatePicker

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

Финальная версия DatePicker'а

Финальная версия DatePicker’а

Попробовать финальную версию можно по ссылке. Перейти к репозиторию Github можно по этой ссылке.

Опыт взаимодействия с Claude

Claude стал «спарринг-партнером» по архитектуре. Я просматривал сгенерированный код и вынужден был задавать вопросы: почему была выбрана именно такая структура, где скрыты допущения и какие части можно смело оставить без изменений.

Этот анализ сделал команду более внимательной. Мы обнаружили такие ошибки, как атрибут aria-selected="false" в каждой ячейке. Если бы мы писали все с нуля, мы могли бы допустить ту же ошибку и дольше не замечать ее.

AI не лишил процесс экспертных знаний. Он сделал их еще более важными, потому что теперь опасные ошибки могут быть спрятаны в коде, который выглядит чистым.

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

Что мы из этого вынесли:

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

  2. Вид выбора даты может выглядеть корректно в коде, но при этом не работать для пользователя screen reader’а. Порядок фокусировки, озвучивание элементов, активные области и поведение клавиатуры необходимо тестировать вручную.

  3. WAI-ARIA APG послужила полезным ориентиром, но не готовым решением. Мы следовали шаблону, тестировали его на реальных устройствах и вносили изменения в реализацию там, где пользовательский опыт оказывался лучше, чем в формальной версии.

AI помогает быстрее добраться до сложной части работы. Конечное качество по-прежнему зависит от мелких деталей, ручного тестирования и ответственности разработчика перед людьми, которые будут использовать этот компонент.

ссылка на оригинал статьи https://habr.com/ru/articles/1052878/