Проект: своя проверка разметки на селекторах

стили≈ 12 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В статье про картинки count("img[alt]") нашёл картинки с alt, а ту, где его нет, пришлось вычитать в уме. Программы проверки доступности делают такую работу за человека: обходят страницу и сверяют каждый элемент с правилами. Первый слой такой проверки можно написать самим, на селекторах этой главы: правило, чей селектор описывает ошибку, срабатывает ровно на ошибках, и вам не нужно ничего запоминать — нужно только уметь описать дефект адресом. Разберём три псевдокласса, :not(), :empty и :has(), соберём проверку из четырёх правил, сравним её с Lighthouse и axe и найдём то, чего CSS не видит в принципе.

Разделы темы:·

Селектор, как вы помните по селекторам и судье каскада, — адрес элементов. Адрес можно написать не только для «всех абзацев», но и для «всех картинок без alt». Правило с таким селектором сработает ровно на ошибках — и это уже половина проверки: осталось узнать, на каких именно элементах оно сработало.

Для этого правило ставит элементу метку — пользовательское свойство --issue, знакомое по статье о переменных и вложенности, — и кладёт в значение текст находки в кавычках. Сам браузер такое свойство ни к чему не применяет, поэтому вид страницы проверка не меняет: метка остаётся в разметке, как пометка карандашом на полях. Собирает метки функция audit(): она обходит элементы страницы примера и перечисляет помеченные — тег и текст находки, в порядке разметки, а в конце говорит, сколько их всего. Ответ, как и в других статьях главы, лежит в разборе под примером.

Адрес ошибки помогает записать :not(). В скобках стоит селектор, а весь псевдокласс выбирает элементы, которые ему не подходят: img:not([alt]) — картинки, у которых нет атрибута alt. Он уже встречался, когда вы собирали кнопку, ссылку и подпись из подручных тегов: press("label:not([for])") щёлкал там по подписи без for.

Ниже три картинки: с осмысленным alt, с пустым и без атрибута. Адрес data:, — пустой файл, записанный прямо в src, как в статье про картинки: сеть примеру не нужна. Прогноз: сколько картинок найдёт count с тем же селектором, что у правила, какую находку даст audit и сколько их будет.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Находка одна — третья картинка. Пустой alt="" селектор пропустил, и так и надо: пустой alt не забыт, а поставлен нарочно — он просит программу чтения пропустить украшение. Проверка, которая ругалась бы и на него, приучала бы писать в alt лишнее.

У метки есть свойство, с которым пришлось считаться: пользовательское свойство наследуется, как цвет. Ниже меню, в нём ссылка без адреса, а внутри ссылки <strong>. Правило задевает только саму ссылку. Прогноз: какое значение --issue окажется у <strong> и сколько находок насчитает проверка?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
У <strong> та же метка: он получил её по наследству. measure печатает значение как есть, с кавычками. Если бы audit печатал каждый помеченный элемент, одна ошибка разрасталась бы в столько строк, сколько элементов лежит внутри. Поэтому прибор сравнивает метку элемента с меткой родителя и молчит, когда они совпадают: это не находка, а эхо.

Метка ничего не рисует. На своей странице находки удобнее видеть глазами: рядом с --issue в правило дописывают outline: 3px solid red, и ошибки обводятся прямо на экране.

Следующая ошибка — кнопка без имени. Имя кнопки — её текст, его и произносит программа чтения. У кнопки-крестика текста нет, и имя ей дают атрибутом aria-label, знакомым по ориентирам страницы. Кнопка без текста и без aria-label прозвучит просто как «кнопка», и что она делает, не узнать: закрыть окно, удалить запись или отправить форму.

Элемент без содержимого выбирает :empty. Склейка без пробела требует всего сразу от одного элемента, поэтому button:empty:not([aria-label]) читается как «кнопка, пустая и без aria-label». Осталось понять, что для браузера значит «пустая». Ниже три кнопки: совсем пустая, с одним пробелом и с HTML-комментарием внутри. Предскажите, сколько из них найдёт :empty.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Две: пустая и с комментарием. Комментарий :empty не считает — это заметка для того, кто читает разметку. А пробел — уже текст, пусть и невидимый, и такая кнопка для :empty не пустая. Для программы чтения она так же безымянна, а правило на :empty её пропустит. К этой дыре вернёмся в конце статьи.

Перенос строки между открывающим и закрывающим тегом — тоже текст. Кнопку, у которой закрывающий тег стоит на следующей строке, :empty уже не выберет. Черновик стандарта предлагает пробелы и переносы не считать, но так пока не делает ни один браузер.

Ещё одна ошибка — подпись, которая ни к чему не привязана. Подпись связывают с полем двумя способами: атрибутом for или полем внутри <label>. Отсутствие первого проверяет :not([for]). Для второго нужен псевдокласс, который смотрит внутрь: :has() выбирает элемент, внутри которого есть что-то подходящее под селектор в скобках. label:has(input) — подпись с полем внутри на любой глубине, а со знаком ребёнка label:has(> input) — только с полем среди прямых детей. label:not(:has(input, select, textarea)) — подпись, внутри которой нет ни поля ввода, ни выпадающего списка <select>, ни многострочного поля <textarea>: запятая в скобках, как и в любом селекторе, собирает список.

:empty — Baseline widely с 29.01.2018, :not() — с 21.07.2023, :has() — с 19.06.2026: пишите все три.

Соберём проверку из четырёх правил. В разметке шапка со ссылкой, подпись и поле, две пустые кнопки и две картинки. Прогноз: сколько находок даст проверка и какие по порядку. Не торопитесь с подписью: поле стоит сразу за ней — но связана ли она с ним?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Четыре находки, по одной на правило. Ссылка в шапке — без href. Подпись «Почта» стоит рядом с полем, но рядом она только для глаза: for у неё нет, поле не внутри, и щелчок по ней в поле не попадёт. Из двух пустых кнопок имя есть у первой — через aria-label. Картинка с пустым alt прошла, без атрибута — нет.

Бывает, что у элемента две ошибки сразу: у картинки нет ни alt, ни размеров. Тогда два правила спорят за одно свойство --issue, и судья каскада оставляет одно значение — при равной специфичности из правила, которое ниже. Метка одна, и находка одна. Исправили первую ошибку — проверка покажет вторую. Это не дефект подхода, а его ограничение, и у настоящих проверок оно устроено иначе: там правило и находка — разные сущности, поэтому на одном элементе их бывает сколько угодно.

:has() пригодился не только для проверок. Он закрыл старую дыру в селекторах: они ходят от элемента вниз, к потомкам, и вперёд, к соседям, а назад, к родителю, не ходят. .agree input найдёт флажок внутри подписи, а подпись по состоянию её флажка до :has() не находил ни один селектор — и ради этого писали скрипт, который добавляет класс родителю.

Поэтому раньше рамку вокруг подписи с отмеченным флажком ставил скрипт: ловил щелчок по флажку, добавлял подписи класс, а когда галочку снимали, класс убирал. С :has() хватает одного правила. :checked вы знаете по статье о переходах: input:checked — отмеченный флажок. Значит, .agree:has(input:checked) — подпись, внутри которой флажок отмечен. Второе правило меняет только цвет рамки, border-color, и сразу всей рамки. Прогноз: каким будет её цвет на странице примера до щелчка по флажку и после.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Серая, потом зелёная. Класс никто не добавлял: флажок отметился, правило стало подходить, и браузер пересчитал стиль сам. Допишите в конец ещё раз press(".agree input") и measure(".agree", "border-top-color") — третья строка снова покажет серый rgb(200, 200, 200). Снять оформление тоже никто не должен помнить: состояние хранит сам флажок, а стиль идёт за ним.

У скрипта, который ставит класс, всегда есть вторая половина — снять его, и её легко забыть. У правила на :has() второй половины нет.

Ваша проверка — уменьшенная модель настоящих. Те устроены так же, обход элементов и правила, только написаны на JavaScript, и правил в них намного больше: в библиотеке axe-core версии 4.13.0 их около сотни. На ней работают расширение axe DevTools для браузера и раздел доступности в Lighthouse. Четыре ваших правила против сотни — не повод считать свою проверку бесполезной: свои правила знают контекст проекта, которого не знает ни одна общая библиотека.

Lighthouse встроен в инструменты разработчика Chrome и Edge. Прибор его не запустит — это ручной шаг. Откройте любую страницу в Chrome, нажмите F12 (в macOS — Cmd+Option+I) и найдите панель Lighthouse; если её не видно среди вкладок, она спрятана за значком ». Оставьте отмеченной категорию Accessibility, остальные снимите и нажмите Analyze page load. Отчёт покажет оценку от 0 до 100 и проваленные проверки, у каждой — элементы, на которых она сработала.

Сравним мнения. Разметку из примера с четырьмя правилами мы положили внутрь <main> обычной страницы и прогнали на ней axe-core 4.13.0 в Chrome. Свои находки axe называет именами правил:

ЭлементВаша проверкаaxe-core
<a> без hrefссылка без адресамолчит
<label> рядом с полемподпись ни к чемуlabel, но у поля input#mail: поле без подписи
<button aria-label="Закрыть">молчитмолчит
пустая <button>кнопка без имениbutton-name
<img alt="">молчитмолчит
<img> без altкартинка без altimage-alt

Картинку и кнопку нашли оба. Подпись — тоже оба, но с разных концов: вы искали подпись без поля, axe — поле без подписи. Для программы чтения важно второе: безымянным осталось именно поле. Ссылку без адреса axe пропустил, и это не всегда ошибка: так размечают, например, пункт меню текущей страницы. А сверх того axe нашёл ошибку всей страницы, page-has-heading-one: на ней нет заголовка <h1>. Проверки расходятся не потому, что одна из них ошибается. У каждой свой список того, что считать ошибкой, и свою вы подстраиваете под правила своего проекта.

Высокая оценка Lighthouse не значит, что страницей удобно пользоваться. Осмысленный ли alt, понятна ли подпись, удобен ли порядок Tab — решает человек, а правило только подсказывает, куда посмотреть.

Селектор видит теги, атрибуты и состояния вроде «отмечен» или «выключен». Три вещи ему недоступны, и на каждой проверка на CSS промахивается.

Первая — текст. Кнопка-иконка, внутри которой только картинка с пустым alt, безымянна, но :empty её не выберет: внутри есть тег. Попробуем другой адрес — кнопка без aria-label, внутри которой картинка с пустым alt. У второй кнопки рядом с иконкой стоит слово «Закрыть». Предскажите находки.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Две находки, и вторая ложная: у кнопки есть текст, а значит, и имя. Селектор не отличит кнопку со словом от кнопки без слова: текст — не тег и не атрибут, адреса у него нет. По той же причине правило на :empty пропустило кнопку с пробелом. axe-core в этих местах не ошибся: button-name он выдал кнопке с пробелом и кнопке с одной иконкой, а кнопку со словом «Закрыть» не тронул.

Вторая — связи между элементами. for хранит id поля, но сравнить значение атрибута одного элемента с id другого селектор не умеет. Ниже подпись с опечаткой в for. Предскажите, что скажет проверка и где окажется фокус после щелчка по подписи.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Проверка молчит: for на месте, и правилу этого достаточно. А щелчок по подписи до поля не дошёл — связи нет.

Исправьте mial на mail и запустите снова: находок так и останется ноль, а фокус встанет в input#mail. Сломанную подпись от рабочей проверка не отличила. Так же молча она пропустит aria-labelledby со ссылкой на id, которого на странице нет. axe такие связи сверяет: подпись с опечаткой он отметил находкой label у поля, а кнопке с aria-labelledby="nope" на странице без элемента #nope — button-name.

Третья — поведение. В каком порядке Tab обходит элементы, куда уходит фокус, что происходит после нажатия — всё это случается, когда страницей пользуются, а правило CSS ничего не нажимает и ничего не ждёт. Даже axe из порядка Tab видит только признак: положительный tabindex он отмечает правилом tabindex, а удобен ли сам порядок, проверяют руками, проходя страницу клавишей Tab.

Молчание проверки не значит, что ошибок нет. Оно значит, что нет тех ошибок, которые эта проверка умеет искать.

Щелчок и фокус в примере с опечаткой проверил не CSS, а функции press и focused. Они, как и audit, measure и page, написаны на JavaScript. На нём же написаны axe-core и Lighthouse: программа читает страницу, сравнивает атрибуты разных элементов, нажимает и смотрит, что изменилось. Там, где кончается CSS, начинается JavaScript, — и с него же начинается часть I: в первой её статье разобрано, что JavaScript умеет на странице.

  • Проверка на селекторах: правило, чей селектор описывает ошибку, ставит элементу метку --issue с текстом находки. Метка ничего не рисует, а audit() печатает помеченные элементы в порядке разметки и пропускает потомков, получивших метку по наследству.
  • :not(X) выбирает всё, что не подходит под X: img:not([alt]) — картинки без alt, а пустой alt="" ошибкой не считается. У элемента с двумя ошибками метка одна: спор за --issue решает судья каскада.
  • :empty — элемент без тегов и без текста; пробел и перенос строки — текст, комментарий — нет. :has(X) — элемент, внутри которого есть X, :has(> X) — X среди прямых детей. Все три псевдокласса — Baseline widely, :has() — с 19.06.2026.
  • :has() оформляет родителя по состоянию того, что внутри: .agree:has(input:checked) меняет рамку сам, без скрипта, который ставил бы и снимал класс.
  • Lighthouse в инструментах разработчика Chrome и расширение axe DevTools работают на библиотеке axe-core. С вашей проверкой их находки совпадают не во всём, и ни одна проверка не решит, осмыслен ли alt и удобен ли порядок Tab.
  • CSS не видит текста, связей между атрибутами разных элементов и поведения: не отличит кнопку со словом от пустой, пропустит опечатку в for и не узнает, куда уйдёт фокус. Это умеют программы на JavaScript — и прибор этой главы, и axe.

1/4Какой селектор найдёт подпись <label>, не связанную с полем ни одним из двух способов?

2/4Сколько кнопок выберет button:empty в разметке <button></button><button> </button>?

3/4Почему audit() не печатает элемент, у которого --issue такая же, как у родителя?

4/4У картинки нет ни alt, ни width. В стилях сначала img:not([alt]) { --issue: "картинка без alt"; }, ниже — img:not([width]) { --issue: "картинка без размеров"; }. Что напечатает audit()?

Три новые находки

На странице кофейни проверка пока из одного правила, и оно молчит: у обеих картинок есть alt. Допишите в <style> три правила. Находка «ссылка-заглушка» — у ссылки с href="#", которая притворяется адресом. «Картинка без размеров» — у картинки, которой не хватает width или height: пока её файл в пути, место под неё не отложено. «Группа без подписи» — у <fieldset>, среди прямых детей которого нет <legend>.

Проверка должна напечатать три находки в порядке разметки и строку находок: 3. Разметку не меняйте.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Прибор напечатает a: ссылка-заглушка, img: картинка без размеров, fieldset: группа без подписи и находок: 3. Первое правило сравнивает значение атрибута целиком, и ссылка на /menu ему не подходит. Во втором запятая собирает два адреса: находкой становится картинка, которой не хватает хотя бы одного атрибута. То же короче — img:not([width][height]): «не оба атрибута сразу». В третьем знак > внутри :has() смотрит только на прямых детей. Без него группа, внутри которой лежит вложенная группа со своей <legend>, прошла бы проверку. Подпись и радиокнопка во второй группе получили метку по наследству, но audit их не напечатал — это эхо.

Выбранный тариф

Три карточки тарифа — подписи с радиокнопками внутри, у всех серая рамка. Допишите одно правило, чтобы у карточки с отмеченной радиокнопкой рамка стала зелёной, rgb(0, 128, 0). Проверка щёлкает сначала «Месяц», потом «Год», и в конце зелёной должна быть только вторая карточка. Разметку и первое правило не трогайте.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Прибор напечатает .plan → border-top-color: rgb(200, 200, 200), rgb(0, 128, 0), rgb(200, 200, 200). У радиокнопок одно имя plan, а в такой группе отмеченной бывает только одна: щелчок по «Году» снял отметку с «Месяца». Правило заметило это само, и первая карточка вернулась к серой рамке. Поставьте такой же measure и между щелчками — он покажет зелёной первую карточку. Скрипт на классах пришлось бы учить и этому: ставить класс новой карточке и снимать с прежней.

Имя по ссылке на абзац

Проверка нашла две кнопки без имени, но у первой имя есть: её aria-labelledby ссылается на абзац с id="hint", и программа чтения назовёт кнопку текстом этого абзаца. Поправьте правило так, чтобы ложная находка пропала, а вторая кнопка осталась находкой. Потом ответьте: что ваша проверка скажет о кнопке с aria-labelledby="nope", если элемента с таким id на странице нет?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Прибор напечатает button: кнопка без имени и находок: 1. Второе :not() отпускает кнопки, чьё имя приходит по ссылке на другой элемент. О кнопке с aria-labelledby="nope" проверка промолчит: атрибут есть, а есть ли на странице элемент #nope, селектор узнать не может. Для CSS это граница, а axe-core на такой кнопке выдаёт находку button-name.