revise September 2019 accessibility articles
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-07-31 10:56:45 +03:00
parent 18adfa80b4
commit bd09f3369c
7 changed files with 624 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 58 из 358 созданных материалов. Остальные 300 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 61 из 358 созданных материалов. Остальные 297 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+123
View File
@@ -0,0 +1,123 @@
# P19 · сентябрь 2019 · базовая веб-доступность — тройное ревью
Статус: **принят в публикационный слой 31 июля 2026 года**. Registry применяет
ровно три ревизии по стабильным slug и сохраняет дату и автора базового архива:
- <code>editorial-2019-09-practice-accessibility-basics</code>;
- <code>editorial-2019-09-mechanism-accessibility-basics</code>;
- <code>editorial-2019-09-field-accessibility-basics</code>.
Модуль экспортирует ровно три revision без полей <code>date</code> и
<code>author</code>. При прямом вызове с <code>--print-revisions</code> он
пишет только JSON, совпадающий с import-safe export. Черновик намеренно не
выдаёт план ручной проверки за пройденный реальный прогон со screen reader.
## Проход 1. Факты и техника — пройдено
| Утверждение или решение | Первичный источник | Проверенная граница |
| --- | --- | --- |
| При последовательной навигации порядок фокуса сохраняет смысл и работоспособность операции | [WCAG 2.1, 2.4.3 Focus Order](https://www.w3.org/TR/WCAG21/#focus-order) | Тексты не выдают произвольный положительный <code>tabindex</code> за решение; сначала требуется пройти 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-практики или масштабные
организационные процессы.
- Техническая речь короткая и предметная. В ней названы <code>label</code>,
<code>for</code>, <code>id</code>, <code>aria-describedby</code>,
<code>aria-invalid</code>, <code>tabindex</code>, active element, role,
name, description и конкретные шаги проверки. Шаблонные обобщения и
обещания «универсальной доступности» удалены.
- Каждая статья содержит не менее пяти смысловых разделов, доступную таблицу
с <code>caption</code>/<code>thead</code>, figure с alt/caption, код,
упорядоченный маршрут и список первичных источников.
Вердикт прохода: **пройден**. Материалы продолжают фронтенд-ветку автора
2019 года и не маскируют проверяемые условия общими советами.
## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета
- <code>accessibility-keyboard-route-2019.svg</code> ведёт вертикально от
видимого фокуса через именованное поле к текстовой ошибке. Короткие подписи
и viewBox без фиксированной ширины позволяют схеме уменьшаться вместе с
контейнером статьи.
- <code>accessibility-computed-semantics-2019.svg</code> сопоставляет
несвязанную подпись рядом с input и семантически связанную пару label/input.
Схема показывает, что именно нужно проверить в браузере: role, name и
description, а не заявляет результат screen-reader run.
- <code>accessibility-error-contract-2019.svg</code> показывает одновременное
изменение текста, <code>aria-invalid</code> и выбранной точки фокуса после
submit. Последний блок оставляет явную границу: Tree и клавиатура —
проверка браузера; прогон со вспомогательной технологией — отдельный
эксперимент.
- Каждый SVG содержит <code>title</code>, <code>desc</code>, <code>role="img"</code>
и <code>aria-labelledby</code>. В SVG нет JavaScript, внешних URL,
<code>foreignObject</code> или растровых 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 прогон на
выбранной форме остаются отдельным ручным сценарием и не заявлены как
выполненные.
### Фактические проверки
<pre><code>node --check web/scripts/upgrade-2019-09.mjs
cd web &amp;&amp; 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</code></pre>
Результат финального запуска 31 июля 2026 года:
| Проверка | Результат |
| --- | --- |
| <code>node --check</code> | PASS, синтаксис import-safe модуля корректен |
| <code>--print-revisions</code> | PASS, stdout содержит только JSON и совпадает с export из трёх revision |
| <code>npm run audit:draft -- scripts/upgrade-2019-09.mjs</code> | PASS, все три текста в пределах 5 000–15 000 знаков основного тела |
| <code>xmllint --noout</code> для трёх SVG | PASS, XML корректен |
| Strict audit после подключения registry | PASS: 10 052 / 9 251 / 10 075 знаков, по одному figure, table и code example |
| <code>npm run build</code> | PASS, code 0, 374 статические страницы |
| Scope/self-review | PASS: нет date/author в revision, скриптов в SVG или перезаписи <code>articles.json</code> |
Выпусковой вердикт: **тройное ревью пройдено, пакет принят к публикации**.
<code>articles.json</code> не менялся; registry заменяет только редакционные
поля по стабильному slug. Проверка поведения в выбранном браузере и со
вспомогательной технологией не подменяется этой публикационной проверкой: её
нужно выполнить на реальной форме отдельно.
+2
View File
@@ -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,
];
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1180" role="img" aria-labelledby="title desc">
<title id="title">От разметки к вычисленной семантике</title>
<desc id="desc">Вертикальная схема сравнивает подпись рядом с полем без связи и нативную пару label input; в браузере проверяются role, name и description.</desc>
<rect width="720" height="1180" fill="#0f172a"/>
<rect x="42" y="34" width="636" height="112" rx="18" fill="#312e81"/>
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">От разметки к проверяемому смыслу</text>
<text x="360" y="116" text-anchor="middle" fill="#ddd6fe" font-size="21" font-family="Arial, sans-serif">DOM не равен имени и роли control</text>
<rect x="75" y="190" width="570" height="144" rx="18" fill="#4c1d2b" stroke="#fb7185" stroke-width="3"/>
<text x="360" y="234" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Видимая подпись рядом</text>
<text x="360" y="270" text-anchor="middle" fill="#fecdd3" font-size="22" font-family="Arial, sans-serif">span «Почта» + input type=email</text>
<text x="360" y="304" text-anchor="middle" fill="#fecdd3" font-size="22" font-family="Arial, sans-serif">визуальный текст не создаёт name сам</text>
<path d="M360 334v44" stroke="#fda4af" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="75" y="384" width="570" height="126" rx="18" fill="#7f1d1d" stroke="#fca5a5" stroke-width="3"/>
<text x="360" y="428" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Проверка в браузере</text>
<text x="360" y="464" text-anchor="middle" fill="#fee2e2" font-size="23" font-family="Arial, sans-serif">role: textbox · name: пусто</text>
<text x="360" y="494" text-anchor="middle" fill="#fee2e2" font-size="20" font-family="Arial, sans-serif">не угадываем результат по DOM-дереву</text>
<path d="M360 510v48" stroke="#a7f3d0" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="75" y="564" width="570" height="144" rx="18" fill="#14532d" stroke="#86efac" stroke-width="3"/>
<text x="360" y="608" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Связанная нативная разметка</text>
<text x="360" y="644" text-anchor="middle" fill="#dcfce7" font-size="22" font-family="Arial, sans-serif">label for=email · input id=email</text>
<text x="360" y="678" text-anchor="middle" fill="#dcfce7" font-size="22" font-family="Arial, sans-serif">hint через description при необходимости</text>
<path d="M360 708v44" stroke="#a7f3d0" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="75" y="758" width="570" height="154" rx="18" fill="#0f766e" stroke="#5eead4" stroke-width="3"/>
<text x="360" y="802" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Проверка в браузере</text>
<text x="360" y="840" text-anchor="middle" fill="#ccfbf1" font-size="23" font-family="Arial, sans-serif">role: textbox · name: Почта</text>
<text x="360" y="874" text-anchor="middle" fill="#ccfbf1" font-size="23" font-family="Arial, sans-serif">description: подсказка или ошибка</text>
<rect x="70" y="970" width="580" height="86" rx="16" fill="#334155" stroke="#94a3b8" stroke-width="3"/>
<text x="360" y="1008" text-anchor="middle" fill="#f8fafc" font-size="24" font-family="Arial, sans-serif" font-weight="700">Tree помогает диагностике</text>
<text x="360" y="1038" text-anchor="middle" fill="#cbd5e1" font-size="20" font-family="Arial, sans-serif">но не заменяет ручной keyboard-check</text>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="#a7f3d0"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1200" role="img" aria-labelledby="title desc">
<title id="title">Ошибка формы как согласованное состояние</title>
<desc id="desc">Вертикальная схема показывает путь submit, валидации, состояния ошибки, выбранной точки фокуса и последующей ручной браузерной проверки.</desc>
<rect width="720" height="1200" fill="#0f172a"/>
<rect x="42" y="34" width="636" height="112" rx="18" fill="#7c2d12"/>
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Ошибка формы — согласованное состояние</text>
<text x="360" y="116" text-anchor="middle" fill="#ffedd5" font-size="21" font-family="Arial, sans-serif">Красная рамка не заменяет текст ошибки</text>
<rect x="85" y="190" width="550" height="94" rx="18" fill="#1e3a5f" stroke="#60a5fa" stroke-width="3"/>
<text x="360" y="230" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">1. Submit</text>
<text x="360" y="262" text-anchor="middle" fill="#dbeafe" font-size="21" font-family="Arial, sans-serif">пользователь завершил действие</text>
<path d="M360 284v40" stroke="#facc15" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="85" y="330" width="550" height="94" rx="18" fill="#3f3108" stroke="#facc15" stroke-width="3"/>
<text x="360" y="370" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">2. Валидация</text>
<text x="360" y="402" text-anchor="middle" fill="#fef3c7" font-size="21" font-family="Arial, sans-serif">есть ли конкретная ошибка поля?</text>
<path d="M360 424v40" stroke="#facc15" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="85" y="470" width="550" height="148" rx="18" fill="#7f1d1d" stroke="#fca5a5" stroke-width="3"/>
<text x="360" y="514" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">3. Error state</text>
<text x="360" y="550" text-anchor="middle" fill="#fee2e2" font-size="22" font-family="Arial, sans-serif">видимый текст ошибки</text>
<text x="360" y="582" text-anchor="middle" fill="#fee2e2" font-size="22" font-family="Arial, sans-serif">aria-invalid=true; связь с описанием</text>
<path d="M360 618v42" stroke="#67e8f9" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="85" y="666" width="550" height="120" rx="18" fill="#0f766e" stroke="#5eead4" stroke-width="3"/>
<text x="360" y="708" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">4. Выбранная точка фокуса</text>
<text x="360" y="744" text-anchor="middle" fill="#ccfbf1" font-size="22" font-family="Arial, sans-serif">первое неверное поле или summary</text>
<text x="360" y="774" text-anchor="middle" fill="#ccfbf1" font-size="20" font-family="Arial, sans-serif">решение фиксируется в сценарии страницы</text>
<path d="M360 786v42" stroke="#67e8f9" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="65" y="834" width="590" height="168" rx="18" fill="#164e63" stroke="#67e8f9" stroke-width="3"/>
<text x="360" y="878" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">5. Проверка в браузере</text>
<text x="360" y="916" text-anchor="middle" fill="#cffafe" font-size="23" font-family="Arial, sans-serif">Tab / Shift+Tab · visible focus</text>
<text x="360" y="950" text-anchor="middle" fill="#cffafe" font-size="23" font-family="Arial, sans-serif">name, role и error state в tree</text>
<text x="360" y="982" text-anchor="middle" fill="#a5f3fc" font-size="20" font-family="Arial, sans-serif">выбранный browser и реальная форма</text>
<rect x="65" y="1060" width="590" height="84" rx="16" fill="#334155" stroke="#94a3b8" stroke-width="3"/>
<text x="360" y="1097" text-anchor="middle" fill="#f8fafc" font-size="25" font-family="Arial, sans-serif" font-weight="700">Screen reader — отдельный прогон</text>
<text x="360" y="1127" text-anchor="middle" fill="#cbd5e1" font-size="21" font-family="Arial, sans-serif">схема его не заменяет</text>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="#67e8f9"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1160" role="img" aria-labelledby="title desc">
<title id="title">Клавиатурный маршрут одной формы</title>
<desc id="desc">Вертикальная схема показывает четыре проверяемых шага: видимый фокус, имя и подсказка поля, согласованная ошибка и ручная проверка в выбранном браузере.</desc>
<rect width="720" height="1160" fill="#0f172a"/>
<rect x="42" y="34" width="636" height="112" rx="18" fill="#164e63"/>
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Клавиатурный маршрут формы</text>
<text x="360" y="116" text-anchor="middle" fill="#cffafe" font-size="21" font-family="Arial, sans-serif">Проверяем путь, а не только DOM</text>
<rect x="86" y="190" width="548" height="126" rx="18" fill="#1e3a5f" stroke="#60a5fa" stroke-width="3"/>
<circle cx="132" cy="252" r="23" fill="#60a5fa"/>
<text x="132" y="260" text-anchor="middle" fill="#0f172a" font-size="22" font-family="Arial, sans-serif" font-weight="700">1</text>
<text x="180" y="242" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Tab: фокус виден</text>
<text x="180" y="278" fill="#cbd5e1" font-size="22" font-family="Arial, sans-serif">не теряется и не уходит под декор</text>
<path d="M360 316v44" stroke="#5eead4" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="86" y="366" width="548" height="126" rx="18" fill="#3f3108" stroke="#facc15" stroke-width="3"/>
<circle cx="132" cy="428" r="23" fill="#facc15"/>
<text x="132" y="436" text-anchor="middle" fill="#1f2937" font-size="22" font-family="Arial, sans-serif" font-weight="700">2</text>
<text x="180" y="418" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Поле: есть name и hint</text>
<text x="180" y="454" fill="#fef3c7" font-size="22" font-family="Arial, sans-serif">label называет control; hint поясняет</text>
<path d="M360 492v44" stroke="#5eead4" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="86" y="542" width="548" height="126" rx="18" fill="#4c1d2b" stroke="#fb7185" stroke-width="3"/>
<circle cx="132" cy="604" r="23" fill="#fb7185"/>
<text x="132" y="612" text-anchor="middle" fill="#1f2937" font-size="22" font-family="Arial, sans-serif" font-weight="700">3</text>
<text x="180" y="594" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Submit: ошибка связана</text>
<text x="180" y="630" fill="#fecdd3" font-size="22" font-family="Arial, sans-serif">текст, aria-invalid и фокус меняются вместе</text>
<path d="M360 668v44" stroke="#5eead4" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="70" y="718" width="580" height="174" rx="18" fill="#164e63" stroke="#67e8f9" stroke-width="3"/>
<text x="360" y="764" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">4. Проверка в браузере</text>
<text x="360" y="806" text-anchor="middle" fill="#cffafe" font-size="23" font-family="Arial, sans-serif">Tab и Shift+Tab: фокус не теряется</text>
<text x="360" y="842" text-anchor="middle" fill="#cffafe" font-size="23" font-family="Arial, sans-serif">Accessibility tree: name, role, state</text>
<text x="360" y="874" text-anchor="middle" fill="#a5f3fc" font-size="20" font-family="Arial, sans-serif">в выбранном браузере и на реальной странице</text>
<rect x="70" y="950" width="580" height="104" rx="16" fill="#334155" stroke="#94a3b8" stroke-width="3"/>
<text x="360" y="992" text-anchor="middle" fill="#f8fafc" font-size="24" font-family="Arial, sans-serif" font-weight="700">Screen reader — отдельный эксперимент</text>
<text x="360" y="1024" text-anchor="middle" fill="#cbd5e1" font-size="20" font-family="Arial, sans-serif">схема не выдаёт его за выполненный прогон</text>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="#5eead4"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+366
View File
@@ -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 = [
'&lt;form id="profile-form" novalidate&gt;',
' &lt;label for="profile-email"&gt;Рабочая почта&lt;/label&gt;',
' &lt;p id="profile-email-hint"&gt;Нужен адрес, на который придёт ссылка.&lt;/p&gt;',
' &lt;input id="profile-email" type="email" required',
' aria-describedby="profile-email-hint profile-email-error"',
' aria-errormessage="profile-email-error" aria-invalid="false" /&gt;',
' &lt;p id="profile-email-error" hidden&gt;&lt;/p&gt;',
' &lt;button type="submit"&gt;Сохранить&lt;/button&gt;',
'&lt;/form&gt;',
'',
'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) =&gt; {',
' event.preventDefault();',
' const message = email.validity.valid ? "" : "Введите адрес в формате name@example.com";',
' setEmailError(message);',
' if (message) email.focus();',
'});',
].join('\n');
const semanticsFixture = [
'&lt;!-- Видимое слово не становится именем само по себе. --&gt;',
'&lt;span class="field-title"&gt;Поиск&lt;/span&gt;',
'&lt;input id="query" type="search" /&gt;',
'',
'&lt;!-- У label есть формальная связь с control. --&gt;',
'&lt;label for="query-good"&gt;Поиск&lt;/label&gt;',
'&lt;input id="query-good" type="search" /&gt;',
'',
'&lt;!-- Обычное действие лучше выражать нативной кнопкой. --&gt;',
'&lt;div class="save" tabindex="0"&gt;Сохранить&lt;/div&gt;',
'&lt;button type="button"&gt;Сохранить&lt;/button&gt;',
'',
'&lt;!-- Положительные tabindex меняют порядок относительно документа. --&gt;',
'&lt;a href="/account" tabindex="3"&gt;Профиль&lt;/a&gt;',
'&lt;input id="phone" type="tel" /&gt;',
].join('\n');
const errorFixture = [
'&lt;form id="registration" novalidate&gt;',
' &lt;label for="registration-email"&gt;Почта&lt;/label&gt;',
' &lt;input id="registration-email" type="email" required',
' aria-describedby="registration-email-hint registration-email-error"',
' aria-errormessage="registration-email-error" aria-invalid="false" /&gt;',
' &lt;p id="registration-email-hint"&gt;Ссылка для входа придёт на этот адрес.&lt;/p&gt;',
' &lt;p id="registration-email-error" hidden&gt;&lt;/p&gt;',
' &lt;button type="submit"&gt;Создать аккаунт&lt;/button&gt;',
'&lt;/form&gt;',
'',
'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) =&gt; {',
' 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;
}
}