This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(code) {
|
||||
return '<pre><code>' + String(code).trim() + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(caption, headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user