На главную

HTML научился смещать уровни заголовков. Но считать их всё ещё нам


В спецификацию HTML5 добавили два новых атрибута headingoffset и headingresetСпецификация. Issue. PR. Которые на данный момент ещё не реализованы ни одним браузером. Меня же это дополнение заставило задуматься зачем эти атрибуты вообще нужны и я решил разобраться и заодно поделиться с вами своей находкой.

Коротко:

  • headingoffset — задаёт смещение уровня заголовков внутри элемента, увеличивая их вычисленный уровень на указанное число.
  • headingreset — останавливает наследование headingoffset от внешних предков, позволяя начать вычисление уровней заголовков заново.

А теперь к проблеме…

Какую проблему решают новые атрибуты

Судя по спецификации, в WHATWG учитывают современные подходы к разработке, а именно компонентный подход.

Давайте себе представим обычную ситуацию. Вы разработали компонент, в котором есть заголовок:

<!-- компонент карточки -->
<article class="card">
  <h2>Новости браузеров</h2>
  <p>Что нового появилось в веб-платформе.</p>
</article>

Вам, конечно, может повезти, и вы правильно добавите компонент в подходящее место:

<h1>Дайджест фронтенд-новостей</h1>

<article class="card">
  <h2>Новости браузеров</h2>
  <p>Что нового появилось в веб-платформе.</p>
</article>

Но чаще всего мы даже не задумываемся, в каком родителе окажется компонент. Нужно смотреть на сгенерированный HTML после сборки, запускать валидатор HTML и уже тогда понимать, что уровень вложенности неправильный:

<h1>Новости фронтенда</h1>

<section>
  <h2>Веб-платформа</h2>

  <!-- компонент карточки -->
  <article class="card">
    <!-- уже нужно h3 -->
    <h2>Новости браузеров</h2>
    <p>Что нового появилось в веб-платформе.</p>
  </article>
</section>

Самым простым вариантом, конечно же, является создать в компоненте пропс:

<Card headingLevel={3} />

чтобы сменить уровень заголовка на нужный. Это ручной контроль со стороны внимательного разработчика, который следит за вложенностью заголовков.

В WHATWG решили нам помочь (хотя это мы ещё поймём дальше в статье), добавив headingoffset. В спецификации появилось понятие «вычисленный уровень заголовка» (computed heading level). Атрибут увеличивает вычисленный уровень всех заголовков-потомков, например:

<article headingoffset="1">
  <h1>Новости браузеров</h1>
</article>

В DOM будет находиться <h1>, но его вычисленный уровень станет равным 2:

h1 + headingoffset="1" = heading level 2

Теперь наш вымышленный компонент можно подправить:

<h1>Новости фронтенда</h1>

<section>
  <h2>Веб-платформа</h2>

  <!-- компонент карточки -->
  <article class="card" headingoffset="1">
    <!-- вычисленный уровень h3:
    h2 + headingoffset="1" = heading level 3 -->
    <h2>Новости браузеров</h2> <!-- в DOM останется h2 -->
    <p>Что нового появилось в веб-платформе.</p>
  </article>
</section>

Это всё ещё ручной труд для внимательного разработчика, который заметит проблему вложенности заголовков и добавит headingoffset с нужным смещением. Да, и пропс <Card headingLevel={3} /> продолжает стабильно работать. Давайте продолжать разбираться.

Поднятие уровня в одиночку нескольких заголовков

В компоненте может быть несколько заголовков, например:

<article>
  <h1>Новый CSS</h1>
  <h2>Комментарии</h2>
</article>

Если сфокусироваться на компоненте, то вложенность сейчас правильная (не смотрите на конкретные уровни), но если компонент поместить в непредвиденного родителя:

<h1>Главная страница</h1>       → level 1
<h2>Последние публикации</h2>   → level 2

<article headingoffset="2">
  <h1>Новый CSS</h1>            → level 3
  <h2>Комментарии</h2>          → level 4
</article>

то придётся резко повышать уровень всех заголовков внутри. headingoffset с этой задачей как раз справляется просто отлично. Если изначально вложенность правильная, то поднятие на два уровня работает хорошо.

На этом примере становится понятно, что в компоненте вашего любимого фреймворка лучше передавать не уровень заголовка

<Card headingLevel={3} />

и менять тег с <h1> на <h3>, а в случае, если заголовков в компоненте несколько, то делать несколько пропсов или как-то внутри компонента разруливать повышения для следующего заголовка — headingLevel++. Тут лучше передать headingoffset, так как его можно повесить на родителя, и все заголовки внутри правильно повысят свой уровень:

<Card headingoffset={2} />

и не трогать теги. Этому пропсу по умолчанию можно передать 0, тогда вычисленный уровень заголовка не изменится.

Такс, ну хорошо, уровни изменять стало чуть-чуть удобнее, но это всё также ручной труд, да ещё и не без проблем…

Проблемы и нюансы

Складывание уровней

Мы пока всё ещё живём в мире, где не всегда понятно, какой компонент в какой вставлен. И если несколько компонентов со своим headingoffset будут вставлены друг в друга, то что произойдёт?

<article headingoffset="1">
  <section headingoffset="2">
    <!-- какой вычисленный уровень у h2? -->
    <h2>Заголовок</h2>
  </section>
</article>

А произойдёт складывание уровней:

исходный h2 → 2
article → +1
section → +2

итого → 5

h2 в итоге станет заголовком пятого уровня. Получается, что вложенные компоненты не уменьшают количество ручного контроля, а местами даже увеличивают его. Теперь нужно смотреть не только на уровень заголовка внутри компонента, но и на все смещения выше по дереву.

Сверх нормы

Другой нюанс, который нужно учитывать при вычислении уровней, — это то, что их будет больше, чем h6. Давайте посмотрим на пример.

<div headingoffset="8">
  <h2>Очень глубокий заголовок</h2>
</div>

Получаем в этом случае 2 + 8 = 10. 10 уровень заголовка? Но такого же не существует.

По спецификации в headingoffset можно передать число от 0 до 8. При этом максимальный вычисленный уровень заголовка — 9. То есть в нашем примере 2 + 8 = 10, но итогом будет 9.

Что в этом случае будут делать скринридеры? В тестах Адриана Розелли уровни выше шестого всё ещё создают проблемы в JAWS и TalkBack. Поэтому пока лучше не вычислять заголовки выше шестого уровня, даже если спецификация технически разрешает дойти до девятого.

Тут вскрывается и другая проблема: браузеры и старые версии скринридеров, которые не поддерживают headingoffset, увидят исходные h1h6. Для них в документе останется та самая неправильная вложенность, которую мы пытались исправить.

Сбрасываем уровни

Все нюансы и проблемы, конечно же, можно решить. Зорким глазом и валидатором. Но как не дать уровням повыситься, если блок вложен в headingoffset? Для этого есть headingreset — булев атрибут, который останавливает поиск смещений выше текущего элемента:

<h1>Главная страница</h1> <!-- level 1 -->
<h2>Последние публикации</h2> <!-- level 2 -->

<article headingoffset="2">
  <h1>Новый CSS</h1>
  <!-- level 3 -->
  <h2>Комментарии</h2>
  <!-- level 4 -->
  <aside headingreset>
    <h2>Полезные ссылки</h2> <!-- level 2 -->
    <section>
      <h3>Документация</h3> <!-- level 3 -->
    </section>
    <section>
      <h3>Примеры</h3>
      <!-- level 3 -->
    </section>
  </aside>
</article>

У блока <aside> уже есть правильная внутренняя структура:

h2 Полезные ссылки
├── h3 Документация
└── h3 Примеры

Но из-за headingoffset="2" у родительской статьи без headingreset она превратилась бы в:

h2 → level 4
h3 → level 5

headingreset останавливает влияние внешнего headingoffset, поэтому самостоятельный блок сохраняет свои исходные уровни:

h2 → level 2
h3 → level 3

Есть небольшой, но важный нюанс. Если на одном элементе указать и headingreset, и headingoffset, собственное смещение всё равно сработает. Сбросятся только значения от предков:

<div headingoffset="3">
  <div headingreset headingoffset="1">
    <h1>Заголовок</h1> <!-- level 2, а не level 5 -->
  </div>
</div>

Это удобно для самостоятельных блоков вроде диалога или модального окна: они могут не зависеть от структуры страницы снаружи и при этом задать своё внутреннее смещение.

Что насчёт aria-level

В спецификации он тоже упоминается. Поддержка aria-level уже сейчас неплохая.

Но проблема с ним ровно такая же, как с передачей уровней в компонент. Для каждого уровня заголовка нужно передавать свой aria-level:

<h1 aria-level="3">Заголовок</h1>
<h2 aria-level="4">Подзаголовок</h2>

headingoffset работает через родителя и меняет уровни у всех заголовков внутри элемента:

<div headingoffset="2">
  <h1>Заголовок</h1> <!-- h3 -->
  <h2>Подзаголовок</h2> <!-- h4 -->
</div>

Но aria-level и headingoffset не меняют сам HTML-тег. Они меняют уровень, который браузер отдаёт в accessibility tree. CSS-селектор h1, стандартные стили браузера и element.tagName продолжат видеть h1. Получается две структуры: та, которую мы видим в DOM, и та, которую услышит пользователь скринридера. При отладке про это легко забыть.

Это не новый outline algorithm

Тут можно решить, что браузер наконец-то научился сам строить правильную структуру документа по section, article, aside и другим секционным элементам. Нет, не научился.

Когда-то для HTML предлагали алгоритм структуры документа: можно было поставить h1 внутрь каждой section, а браузер якобы сам вычислял бы нужный уровень по вложенности секций. На практике браузеры этот алгоритм не реализовали, а авторам всё равно пришлось вручную использовать h1h6. Историю этого эксперимента хорошо разобрал Адриан Розелли.

headingoffset ничего не выводит из структуры документа и не принимает решений за разработчика. Он просто складывает числа, которые разработчик сам расставил по предкам. Если атрибут не добавить, h1 внутри section останется заголовком первого уровня.

То есть новый API не возвращает автоматический outline algorithm. Он лишь даёт ещё один ручной инструмент для работы с компонентами.

А что с импортируемым контентом

С заранее известным компонентом всё более-менее понятно: мы знаем его внутреннюю структуру и можем подобрать смещение. Но если внутрь прилетает пользовательский HTML или контент из CMS, одного headingoffset уже недостаточно.

Допустим, внутри уже пропущены уровни:

<article headingoffset="2">
  <h1>Название статьи</h1> <!-- level 3 -->
  <h4>Комментарии</h4> <!-- level 6 -->
</article>

Атрибут честно прибавил два к каждому заголовку, но неправильная структура никуда не исчезла. Между третьим и шестым уровнем всё ещё дыра. headingoffset не понимает отношения между частями контента и не пытается их исправить — он только складывает числа.

Поэтому для пользовательского или импортируемого HTML сначала всё равно придётся проверить и нормализовать заголовки. Если структура неизвестна заранее, подобрать одно правильное смещение для всего блока может быть невозможно.

Итог

Мне нравится сама идея headingoffset. Для компонента с несколькими заголовками одно смещение на контейнере удобнее, чем набор пропсов и ручное переключение каждого тега. headingreset тоже закрывает понятный случай с самостоятельными вложенными блоками.

Но магии тут не случилось. Разработчику всё ещё нужно знать, куда вставлен компонент, посчитать итоговый уровень и не уйти выше шестого. Атрибуты уменьшают количество механической работы, но не снимают ответственность за структуру документа и сами по себе не уменьшают риски для доступности.

Исходные h1h6 в любом случае должны складываться в осмысленную структуру. Нельзя сначала сделать кашу из заголовков, добавить сверху headingoffset и считать задачу решённой.

Такс, новый инструмент у нас появился. Автоматического оглавления — всё ещё нет. Магии не случилось. В жизни разработчика ничего не поменялось. Такие дела 🥲