inova996 часов назад
Попросили Claude создать WCAG-доступный DataPicker на React и потратили 3 дня на доработки
Уровень сложностиСреднийВремя на прочтение11 минОхват и читатели3.9KВеб-разработка*ReactJS*TypeScript*Искусственный интеллектJavaScript*КейсИз песочницыВведение
Казалось, что 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%, но подвел в невидимой части, где на самом деле и скрывается доступность.
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, как правило, генерирует сырой DataPicker без учета пользовательского опыта. С ней же Claude по крайней мере пытается следовать известной модели взаимодействия.
Первый ответ был обнадеживающим (полный ответ по ссылке). Claude выдал вполне рабочую структуру: input с aria-describedby для формата, кнопка-триггер с динамическим aria-label, popover с role="dialog" и aria-modal="true", календарная сетка (table с role="grid"). Реакция команды: «почти готово» — есть даже keyboard navigation, но главные испытания ждали нас впереди.
Запускаем:
Первый запуск 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'ом были еще хуже.
• Заголовок месяца не имел атрибута 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.
Финальная версия DatePicker'аПопробовать финальную версию можно по ссылке. Перейти к репозиторию Github можно по этой ссылке.
Опыт взаимодействия с Claude
Claude стал «спарринг-партнером» по архитектуре. Я просматривал сгенерированный код и вынужден был задавать вопросы: почему была выбрана именно такая структура, где скрыты допущения и какие части можно смело оставить без изменений.
Этот анализ сделал команду более внимательной. Мы обнаружили такие ошибки, как атрибут aria-selected="false" в каждой ячейке. Если бы мы писали все с нуля, мы могли бы допустить ту же ошибку и дольше не замечать ее.
AI не лишил процесс экспертных знаний. Он сделал их еще более важными, потому что теперь опасные ошибки могут быть спрятаны в коде, который выглядит чистым.
Это все еще концепт, поэтому кое-чего не хватает: пока нельзя листать годы сразу и выбирать интервал — только одну дату. Но рабочая база есть, и она уже пригодна для реальных проектов.
Что мы из этого вынесли:
• Claude дал нам прочную отправную точку: ARIA-атрибуты, базовую keyboard navigation, расположение компонентов и логику работы календаря. Это сэкономило нам время, но не заменило экспертных знаний в области доступности.
• Вид выбора даты может выглядеть корректно в коде, но при этом не работать для пользователя screen reader'а. Порядок фокусировки, озвучивание элементов, активные области и поведение клавиатуры необходимо тестировать вручную.
• WAI-ARIA APG послужила полезным ориентиром, но не готовым решением. Мы следовали шаблону, тестировали его на реальных устройствах и вносили изменения в реализацию там, где пользовательский опыт оказывался лучше, чем в формальной версии.AI помогает быстрее добраться до сложной части работы. Конечное качество по-прежнему зависит от мелких деталей, ручного тестирования и ответственности разработчика перед людьми, которые будут использовать этот компонент.Теги:• datapicker
• frontend
• claude
• ии
• typescript
• javascript
• web
• ux
• ui
• aiХабы:• Веб-разработка
• ReactJS
• TypeScript
• Искусственный интеллект
• JavaScript
Получайте больше инсайтов о систематизации бизнеса
Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья