';
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
return {
...meta,
contentHtml: bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources),
};
}
const wcagFocusOrder = {
title: 'WCAG 2.1: 2.4.3 Focus Order',
url: 'https://www.w3.org/TR/WCAG21/#focus-order',
note: 'при последовательной навигации порядок фокуса должен сохранять смысл и работоспособность операции',
};
const wcagFocusVisible = {
title: 'WCAG 2.1: 2.4.7 Focus Visible',
url: 'https://www.w3.org/TR/WCAG21/#focus-visible',
note: 'для управляемого с клавиатуры интерфейса нужен режим с видимым индикатором фокуса',
};
const wcagNameRoleValue = {
title: 'WCAG 2.1: 4.1.2 Name, Role, Value',
url: 'https://www.w3.org/TR/WCAG21/#name-role-value',
note: 'компонентам интерфейса требуются программно определимые имя, роль и доступные состояния или значения',
};
const wcagErrorIdentification = {
title: 'WCAG 2.1: 3.3.1 Error Identification',
url: 'https://www.w3.org/TR/WCAG21/#error-identification',
note: 'если ошибка ввода определяется автоматически, ошибочный элемент и текст ошибки должны быть определены для пользователя',
};
const htmlLabel = {
title: 'HTML Standard: label element',
url: 'https://html.spec.whatwg.org/multipage/forms.html#the-label-element',
note: 'label связывается с form control через for/id или вложением самого control',
};
const htmlTabindex = {
title: 'HTML Standard: tabindex',
url: 'https://html.spec.whatwg.org/multipage/interaction.html#the-tabindex-attribute',
note: 'положительный tabindex создаёт отдельный относительный порядок последовательной фокусировки',
};
const ariaDescribedBy = {
title: 'WAI-ARIA 1.1: aria-describedby',
url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-describedby',
note: 'список ID связывает элемент с полной текстовой description',
};
const ariaErrorMessage = {
title: 'WAI-ARIA 1.1: aria-errormessage',
url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage',
note: 'ссылка на пользовательское сообщение об ошибке используется вместе с aria-invalid; актуальный текст ошибки не должен быть скрыт',
};
const keyboardFixture = [
'<form id="profile-form" novalidate>',
' <label for="profile-email">Рабочая почта</label>',
' <p id="profile-email-hint">Нужен адрес, на который придёт ссылка.</p>',
' <input id="profile-email" type="email" required',
' aria-describedby="profile-email-hint profile-email-error"',
' aria-errormessage="profile-email-error" aria-invalid="false" />',
' <p id="profile-email-error" hidden></p>',
' <button type="submit">Сохранить</button>',
'</form>',
'',
'const form = document.querySelector("#profile-form");',
'const email = document.querySelector("#profile-email");',
'const error = document.querySelector("#profile-email-error");',
'',
'function setEmailError(message) {',
' const invalid = Boolean(message);',
' email.setAttribute("aria-invalid", String(invalid));',
' error.hidden = !invalid;',
' error.textContent = message;',
'}',
'',
'form.addEventListener("submit", (event) => {',
' event.preventDefault();',
' const message = email.validity.valid ? "" : "Введите адрес в формате name@example.com";',
' setEmailError(message);',
' if (message) email.focus();',
'});',
].join('\n');
const semanticsFixture = [
'<!-- Видимое слово не становится именем само по себе. -->',
'<span class="field-title">Поиск</span>',
'<input id="query" type="search" />',
'',
'<!-- У label есть формальная связь с control. -->',
'<label for="query-good">Поиск</label>',
'<input id="query-good" type="search" />',
'',
'<!-- Обычное действие лучше выражать нативной кнопкой. -->',
'<div class="save" tabindex="0">Сохранить</div>',
'<button type="button">Сохранить</button>',
'',
'<!-- Положительные tabindex меняют порядок относительно документа. -->',
'<a href="/account" tabindex="3">Профиль</a>',
'<input id="phone" type="tel" />',
].join('\n');
const errorFixture = [
'<form id="registration" novalidate>',
' <label for="registration-email">Почта</label>',
' <input id="registration-email" type="email" required',
' aria-describedby="registration-email-hint registration-email-error"',
' aria-errormessage="registration-email-error" aria-invalid="false" />',
' <p id="registration-email-hint">Ссылка для входа придёт на этот адрес.</p>',
' <p id="registration-email-error" hidden></p>',
' <button type="submit">Создать аккаунт</button>',
'</form>',
'',
'const input = document.querySelector("#registration-email");',
'const message = document.querySelector("#registration-email-error");',
'',
'function renderEmailError(text) {',
' const hasError = Boolean(text);',
' input.setAttribute("aria-invalid", String(hasError));',
' message.hidden = !hasError;',
' message.textContent = text;',
'}',
'',
'document.querySelector("#registration").addEventListener("submit", (event) => {',
' event.preventDefault();',
' const text = input.validity.valid ? "" : "Укажите почту в формате name@example.com";',
' renderEmailError(text);',
' if (text) input.focus();',
'});',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2019-09-practice-accessibility-basics',
title: 'Базовая доступность: пройти форму с клавиатуры до первого релиза',
categories: ['Доступность', 'Frontend', 'Практика'],
cover: '/assets/editorial/2019/accessibility-keyboard-route-2019.svg',
excerpt: 'Форма выглядит готовой, пока фокус не пропадёт, поле не останется без имени, а ошибка не окажется только красной рамкой. Собираем короткий маршрут проверки в браузере.',
readingMinutes: 13,
},
[
paragraph('Симптом обычно обнаруживается поздно: форма отправляется мышью, но после Tab фокус исчезает на светлом фоне, поле «Почта» в дереве браузера не получает имени, а после submit остаётся только красная рамка. Цена не в одном CSS-правиле. Пользователь не понимает, где находится и что исправлять, а команда получает дефект уже после того, как разметка разошлась по нескольким экранам.'),
paragraph('Для первого прохода не нужен большой аудит всего продукта. Берём одну форму, один путь с клавиатуры и три наблюдаемых свойства: виден ли фокус, есть ли у control роль и имя, остаётся ли ошибка текстом рядом с полем. Затем смотрим это в настоящем браузере, а не объявляем DOM-домысел доказательством. Такой маршрут подходит для фронтенд-проекта 2019 года: он не требует отдельной платформы, но оставляет проверяемый результат в задаче.'),
heading('Сначала фиксируем пользовательский сбой'),
paragraph('Запись «доступность не готова» слишком широкая: по ней нельзя выбрать правку. В тестовом сценарии лучше назвать конкретную операцию. Например: открыть форму профиля, перейти Tab в поле почты, оставить поле пустым, отправить форму, понять по фокусу и тексту, какое действие нужно дальше. Если один из шагов не воспроизводится, это уже не общая оценка, а дефект в маршруте.'),
paragraph('В этом сценарии DOM нужен, но его мало. Инспектор покажет, что у input есть id и класс. Он не заменяет последовательный переход фокуса, вычисленное имя control и видимое состояние после ошибки. Семантика формируется нативным HTML, атрибутами и текущим состоянием страницы; браузер показывает её через реальное поведение и своё дерево доступности. Поэтому я сначала прохожу страницу клавиатурой, а потом открываю DevTools для конкретного активного элемента.'),
dataTable(
'Три быстрых симптома и минимальная проверка',
['Симптом', 'Вероятная причина', 'Что увидеть в браузере', 'Первая правка'],
[
['После Tab непонятно, где курсор', 'outline отключён или визуальный индикатор не отличается от фона', 'активный элемент меняется, но видимой рамки или заливки нет', 'вернуть заметный :focus-стиль и перепроверить на реальном фоне'],
['Поле имеет текст рядом, но имени нет', 'видимая подпись сделана span или div без связи с input', 'в дереве браузера у textbox пустое или неверное name', 'связать label и input через for/id либо вложить control в label'],
['После submit есть только красная граница', 'ошибка не описана текстом или состояние aria-invalid не синхронизировано', 'у поля нет понятного error text, а ошибочный элемент не отмечен', 'вывести текст ошибки, установить aria-invalid и оставить связь с полем'],
],
),
paragraph('Таблица не обещает, что одна проверка закроет все особенности вспомогательных технологий. Она отделяет очевидную поломку интерфейса от предположения. Пустое имя у textbox можно исправить разметкой. Слышимость динамического сообщения уже требует отдельной проверки с выбранной технологией, и без такого прогона нельзя писать, что сообщение точно прозвучало.'),
heading('Минимальная форма: имя, описание и ошибка'),
paragraph('В следующей фикстуре подпись связана с input через for/id. Подсказка и место для ошибки перечислены в aria-describedby; при неверном значении код синхронно меняет aria-invalid, делает текст ошибки видимым и возвращает фокус в поле только после неудачной отправки. Код не является сертификатом доступности. Он делает отношения в разметке явными, чтобы их можно было проверить.'),
codeBlock(keyboardFixture),
paragraph('У label есть конкретная задача: HTML связывает caption с labelable form control по for/id или вложению control в label. Рядом стоящий span такой связи сам по себе не создаёт. Это полезная граница для код-ревью: не обсуждаем, похоже ли слово на подпись, а проверяем, может ли браузер сопоставить его с полем.'),
paragraph('В примере error element присутствует рядом с полем, но скрыт, пока значения достаточно. Когда валидация нашла проблему, текст становится видимым, а aria-invalid получает true. WAI-ARIA 1.1 отдельно связывает aria-errormessage с aria-invalid и требует не прятать актуальный текст. Даже при такой разметке я не выдаю факт объявления сообщения за доказанный: его подтверждает отдельный ручной прогон с нужной вспомогательной технологией.'),
heading('Клавиатурный маршрут, который можно вложить в задачу'),
paragraph('Маршрут ниже безопасен для локального стенда или тестового контура: он ничего не отправляет на сервер и не требует учётной записи. Цель — записать факты до правки и после неё. Если форма зависит от сети, подставляем тестовый ответ или отключаем submit, но не делаем вывод по одному скриншоту DOM.'),
orderedList([
'Открыть страницу в поддерживаемом проектом браузере в чистом состоянии. Не ставить курсор мышью внутрь формы: начать последовательную навигацию с начала документа.',
'Нажимать Tab и записывать порядок: ссылка пропуска блока, поле, следующее поле, кнопка. На каждом шаге проверить, что индикатор фокуса различим на настоящем фоне и что фокус не уходит на декоративный элемент.',
'Нажать Shift+Tab из кнопки и убедиться, что обратный путь возвращает к ожидаемому control. Это ловит разрыв порядка, который иногда скрывает положительный tabindex.',
'Оставить обязательное поле пустым, активировать submit с клавиатуры и записать, куда попал фокус. Если команда переводит фокус в поле или summary, это должно быть одним предсказуемым решением, а не случайным побочным эффектом обработчика.',
'Проверить, что рядом есть текст ошибки с понятной причиной, а не только цвет, и что исходная подсказка не исчезла без замены. В примере пользователь видит формат адреса, который ожидает форма.',
'Открыть панель Accessibility для активного поля и сверить role, name, description, invalid state и текст ошибки. Зафиксировать браузер и версию в задаче, потому что это наблюдение конкретного user agent.',
]),
heading('Почему проверяем браузер, а не только DOM'),
paragraph('DOM-дерево отвечает на вопрос, какие узлы создал код. Пользовательский путь отвечает на другой вопрос: к какому узлу можно последовательно прийти, что видно при фокусе и какое имя браузер вычислил для control. Эти ответы могут расходиться. Например, input с id существует в DOM, но внешняя подпись не связана с ним; div c click-обработчиком виден мышью, но его клавиатурная модель зависит от дополнительной реализации.'),
paragraph('В DevTools выбираем именно элемент, на котором стоит фокус после нужного шага. Затем смотрим роль, имя, описание и состояние. У обычного input type=email ожидается нативная роль textbox/field в представлении конкретного браузера; не надо добавлять role только ради похожего слова. Проверяем, что name совпадает с человеческой подписью «Рабочая почта», а description содержит подсказку и актуальную ошибку там, где браузер её показывает.'),
paragraph('Такой снимок дерева не равен тесту со screen reader. Он помогает поймать пустое имя, лишний aria-label или потерянную связь до дорогого ручного прогона. Последний шаг требует выбрать браузер и вспомогательную технологию из поддерживаемой матрицы проекта, пройти тот же маршрут и записать наблюдение отдельно. В этой статье такой прогон не выполнялся.'),
figure('/assets/editorial/2019/accessibility-keyboard-route-2019.svg', 'Вертикальная схема клавиатурного маршрута формы: видимый фокус приводит к полю с именем, затем к действию, а неудачная отправка показывает текст ошибки и возвращает проверяемый фокус', 'Короткий маршрут проверки: фокус, имя control, действие, текстовая ошибка. Схема задаёт порядок наблюдений, а не результат выполнения в конкретном браузере.'),
heading('Фокус — часть действия, а не декоративная рамка'),
paragraph('WCAG 2.1 требует, чтобы при последовательной навигации порядок фокуса сохранял смысл и работоспособность операции. Отдельно для интерфейса, которым управляют с клавиатуры, нужен режим с видимым индикатором фокуса. Поэтому :focus не стоит заменять на пустое outline:none без равноценной видимой альтернативы. В обычной форме чаще всего выгоднее сохранить порядок документа и нативные элементы, чем собирать новый порядок через положительные tabindex.'),
paragraph('Положительный tabindex создаёт собственный относительный порядок фокусировки. На короткой странице это может казаться удобной правкой: поле «поднимают» перед кнопкой. Через месяц рядом появляется ещё один control, и маршрут перестаёт совпадать с визуальным порядком. Для начального ремонта сначала убрать искусственные номера, вернуть нормальную DOM-последовательность и пройти путь Tab ещё раз.'),
heading('Границы этой проверки'),
bulletList([
'Этот маршрут не измеряет контраст, масштабирование текста, работу жестов, язык страницы и все критерии WCAG. Он закрывает один узкий риск: базовое управление формой с клавиатуры и понятный текст ошибки.',
'Панель Accessibility зависит от браузера и версии. Результат нужно прикладывать как наблюдение конкретного браузера, а не выдавать за универсальное описание всех accessibility API.',
'Наличие aria-атрибута не гарантирует полезный опыт. Если пользовательский текст плохой или ошибка появляется в неожиданном месте, дерево может выглядеть формально полным, а сценарий всё равно останется запутанным.',
'Автор не выполнял здесь прогон со screen reader и не утверждает, что динамическая ошибка была произнесена. План такого прогона должен повторить тот же сценарий после согласования тестовой среды.',
]),
heading('Итог: оставить после правки проверяемый маршрут'),
paragraph('Для базовой доступности формы я не начинаю с набора ARIA-атрибутов. Сначала называю поломку: пропал фокус, у control нет имени или ошибка не стала текстом. Затем прохожу клавиатурный маршрут, смотрю активный элемент и его представление в браузере, правлю нативную семантику и повторяю маршрут. Это небольшой объём работы, но он связывает разметку с реальным действием пользователя и не прячет дефект за красивой рамкой.'),
],
[wcagFocusOrder, wcagFocusVisible, wcagErrorIdentification, htmlLabel, htmlTabindex, ariaDescribedBy, ariaErrorMessage],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2019-09-mechanism-accessibility-basics',
title: 'Под капотом: почему DOM не доказывает имя, роль и фокус',
categories: ['Доступность', 'Frontend', 'Разбор механизма'],
cover: '/assets/editorial/2019/accessibility-computed-semantics-2019.svg',
excerpt: 'В DOM есть input и кнопка, но пользователю всё равно не за что зацепиться. Разбираем, где HTML даёт браузеру семантику и почему финальная проверка происходит по живому маршруту.',
readingMinutes: 13,
},
[
paragraph('Симптом коварен именно потому, что в код-ревью всё выглядит живым: input отрисован, обработчик клика срабатывает, классы ошибок меняются. В браузере пользователь проходит Tab и получает control без понятного имени либо теряет ожидаемый порядок. Цена такого дефекта — не только недоступный экран. Команда начинает добавлять role, tabindex и обработчики наугад, а затем уже не понимает, какое из дополнений отвечает за поведение.'),
paragraph('Разберём границу без мистики. DOM хранит узлы и атрибуты. HTML задаёт нативные правила для form controls и интерактивных элементов. Браузер применяет эти правила к текущей странице и предоставляет представление, которое удобно проверять в accessibility tree. Поэтому строка разметки и фактическое имя control — связанные, но не одинаковые объекты. В 2019-м для такой диагностики достаточно одной формы, браузера и дисциплины: смотреть реальный фокус до того, как менять API.'),
heading('Три слоя, которые нельзя смешивать'),
paragraph('Первый слой — DOM: порядок узлов, id, текст и атрибуты. Второй — поведение браузера: какой узел участвует в последовательной фокусировке, что происходит при Enter и Space, какую роль даёт нативный HTML. Третий — доступное представление: имя, роль, описание и состояние, которые пользовательские технологии получают через браузер и платформенный API. Ошибка появляется, когда разработчик проверил первый слой и по нему объявил третий готовым.'),
paragraph('Например, span со словом «Поиск» рядом с input виден в DOM и на макете. Но HTML связывает caption с конкретным form control через label: for должен указывать на id labelable element, либо control должен быть вложен в label. Внешний span не становится связанной подписью только потому, что стоит близко. У этого случая есть точная проверка: открыть живой input в панели Accessibility и сравнить вычисленное name с текстом, который видит человек.'),
paragraph('Так же устроена и роль. Нативная button уже несёт знакомое браузеру поведение. div с click-обработчиком заставляет автора отдельно продумать доступность с клавиатуры, фокус, активацию и состояние. Иногда компоненту действительно нужна собственная модель, но базовый экран не должен получать её случайно только ради внешнего вида. Чем больше самодельных правил, тем больше маршрутов придётся проверять.'),
figure('/assets/editorial/2019/accessibility-computed-semantics-2019.svg', 'Схема преобразования семантики: подпись label и input с id проходят через браузер и дают проверяемые role, name, description и state; рядом показан input с несвязанным span и пустым именем', 'DOM — вход для браузера, а не финальный отчёт о доступности. Проверяем вычисленное представление выбранного control.'),
heading('Имя начинается с нативной связи'),
paragraph('Ниже намеренно показаны две пары похожей разметки. В плохом варианте текст рядом с input и div-кнопка могут выглядеть правильно. В лучшем варианте label связан с control, а действие выражено button. Это не запрет на компонентный UI. Это точка, с которой удобно начинать: сначала даём браузеру честную семантику, потом добавляем компонентные детали только там, где задача этого требует.'),
codeBlock(semanticsFixture),
paragraph('Строка aria-label не является универсальной заменой label. Она может дать имя элементу, но видимая подпись и вычисленное имя способны разойтись. Для поля формы label одновременно объясняет действие на экране и создаёт установленную HTML-связь. Если имя строится из нескольких фрагментов или контроль нетипичен, решение нужно проверить на конкретной реализации, а не переносить механически из другого компонента.'),
paragraph('WCAG 2.1 формулирует для компонентов понятную цель: их name, role и значения или состояния должны быть программно определимы. В статье это не превращается в обещание «одного aria-атрибута». Сначала задаём role нативным элементом, name — текстом и label, state — актуальной разметкой. Затем смотрим, что вышло в браузере.'),
heading('Порядок фокуса — это порядок исполнения'),
paragraph('Tab не должен быть вторым пользовательским сценарием после мыши. Для части людей это основной способ пройти форму, меню или модальное окно. При последовательной навигации WCAG требует порядок, который сохраняет смысл и работоспособность. Это означает не «сделать красиво по документу», а проверить фактическую цепочку: после текущего поля куда попадёт фокус и может ли пользователь продолжить действие без угадывания.'),
paragraph('Положительный tabindex меняет порядок относительно других элементов. В небольшом фрагменте число 3 кажется прямым решением: оно помещает ссылку раньше. Но порядок начинает жить отдельно от разметки, а следующая правка компонента легко создаёт прыжок. Для обычной страницы лучше собрать DOM в логическом порядке, оставить нативные focusable elements и пользоваться tabindex=0 только там, где элементу действительно нужна включённость в естественную последовательность.'),
dataTable(
'Что проверять на каждом слое',
['Слой', 'Недостаточное доказательство', 'Проверяемый факт', 'Действие'],
[
['DOM', 'у input есть id, а возле него есть текст', 'label связан с этим control через for/id или вложение', 'исправить структуру, не добавлять случайный aria-label'],
['Фокус', 'в разметке есть tabindex или обработчик keydown', 'Tab и Shift+Tab дают ожидаемый порядок, а фокус виден', 'убрать положительные tabindex, восстановить порядок документа и :focus-стиль'],
['Доступное представление', 'атрибут aria-describedby присутствует', 'в выбранном браузере у активного control есть ожидаемые role, name, description и invalid state', 'сверить ID-ссылки и живое состояние после ошибки'],
['Динамика', 'в DOM появился div с текстом ошибки', 'ошибка видима пользователю и имеет связь с ошибочным полем', 'синхронизировать текст, hidden и aria-invalid; запланировать проверку со вспомогательной технологией'],
],
),
heading('ARIA добавляет связь, но не отменяет HTML'),
paragraph('aria-describedby создаёт список ссылок на элементы, составляющие полное описание. Это подходит для короткой подсказки рядом с полем и для актуального текста ошибки, если связи действительно существуют. Но атрибут не создаёт текст из воздуха: referenced element должен содержать понятные слова и быть в ожидаемом состоянии. Если id опечатан или ошибка остаётся скрытой, формальная строка не даст пользователю полезного объяснения.'),
paragraph('aria-errormessage имеет более узкую роль. WAI-ARIA 1.1 требует использовать его вместе с aria-invalid; пока поле валидно, сообщение не применимо, а когда ошибка есть — соответствующий текст не должен быть скрыт. Это причина не ставить атрибут в шаблон и забывать о состоянии. Нужен один владелец: функция валидации меняет visible text и aria-invalid в одном месте, а тест повторяет ошибочный submit.'),
paragraph('Не стоит назначать ARIA-роль нативному input или button, если она совпадает с их поведением. Лишняя роль создаёт ещё одно место для ошибки и не делает интерфейс «более доступным». Для нестандартного виджета потребуются отдельные keyboard interactions и состояние; в базовом ремонте формы выгоднее уменьшить эту поверхность, а не расширять её.'),
heading('Маршрут технической проверки'),
orderedList([
'Взять один реальный экран и назвать операцию: поиск, сохранение профиля или регистрация. Выписать controls в том порядке, в каком пользователь должен пройти их с клавиатуры.',
'Прочитать разметку только для первой гипотезы: проверить label/for/id, нативные button и link, hidden-элементы, положительные tabindex и обработчики submit.',
'Открыть страницу в браузере, пройти Tab и Shift+Tab без мыши, записать active element, видимый фокус и факт активации Enter или Space там, где он предусмотрен.',
'На каждом спорном control открыть Accessibility tree этого браузера. Сверить role, name, description и состояния с тем, что должно быть видно пользователю на этом шаге.',
'Вызвать ошибку формы, проверить текст, связь с полем и предсказуемый фокус. Не записывать «сообщение объявлено», пока тот же путь не выполнен с согласованной вспомогательной технологией.',
'Сделать одну минимальную правку, повторить маршрут с чистого состояния и приложить результат: браузер, версия, шаги, ожидаемое и фактическое поведение.',
]),
heading('Что browser tree подтверждает, а что нет'),
paragraph('Дерево доступности полезно как прибор диагностики. Оно позволяет поймать пустое name, неожиданную role, потерянное description или неактуальный invalid state до ручной проверки со screen reader. Но у него есть границы. В нём нельзя заменить пользовательский опыт чтения длинного текста, последовательность озвучивания динамических изменений или матрицу поддержки конкретной связки браузер плюс технология.'),
paragraph('Поэтому в отчёте лучше разделить результаты. «В Firefox версии N на поле после неудачного submit видны name, description и invalid=true» — наблюдение браузера. «Пользователь услышал ошибку в выбранном screen reader» — другой эксперимент, который нужно действительно провести. Такое разделение не ослабляет статью; оно не даёт команде принять догадку за готовность.'),
heading('Итог: проверяем смысл элемента на живом пути'),
paragraph('DOM остаётся исходным материалом, но проверка заканчивается не на нём. Делаем нативный HTML носителем роли и имени, держим логический порядок фокуса, связываем описание и ошибку с control, после чего идём по странице клавиатурой и смотрим вычисленное представление в браузере. Это даёт короткий, воспроизводимый диагноз вместо накопления случайных ARIA-правок.'),
],
[wcagFocusOrder, wcagFocusVisible, wcagNameRoleValue, htmlLabel, htmlTabindex, ariaDescribedBy, ariaErrorMessage],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2019-09-field-accessibility-basics',
title: 'Разбор: красная ошибка формы не становится контрактом сама',
categories: ['Доступность', 'Frontend', 'Полевой разбор'],
cover: '/assets/editorial/2019/accessibility-error-contract-2019.svg',
excerpt: 'У поля появилась красная рамка, но пользователь не получает объяснение и не возвращается к ошибке. Собираем контракт между валидацией, текстом, фокусом и проверкой в браузере.',
readingMinutes: 14,
},
[
paragraph('Симптом в форме регистрации выглядит мелким: после submit у поля почты появляется красная рамка. Мышью её замечают, вводят значение и идут дальше. С клавиатурой фокус остаётся на кнопке, текст ошибки не появляется или не связан с input, а значение ожидаемого формата приходится угадывать. Цена — сорванная операция и повторная правка общего компонента формы, когда похожая ошибка уже разъехалась по нескольким страницам.'),
paragraph('Разбирать такой случай полезнее как контракт, а не как выбор цвета. Валидация должна назвать ошибку текстом, control должен получить актуальное состояние, пользователь должен понять, где продолжить путь, а браузер должен показать эти отношения в живой странице. Нельзя заменить эту цепочку одной проверкой DOM: в нём может лежать правильный p с сообщением, пока он hidden, а фокус и вычисленное описание остаются другими.'),
heading('Картина до правки: четыре вопроса к одной ошибке'),
paragraph('Возьмём поле «Почта» в регистрации. Серверная проверка и формат могут быть шире, чем type=email, но для клиентского примера достаточно пустого или заведомо неверного значения. Важнее не валидатор, а последствия его результата. После неудачной отправки нужно ответить: какое поле неверно, есть ли объяснение в тексте, как это отражено в состоянии control и какой следующий клавиатурный шаг ожидается.'),
paragraph('Красный border отвечает только на часть вопроса «есть ли проблема». Он не называет поле в тексте, не объясняет формат и может исчезнуть в пользовательской теме. WCAG 2.1 для автоматически определённой input error требует идентифицировать ошибочный элемент и описать ошибку пользователю текстом. Поэтому первый технический артефакт — не палитра, а связанная строка «Укажите почту в формате name@example.com».'),
dataTable(
'Контракт ошибки поля почты',
['Наблюдение', 'Причина разрыва', 'Проверка в браузере', 'Исправление'],
[
['Красная рамка есть, текста нет', 'состояние выражено только стилем', 'после submit найти видимое текстовое сообщение рядом с полем', 'добавить короткое описание ошибки и не прятать его, пока поле не исправлено'],
['Текст существует, но control не отмечен', 'ошибка рендерится отдельным компонентом без синхронизации состояния', 'у активного input нет invalid state в Accessibility tree', 'менять aria-invalid в той же функции, что выводит текст'],
['Фокус остаётся на submit', 'валидация не определила следующий шаг для клавиатуры', 'после неудачного submit записать document.activeElement и видимый индикатор', 'согласованно перевести фокус к первому ошибочному control или к error summary'],
['Подсказка и ошибка спорят друг с другом', 'aria-describedby ссылается не на те ID либо текст заменён без контекста', 'сверить name, description и текущий текст рядом с полем', 'оставить понятную подсказку и добавить конкретную ошибку без дублирования'],
],
),
paragraph('Таблица не диктует единственный UX. На длинной форме команда может выбрать error summary в начале, на короткой — первое ошибочное поле. В обоих вариантах нужно зафиксировать маршрут и не менять фокус при каждом символе. Фокус после submit — реакция на завершённую операцию; во время набора внезапный прыжок мешает исправлять значение.'),
heading('Один владелец состояния ошибки'),
paragraph('Ниже валидация и представление ошибки собраны в одной функции. Она принимает текст, выводит его, меняет hidden и синхронизирует aria-invalid. При ошибке submit фокус возвращается в поле. Такой код не решает серверную валидацию и не доказывает озвучивание сообщения, но не оставляет четыре независимые точки, которые могут разойтись при следующем изменении компонента.'),
codeBlock(errorFixture),
paragraph('В этом примере aria-describedby соединяет field с подсказкой и контейнером ошибки. WAI-ARIA описывает его как ID reference list для полного description. aria-errormessage дополнительно указывает на custom error message и используется вместе с aria-invalid. При валидном значении сообщения нет в действии; при ошибке текст должен стать доступен пользователю. Эти отношения стоит проверять вместе, а не искать в JSX отдельные атрибуты.'),
paragraph('Тип сообщения тоже имеет значение. «Неверно» не даёт следующего шага. «Укажите почту в формате name@example.com» называет поле и ожидаемое исправление. Если формат определяется сервером или бизнес-правилом, текст не должен обещать больше, чем реально проверяет клиент. В тикете удобно отдельно записать: клиент ловит пустое значение и базовый формат, сервер остаётся источником окончательного решения.'),
heading('Фокус после submit: не прятать выбор в обработчике'),
paragraph('Обработчик submit уже знает, что пользователь попытался завершить форму. В этот момент допустимо выбрать следующий фокусируемый объект и сделать его видимым. Но выбор должен быть частью контракта страницы. Если фокус идёт на первое ошибочное поле, там должен быть виден индикатор и доступен текст причины. Если фокус идёт на summary, summary должна содержать ссылки или понятный порядок к ошибкам.'),
paragraph('Не стоит автоматически фокусировать каждое поле в момент изменения value. Такая правка обрывает набор, особенно когда валидатор срабатывает на input. Сначала определяем событие: submit, blur или явная проверка. Затем для каждого события задаём предсказуемое поведение. В нашем сценарии проверяем именно submit: его легко повторить с клавиатуры и сравнить до/после.'),
figure('/assets/editorial/2019/accessibility-error-contract-2019.svg', 'Схема контракта ошибки формы: submit приводит к валидации, затем одновременно обновляются текст ошибки и aria-invalid, фокус переходит к первому ошибочному полю, а проверка идёт по клавиатуре и дереву браузера', 'Ошибка формы — не цвет. В ней должны согласованно меняться текст, состояние поля, решение о фокусе и наблюдение в браузере.'),
heading('Ручной сценарий клавиатуры и план дерева доступности'),
paragraph('Ниже не отчёт о выполненном браузерном или assistive-technology прогоне. Это безопасный сценарий, который команда запускает на локальном стенде до релиза. Он не требует отправлять персональные данные: используется тестовый адрес, а submit может быть перехвачен. Результат нужно хранить как пару «ожидание — факт» с браузером и версией.'),
orderedList([
'Открыть форму в исходном состоянии, перейти Tab до поля почты и удостовериться, что его фокус различим. В DevTools записать role, name и description этого control, не подменяя запись скриншотом исходного HTML.',
'Оставить поле пустым или ввести тестовое неверное значение. Перейти Tab до submit и активировать его с клавиатуры, не касаясь мыши.',
'Проверить, что неверное поле имеет видимый текст ошибки, не только изменение border. Сверить, что текст называет исправление, а не просто сообщает о неуспехе.',
'Записать активный элемент после submit. Если продукт выбрал фокус на первом неверном поле, он должен быть там; если выбран summary, следующий Tab должен вести к понятному способу исправления.',
'Открыть дерево доступности для ошибочного input и сверить name, description, invalid state и видимость связанного сообщения в этом браузере. Если вывод отличается от ожидания, приложить факт, а не угадывать причину.',
'Запланировать отдельный повтор со screen reader из поддерживаемой матрицы. Только после выполненного прогона можно фиксировать, как динамическое изменение воспринимается выбранной технологией.',
]),
heading('Почему дерево браузера нужно вместе с клавиатурой'),
paragraph('Клавиатура показывает порядок и видимый фокус. Accessibility tree показывает, какую семантику браузер собрал для активного элемента. По отдельности проверки неполны: можно увидеть хорошую рамку, но пустое name; можно увидеть aria-invalid в панели, но не заметить, что Tab после submit оставил пользователя на кнопке. Сценарий связывает эти два факта одной операцией.'),
paragraph('Для базовой формы не нужно строить собственную систему ролей. Нативные label, input и button уже дают браузеру значительную часть поведения. Работы ARIA здесь ровно столько, сколько нужно для отношений с подсказкой и error message. Чем меньше кастомной интерактивности вокруг обязательного поля, тем проще сопоставить код, фокус и представление браузера при регрессии.'),
heading('Проверка перед выпуском и известные границы'),
paragraph('После правки полезно добавить короткий регрессионный тест на DOM-состояние: при неудачном submit поле получает aria-invalid=true, текст ошибки перестаёт быть hidden, а обработчик выбирает ожидаемый фокус. Такой тест не заменит живой browser route, но не даст случайной правке удалить ID-связь или поменять текстовую ошибку на одну рамку. Ручной сценарий остаётся выпускной проверкой для фокуса и дерева браузера.'),
bulletList([
'Статья не утверждает соответствие всей страницы WCAG 2.1. Проверяется один контракт ошибки в одной форме; контраст, язык, масштабирование, сложные виджеты и остальные экраны требуют своих сценариев.',
'aria-errormessage и aria-invalid не заменяют видимый текст. Если error element скрыт или сообщение не объясняет исправление, формальная ссылка не помогает пользователю.',
'Автор не запускал в этом пакете browser automation, screen reader или реальное серверное API. Утверждения о них заменены воспроизводимым планом проверки.',
'Если в продукте есть asynchronous validation, нужно отдельно описать отмену старого ответа, состояние ожидания и момент, когда ошибка становится актуальной. Этот базовый пример намеренно не имитирует сеть.',
]),
heading('Итог: ошибка становится частью маршрута'),
paragraph('В форме доступная ошибка начинается не с ARIA и не с красного класса. Сначала определяем пользовательский сбой, затем делаем текст ошибки видимым, связываем его с control, обновляем invalid state и выбираем предсказуемый фокус после submit. После этого идём по странице клавиатурой и смотрим конкретный input в дереве браузера. Такой контракт можно повторить на следующей форме и проверить без догадок.'),
],
[wcagFocusOrder, wcagFocusVisible, wcagErrorIdentification, wcagNameRoleValue, htmlLabel, ariaDescribedBy, ariaErrorMessage],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isDirectRun = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2019-09.mjs --print-revisions\n');
process.exitCode = 1;
}
}