Введение
Казалось, что DatePicker от Cloude сразу был готов в prod, но:
Я запустил NVDA, переключился клавишей Tab по нашему новому DatePicker'у, и фокус выскочил за пределы диалогового окна. В Storybook все работало нормально. Календарь открывался, даты менялись, состояние выбора срабатывало, и Claude написал приличную структуру на React, но как только в дело вмешался пользователь со screen reader'ом, все это перестало казаться готовым в prod.
Привет, коллеги!
Меня зовут Илья, я технический директор в «Исходном коде». Наша frontend-команда последние шесть месяцев занималась улучшением доступности компонентов React (a11y). Этот DatePicker стал одним из лучших напоминаний о том, что 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%, но подвел в невидимой части, где на самом деле и скрывается доступность.
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, 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, как правило, генерирует сырой DatePicker без учета пользовательского опыта. С ней же Claude по крайней мере пытается следовать известной модели взаимодействия.
Первый ответ был обнадеживающим (полный ответ по ссылке). Claude выдал вполне рабочую структуру: input с aria-describedby для формата, кнопка-триггер с динамическим aria-label, popover с role="dialog" и aria-modal="true", календарная сетка (table с role="grid"). Реакция команды: «почти готово» — есть даже keyboard navigation, но главные испытания ждали нас впереди.
Запускаем:

Файл /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'ом были еще хуже.
Заголовок месяца не имел атрибута
aria-live="polite", поэтому NVDA не объявляла об изменении месяца. Зрячий пользователь видит, как меняется месяц. Пользователь screen reader'а слышит только тишину.Claude также добавил атрибут
aria-selected="false"ко всем невыбранным дням. Это выглядит безобидно, если просто просматривать DOM, но это не так, ведь выбранная дата должна иметь атрибутaria-selected, а другие даты не должны снова и снова повторять, что они не выбраны. В сгенерированной версии навигация быстро стала «шумной».В поле ввода также отсутствовал атрибут
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.

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

MS03
28.06.2026 17:20Да, тоже сталкивался как то с тем, что клауд плохо работает с передачей фокуса в виндах. Может системно это..

rbdr
28.06.2026 17:20Что такое постоянно упоминаемый в статье "Claude". Их там уже штук пять, не считая усилий. По описанию процесса похоже на какой-то Соннет, что ли.
Пишите ясно - например: Claude Opus 4.8 xhigh. Этот с Дейтпикером справился бы, скорее всего.
При работе рекомендую иметь npm тул в режиме билда, чтобы оно само потом всё исправляло до момента сдачи на тестирование.
Также сразу надо просить тесты хотя бы по модели данных, для начала.
Короче, статья уровня не "давайте научим вас как", а больше типа "памагити, научити"

cmyser
28.06.2026 17:20А на что вы рассчитывали беря реакт ? Какой инструмент такой и результат

winkyBrain
28.06.2026 17:20кто о чём, а вшивый о бане) предлагаете ничего не понимающему юзеру с помощью нейронки писать на смол? это будет довольно забавно, ведь интернет буквально забит лучшими примерами кода на этом общеизвестном фреймворке)
IAmNotMe
Подскажите, когда вы пишете “мы изменили” (после скелета от клауда) - это уже ручная работа или Клауд после ваших репортов и требований?