From bd09f3369c7acf0466995ccb2611cbc93f924eae Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 10:56:45 +0300 Subject: [PATCH] revise September 2019 accessibility articles --- editorial/production/README.md | 2 +- editorial/reviews/2019-09-draft.md | 123 ++++++ web/data/editorial-revisions.mjs | 2 + .../accessibility-computed-semantics-2019.svg | 41 ++ .../accessibility-error-contract-2019.svg | 46 +++ .../accessibility-keyboard-route-2019.svg | 45 +++ web/scripts/upgrade-2019-09.mjs | 366 ++++++++++++++++++ 7 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2019-09-draft.md create mode 100644 web/public/assets/editorial/2019/accessibility-computed-semantics-2019.svg create mode 100644 web/public/assets/editorial/2019/accessibility-error-contract-2019.svg create mode 100644 web/public/assets/editorial/2019/accessibility-keyboard-route-2019.svg create mode 100644 web/scripts/upgrade-2019-09.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index 77ef9ef..ebf49a9 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 58 из 358 созданных материалов. Остальные 300 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 61 из 358 созданных материалов. Остальные 297 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2019-09-draft.md b/editorial/reviews/2019-09-draft.md new file mode 100644 index 0000000..9ec8dcc --- /dev/null +++ b/editorial/reviews/2019-09-draft.md @@ -0,0 +1,123 @@ +# P19 · сентябрь 2019 · базовая веб-доступность — тройное ревью + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry применяет +ровно три ревизии по стабильным slug и сохраняет дату и автора базового архива: + +- editorial-2019-09-practice-accessibility-basics; +- editorial-2019-09-mechanism-accessibility-basics; +- editorial-2019-09-field-accessibility-basics. + +Модуль экспортирует ровно три revision без полей date и +author. При прямом вызове с --print-revisions он +пишет только JSON, совпадающий с import-safe export. Черновик намеренно не +выдаёт план ручной проверки за пройденный реальный прогон со screen reader. + +## Проход 1. Факты и техника — пройдено + +| Утверждение или решение | Первичный источник | Проверенная граница | +| --- | --- | --- | +| При последовательной навигации порядок фокуса сохраняет смысл и работоспособность операции | [WCAG 2.1, 2.4.3 Focus Order](https://www.w3.org/TR/WCAG21/#focus-order) | Тексты не выдают произвольный положительный tabindex за решение; сначала требуется пройти Tab и Shift+Tab в браузере | +| Интерфейс, которым управляют с клавиатуры, нуждается в режиме с видимым индикатором фокуса | [WCAG 2.1, 2.4.7 Focus Visible](https://www.w3.org/TR/WCAG21/#focus-visible) | Не заявляется достаточность одного CSS-правила: проверяется различимость на реальном фоне при фокусе | +| У автоматически обнаруженной input error должны быть идентифицированы ошибочный элемент и текстовое описание ошибки | [WCAG 2.1, 3.3.1 Error Identification](https://www.w3.org/TR/WCAG21/#error-identification) | Красная рамка не названа текстовой ошибкой; во всех примерах есть видимое объяснение и связь с полем | +| Компонентам интерфейса требуются программно определимые name, role, value или state | [WCAG 2.1, 4.1.2 Name, Role, Value](https://www.w3.org/TR/WCAG21/#name-role-value) | Статья о механизме отличает DOM-узел от проверяемого role/name/state в browser accessibility tree | +| HTML label связывается с form control через for/id либо вложение control | [HTML Standard: label element](https://html.spec.whatwg.org/multipage/forms.html#the-label-element) | Текст рядом с input не объявлен именем сам по себе; пример использует label и уникальный id | +| Положительный tabindex создаёт отдельный относительный порядок последовательной фокусировки | [HTML Standard: tabindex](https://html.spec.whatwg.org/multipage/interaction.html#the-tabindex-attribute) | Не обещается, что tabindex «чинит» доступность; маршрут Tab проверяется на собранной странице | +| aria-describedby — список ID для полного description, а aria-errormessage работает вместе с aria-invalid и актуальная ошибка не должна быть скрыта | [WAI-ARIA 1.1: aria-describedby](https://www.w3.org/TR/wai-aria-1.1/#aria-describedby), [WAI-ARIA 1.1: aria-errormessage](https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage) | Пример синхронизирует текст, hidden и aria-invalid; он не заявляет, что сообщение уже было озвучено вспомогательной технологией | + +### Честная граница исследования + +Источники сверены с нормативными WCAG 2.1, HTML Standard и WAI-ARIA 1.1. +Кодовые фрагменты — воспроизводимые примеры отношений в разметке и локального +состояния, а не отчёт о запуске браузерной автоматизации. Ни screen reader, ни +реальный браузерный прогон страницы в рамках P19 не выполнялись. Поэтому +формулировки «проверить в Accessibility tree» и «пройти клавиатурный маршрут» +в статьях являются планом ручной проверки; они не выданы за наблюдённый +результат. + +Вердикт прохода: **пройден**. Факты привязаны к первичным источникам, а +свойства, зависящие от браузера, версии или вспомогательной технологии, +отделены от уже проверенной разметки. + +## Проход 2. Редактура и голос M2 / 2019 — пройдено + +| Ревизия | Симптом и цена в начале | Главный вопрос | Практический артефакт и ограничение | +| --- | --- | --- | --- | +| Практика | Фокус исчезает, у поля нет имени или ошибка остаётся рамкой; цена — сорванное действие и поздняя переделка формы | Как пройти один безопасный keyboard route до релиза | Форма с label, text error и планом DevTools; нет заявления о выполненном screen-reader run | +| Механизм | DOM и click-обработчик есть, но role/name/focus не доказаны; цена — хаотичные role и tabindex | Где заканчивается DOM и начинается наблюдаемая семантика браузера | Сравнение разметки, таблица слоёв и маршрут проверки; browser tree не выдан за универсальную матрицу поддержки | +| Полевой разбор | Красная ошибка не объясняет проблему и оставляет фокус на submit; цена — пользователь угадывает исправление | Как собрать контракт между валидацией, текстом, состоянием и фокусом | Одна функция состояния ошибки и последовательность ручной проверки; сеть и assistive technology не имитируются | + +- Первые абзацы во всех трёх текстах содержат слова «симптом», «проблема» или + конкретный сбой и называют цену. Дальше выдержана практическая цепочка: + симптом → причина → проверка → действие → граница. +- Голос соответствует M2 / 2019: автор уверенно работает с HTML, формами, + модульным JavaScript и browser tooling, но не приписывает себе опыт + платформенной доступности, поздние SLO-практики или масштабные + организационные процессы. +- Техническая речь короткая и предметная. В ней названы label, + for, id, aria-describedby, + aria-invalid, tabindex, active element, role, + name, description и конкретные шаги проверки. Шаблонные обобщения и + обещания «универсальной доступности» удалены. +- Каждая статья содержит не менее пяти смысловых разделов, доступную таблицу + с caption/thead, figure с alt/caption, код, + упорядоченный маршрут и список первичных источников. + +Вердикт прохода: **пройден**. Материалы продолжают фронтенд-ветку автора +2019 года и не маскируют проверяемые условия общими советами. + +## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета + +- accessibility-keyboard-route-2019.svg ведёт вертикально от + видимого фокуса через именованное поле к текстовой ошибке. Короткие подписи + и viewBox без фиксированной ширины позволяют схеме уменьшаться вместе с + контейнером статьи. +- accessibility-computed-semantics-2019.svg сопоставляет + несвязанную подпись рядом с input и семантически связанную пару label/input. + Схема показывает, что именно нужно проверить в браузере: role, name и + description, а не заявляет результат screen-reader run. +- accessibility-error-contract-2019.svg показывает одновременное + изменение текста, aria-invalid и выбранной точки фокуса после + submit. Последний блок оставляет явную границу: Tree и клавиатура — + проверка браузера; прогон со вспомогательной технологией — отдельный + эксперимент. +- Каждый SVG содержит title, desc, role="img" + и aria-labelledby. В SVG нет JavaScript, внешних URL, + foreignObject или растровых data URI. У figure в статьях есть + самостоятельные alt и подписи. +- Основной редактор отдельно отрисовал три SVG через Sharp в PNG шириной + 375 px. В первом варианте нижняя подпись схемы error-contract была слишком + длинной для узкого экрана; её заменили короткой двухстрочной формулировкой. + Повторный рендер трёх схем не показал обрезания, наложения или + горизонтального выхода. Это проверка статичных схем, а не browser-run. +- После подключения registry строгий аудит подтвердил для всех трёх slug + объём, figure, table и code example; production build сгенерировал 374 + статические страницы. Реальный keyboard-route и screen-reader прогон на + выбранной форме остаются отдельным ручным сценарием и не заявлены как + выполненные. + +### Фактические проверки + +
node --check web/scripts/upgrade-2019-09.mjs
+cd web && npm run audit:draft -- scripts/upgrade-2019-09.mjs
+xmllint --noout \
+  web/public/assets/editorial/2019/accessibility-keyboard-route-2019.svg \
+  web/public/assets/editorial/2019/accessibility-computed-semantics-2019.svg \
+  web/public/assets/editorial/2019/accessibility-error-contract-2019.svg
+ +Результат финального запуска 31 июля 2026 года: + +| Проверка | Результат | +| --- | --- | +| node --check | PASS, синтаксис import-safe модуля корректен | +| --print-revisions | PASS, stdout содержит только JSON и совпадает с export из трёх revision | +| npm run audit:draft -- scripts/upgrade-2019-09.mjs | PASS, все три текста в пределах 5 000–15 000 знаков основного тела | +| xmllint --noout для трёх SVG | PASS, XML корректен | +| Strict audit после подключения registry | PASS: 10 052 / 9 251 / 10 075 знаков, по одному figure, table и code example | +| npm run build | PASS, code 0, 374 статические страницы | +| Scope/self-review | PASS: нет date/author в revision, скриптов в SVG или перезаписи articles.json | + +Выпусковой вердикт: **тройное ревью пройдено, пакет принят к публикации**. +articles.json не менялся; registry заменяет только редакционные +поля по стабильному slug. Проверка поведения в выбранном браузере и со +вспомогательной технологией не подменяется этой публикационной проверкой: её +нужно выполнить на реальной форме отдельно. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 57e099a..49f021d 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -15,6 +15,7 @@ import { revisions as may2019Revisions } from '../scripts/upgrade-2019-05.mjs'; import { revisions as june2019Revisions } from '../scripts/upgrade-2019-06.mjs'; import { revisions as july2019Revisions } from '../scripts/upgrade-2019-07.mjs'; import { revisions as august2019Revisions } from '../scripts/upgrade-2019-08.mjs'; +import { revisions as september2019Revisions } from '../scripts/upgrade-2019-09.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -35,4 +36,5 @@ export const editorialRevisions = [ ...june2019Revisions, ...july2019Revisions, ...august2019Revisions, + ...september2019Revisions, ]; diff --git a/web/public/assets/editorial/2019/accessibility-computed-semantics-2019.svg b/web/public/assets/editorial/2019/accessibility-computed-semantics-2019.svg new file mode 100644 index 0000000..25fe10a --- /dev/null +++ b/web/public/assets/editorial/2019/accessibility-computed-semantics-2019.svg @@ -0,0 +1,41 @@ + + От разметки к вычисленной семантике + Вертикальная схема сравнивает подпись рядом с полем без связи и нативную пару label input; в браузере проверяются role, name и description. + + + От разметки к проверяемому смыслу + DOM не равен имени и роли control + + + Видимая подпись рядом + span «Почта» + input type=email + визуальный текст не создаёт name сам + + + + Проверка в браузере + role: textbox · name: пусто + не угадываем результат по DOM-дереву + + + + Связанная нативная разметка + label for=email · input id=email + hint через description при необходимости + + + + Проверка в браузере + role: textbox · name: Почта + description: подсказка или ошибка + + + Tree помогает диагностике + но не заменяет ручной keyboard-check + + + + + + + diff --git a/web/public/assets/editorial/2019/accessibility-error-contract-2019.svg b/web/public/assets/editorial/2019/accessibility-error-contract-2019.svg new file mode 100644 index 0000000..e9f4b12 --- /dev/null +++ b/web/public/assets/editorial/2019/accessibility-error-contract-2019.svg @@ -0,0 +1,46 @@ + + Ошибка формы как согласованное состояние + Вертикальная схема показывает путь submit, валидации, состояния ошибки, выбранной точки фокуса и последующей ручной браузерной проверки. + + + Ошибка формы — согласованное состояние + Красная рамка не заменяет текст ошибки + + + 1. Submit + пользователь завершил действие + + + + 2. Валидация + есть ли конкретная ошибка поля? + + + + 3. Error state + видимый текст ошибки + aria-invalid=true; связь с описанием + + + + 4. Выбранная точка фокуса + первое неверное поле или summary + решение фиксируется в сценарии страницы + + + + 5. Проверка в браузере + Tab / Shift+Tab · visible focus + name, role и error state в tree + выбранный browser и реальная форма + + + Screen reader — отдельный прогон + схема его не заменяет + + + + + + + diff --git a/web/public/assets/editorial/2019/accessibility-keyboard-route-2019.svg b/web/public/assets/editorial/2019/accessibility-keyboard-route-2019.svg new file mode 100644 index 0000000..2add013 --- /dev/null +++ b/web/public/assets/editorial/2019/accessibility-keyboard-route-2019.svg @@ -0,0 +1,45 @@ + + Клавиатурный маршрут одной формы + Вертикальная схема показывает четыре проверяемых шага: видимый фокус, имя и подсказка поля, согласованная ошибка и ручная проверка в выбранном браузере. + + + Клавиатурный маршрут формы + Проверяем путь, а не только DOM + + + + 1 + Tab: фокус виден + не теряется и не уходит под декор + + + + + 2 + Поле: есть name и hint + label называет control; hint поясняет + + + + + 3 + Submit: ошибка связана + текст, aria-invalid и фокус меняются вместе + + + + 4. Проверка в браузере + Tab и Shift+Tab: фокус не теряется + Accessibility tree: name, role, state + в выбранном браузере и на реальной странице + + + Screen reader — отдельный эксперимент + схема не выдаёт его за выполненный прогон + + + + + + + diff --git a/web/scripts/upgrade-2019-09.mjs b/web/scripts/upgrade-2019-09.mjs new file mode 100644 index 0000000..a66c10f --- /dev/null +++ b/web/scripts/upgrade-2019-09.mjs @@ -0,0 +1,366 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(code) { + return '
' + String(code).trim() + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return ''; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sourceList(items) { + return ''; +} + +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; + } +}