Навигация
Вкладки
Горизонтальный переключатель видов внутри одного раздела. По умолчанию — на ссылках: переключение экрана это роутинг, и 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" ставится только если вкладки настоящие. Роль обещает
скринридеру стрелочную навигацию и связь с панелью; необеспеченное обещание
хуже отсутствующего.
Разметка
<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-state | modified | На .inst-tab: на месте крестика точка, пока файл не сохранён. Крестик возвращается под курсором |
| Имя | Значение | Откуда |
|---|---|---|
| токен | ||
--space-3 | 6px | одно на все ячейки |
--space-4 | 8px | одно на все ячейки |
--space-6 | 16px | одно на все ячейки |
--text-sm | 0.875rem | база +4: масштаб |
--weight-medium | 600 | одно на все ячейки |
--size-marker | 2px | база +4: масштаб |
--hairline | 1px | база +1: экран |
--border | light-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-solid | var(--a-4) | одно на все ячейки |
--text-primary | light-dark(var(--n-12), var(--n-1)) | одно на все ячейки |
--text-secondary | light-dark(var(--n-9), var(--n-5)) | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument