Перейти к содержимому
EN
Масштаб
Плотность
Тема
Акцент

Навигация

Вкладки

JS Переключение панели. Стрелки и бегущий tabindex делает `instrument.js`

Горизонтальный переключатель видов внутри одного раздела. По умолчанию — на ссылках: переключение экрана это роутинг, и JS ему не нужен.

Компонент
Разметка
<nav class="inst-tabs" aria-label="Разделы прогонов">
  <a class="inst-tab" href="#" aria-current="page">Активные</a>
  <a class="inst-tab" href="#">История</a>
  <a class="inst-tab" href="#">Расписание</a>
</nav>

Контракт

Что Обязательно Почему
<nav> + aria-label на роутинговом варианте да Навигаций на экране несколько, и без имени они неразличимы
aria-current="page" да Активный вид при переключении адресом
role="tablist" + role="tab" + aria-selected + aria-controls да, только у настоящих вкладок Роль без стрелочной навигации и панели — обещание, которого никто не выполнит
type="button" у вкладки-кнопки да Иначе внутри формы она её отправит
Активная вкладка — подчёркиванием наравне с цветом да aria-current или aria-selected сообщает то же скринридеру

Доступность

Клавиатура, роутинг Нативные ссылки: Tab — обход, Enter — переход
Клавиатура, настоящие вкладки Tab входит в полосу один раз, стрелки переключают, — это JS приложения. Без него role="tablist" ставить нельзя
Цвет не единственный носитель Активная вкладка несёт подчёркивание и aria-current / aria-selected
Прокрутка Полоса прокручивается по горизонтали, скроллбар скрыт визуально, но прокрутка колесом и с клавиатуры остаётся
Контраст Неактивная подпись --text-secondary держит 4.5:1 в пяти темах
Фокус Кольцо :focus-visible от базового слоя, полоса вкладок его не обрезает

Варианты

Ссылки или настоящие вкладки — два разных случая, и разметка у них разная. Выбирается тот, который правдив:

Случай Разметка Признак
Переключение экрана <nav class="inst-tabs"> + <a class="inst-tab" aria-current="page"> У каждого вида свой адрес. Средний клик открывает вкладку в новой вкладке браузера
Панели внутри одной страницы role="tablist" + role="tab" + aria-selected + aria-controls Адрес не меняется, содержимое подменяется на месте

Оформление ловит оба: селектор библиотеки реагирует и на [aria-current]:not([aria-current="false"]), и на [aria-selected="true"]. Поэтому вопрос «какой класс поставить» не возникает — возникает вопрос «какая разметка правдива».

Настоящие вкладки

role="tablist" ставится только если вкладки настоящие. Роль обещает скринридеру стрелочную навигацию и связь с панелью; необеспеченное обещание хуже отсутствующего.

Пример
Вершин 12 480, полигонов 6 240.
Разметка
<div class="inst-tabs" role="tablist" aria-label="Свойства объекта">
  <button class="inst-tab" type="button" role="tab" id="t-geom"
          aria-selected="true" aria-controls="p-geom" tabindex="0">Геометрия</button>
  <button class="inst-tab" type="button" role="tab" id="t-mat"
          aria-selected="false" aria-controls="p-mat" tabindex="-1">Материал</button>
  <button class="inst-tab" type="button" role="tab" id="t-phys"
          aria-selected="false" aria-controls="p-phys" tabindex="-1">Физика</button>
</div>
<div id="p-geom" role="tabpanel" aria-labelledby="t-geom" tabindex="0">Вершин 12 480, полигонов 6 240.</div>
<div id="p-mat" role="tabpanel" aria-labelledby="t-mat" tabindex="0" hidden>Стандартный PBR, две текстуры.</div>
<div id="p-phys" role="tabpanel" aria-labelledby="t-phys" tabindex="0" hidden>Выпуклая оболочка, масса 4,2 кг.</div>

Стрелки, Home, End и roving tabindex — работа instrument.js. Показ нужной панели — приложения: кит не знает, какая панель за какой вкладкой.

Состояния

Пример
Разметка
<nav class="inst-tabs" aria-label="Пример активной вкладки">
  <a class="inst-tab" href="#" aria-current="page">Активная</a>
  <a class="inst-tab" href="#">Обычная</a>
  <a class="inst-tab" href="#">Ещё одна</a>
</nav>
Признак активной Что даёт
Подчёркивание border-block-end шириной --size-marker цветом --accent-solid. Носитель наравне с цветом
Цвет подписи --text-primary вместо --text-secondary

Начертание при выборе не меняется, и это не упущение. Смена веса меняет ширину подписи, а вкладки стоят в ряд: замер на этом справочнике дал сдвиг соседей вправо на 2 и 3 пикселя — выбор мышью двигал бы под курсором то, во что целятся. То же правило кит уже применил к пункту меню и к чипу.

Носителей остаётся два, и один из них не цвет: от обычной вкладки активная отличается наличием полосы, от наведённой — её тоном.

Полоса вкладки перекрывает линию контейнера отрицательным полем в --hairline, а не сдвигом на полпикселя: браузер не рисует рамку тоньше физического пикселя, и остаток был бы виден на каждом стыке.

JS

Подключите модуль один раз на страницу.

<script type="module" src="instrument.js"></script>

Что делает instrument.js

Выполняет контракт роли role="tablist": ← и → между вкладками, Home, End, обход по кругу, один Tab на всю полосу. aria-selected переносится и по стрелке, и по клику.

События

inst:select всплывает с выбранной вкладки, detail — { value }: data-value, если он есть, иначе подпись.

tabs.addEventListener('inst:select', (e) => {
  for (const panel of panels) {
    panel.hidden = panel.id !== e.detail.value;
  }
});

Что остаётся приложению

Показ нужной панели и скрытие остальных.

Композиции

С иконкой и счётчиком

Пример
Разметка
<nav class="inst-tabs" aria-label="Пример со счётчиком">
  <a class="inst-tab" href="#" aria-current="page">
    <svg class="inst-icon" aria-hidden="true"><use href="#i-list"/></svg>Очередь
    <span class="inst-badge">7</span></a>
  <a class="inst-tab" href="#">История</a>
</nav>

Зазор внутри вкладки — --space-3, между вкладками — --space-6: расстояние между соседями обязано быть заметно больше, чем внутри одной, иначе иконка следующей читается как часть предыдущей.

Вкладки-чипы

Второй вид вкладки, и это другая работа. Вкладка-полоса переключает раздел: разделов немного, они заданы заранее и не закрываются. Чип держит открытый документ: их сколько угодно, они появляются и закрываются по ходу дела, у каждого своё состояние «не сохранён».

Пример
Разметка
<div class="inst-tabs inst-tabs--chips" role="tablist" aria-label="Открытые файлы">
  <div class="inst-tab" role="tab" aria-selected="false" tabindex="-1">tokens.css
    <button class="inst-tab-close" type="button" aria-label="Закрыть tokens.css">
      <svg class="inst-icon" aria-hidden="true"><use href="#i-close"/></svg></button></div>
  <div class="inst-tab" role="tab" aria-selected="true" tabindex="0" data-state="modified">layout.css
    <button class="inst-tab-close" type="button" aria-label="Закрыть layout.css">
      <svg class="inst-icon" aria-hidden="true"><use href="#i-close"/></svg></button></div>
  <div class="inst-tab" role="tab" aria-selected="false" tabindex="-1">components.css
    <button class="inst-tab-close" type="button" aria-label="Закрыть components.css">
      <svg class="inst-icon" aria-hidden="true"><use href="#i-close"/></svg></button></div>
</div>

Полоса под таким рядом перестаёт работать: она рисует одну линию под всеми, и на двадцати вкладках выбранную приходится искать. Заливка читается сразу и на любой длине ряда.

Ряд — утопленная дорожка, а выбранный чип на ней приподнят. Одной приподнятости мало, когда ряд лежит на приподнятой поверхности: разница в один шаг светлоты — почти ничто. Поэтому вниз уходит ряд, а не вверх чип, и между ними получается два шага. Глубину несёт порядок светлоты — как и везде в ките.

Высота чипа — из лестницы контролов (--control-h-sm), а не из отступа: набор отступом давал ряд выше стоящей рядом кнопки, и чип читался кирпичом.

Крестик — оверлей у дальнего края, а не место в строке. Имя файла читают всё время, закрывают вкладку один раз, поэтому крестик всплывает под курсором поверх подписи; подпись при этом не обрывается на полуслове — под крестиком лежит градиент к фону чипа. Появляется он и от клавиатуры: невидимая кнопка, до которой доехал Tab, — ловушка.

Место под точку «не сохранено» резервируется, и это не то же самое. Состояние стоит постоянно, а постоянный оверлей навсегда съел бы конец имени.

Чип скруглён со всех четырёх сторон и лежит на полосе, а не прирастает к содержимому снизу. Скруглены только верхние углы плюс метка-полоса — это вкладка-полоса, переодетая в заливку, и от чипа она отличается ровно тем, чем не должна. Носителей у выбранного два, и ни один не цвет: заливка и начертание.

Закрываемая вкладка — <div role="tab">, а не <button>. Крестик обязан быть настоящей кнопкой: вкладки закрывают с клавиатуры, и у крестика своя цель нажатия. А кнопку внутрь кнопки HTML не пускает вовсе — интерактивное содержимое запрещено потомком <button> и <a>. Вкладка-полоса остаётся кнопкой или ссылкой: закрывать там нечего.

Что Обязательно Почему
<div role="tab"> у закрываемой да Настоящий крестик внутри <button> — невалидная разметка
tabindex бегущий: 0 у выбранной, -1 у прочих да Тридцать открытых файлов не должны стоить тридцати нажатий Tab
aria-label на крестике да Кнопка без текста не имеет доступного имени, а «закрыть» без имени файла не говорит что именно
data-state="modified" нет Точка на месте крестика, пока файл не сохранён. Место под неё резервируется, крестик возвращается под курсором
Скругление из --radius-control-md — Чип и есть контрол: в плотном режиме он скругляется слабее вместе с кнопкой

API

Классы
ИмяЧто делает
класс
inst-tabsПолоса вкладок. Нижняя линия и горизонтальная прокрутка без видимого скроллбара
inst-tabВкладка. Ссылка или кнопка с role="tab"
inst-tab-closeКрестик закрытия. Настоящая <button>, aria-label обязателен. Закрываемая вкладка поэтому <div role="tab">, а не <button>. Область растёт до --tap-min, форма остаётся мелкой
модификатор
inst-tabs--chipsВкладки-чипы: выбранная заливкой, а не полосой. Для ОТКРЫТЫХ ДОКУМЕНТОВ, которых сколько угодно и которые закрываются
Атрибуты
ИмяЗначениеЧто делает
атрибут
data-statemodifiedНа .inst-tab: на месте крестика точка, пока файл не сохранён. Крестик возвращается под курсором
Токены
ИмяЗначениеОткуда
токен
--space-36pxодно на все ячейки
--space-48pxодно на все ячейки
--space-616pxодно на все ячейки
--text-sm0.875remбаза +4: масштаб
--weight-medium600одно на все ячейки
--size-marker2pxбаза +4: масштаб
--hairline1pxбаза +1: экран
--borderlight-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11))одно на все ячейки
--surface-recessedДорожка, на которой лежит ряд чипов. Она и позволяет выбранному чипу быть приподнятымlight-dark(oklch(0 0 0 / 0.060), oklch(1 0 0 / 0.050))одно на все ячейки
--control-h-smВысота чипа. Набор отступом давал ряд выше соседней кнопки24pxбаза +14: плотность · масштаб
--size-icon-smЗначок, который несёт чип: метка файла, мельче значка контрола14pxбаза +4: масштаб
--control-pad-smВнутренний отступ чипа по горизонтали. Чип — контрол и берёт меру контролаcalc(var(--control-h-sm) * var(--control-ratio-sm))одно на все ячейки
--control-h-xsКоробка крестика. Квадрат с полем вокруг глифа20pxбаза +14: плотность · масштаб
--surface-raisedПоверхность выбранного чипа, на два шага выше дорожкиlight-dark(var(--n-0), var(--n-12))база +1: тема
--accent-solidvar(--a-4)одно на все ячейки
--text-primarylight-dark(var(--n-12), var(--n-1))одно на все ячейки
--text-secondarylight-dark(var(--n-9), var(--n-5))одно на все ячейки

Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument