Оверлеи
Меню
Список действий над объектом. Меню — это содержимое поповера, а не самостоятельный оверлей: верхний слой, закрытие и фокус приходят оттуда.
Разметка
<button class="inst-btn" type="button" popovertarget="menu-run">Действия</button>
<div class="inst-popover inst-popover--anchored" id="menu-run" popover>
<div class="inst-menu" role="menu">
<span class="inst-menu-label">Прогон #4127</span>
<button class="inst-menu-item" type="button" role="menuitem">
<svg class="inst-icon" aria-hidden="true"><use href="#i-refresh"/></svg>Перезапустить
<span class="inst-menu-shortcut"><kbd>R</kbd></span></button>
<button class="inst-menu-item" type="button" role="menuitem">
<svg class="inst-icon" aria-hidden="true"><use href="#i-copy"/></svg>Скопировать id</button>
<button class="inst-menu-item" type="button" role="menuitem" aria-checked="true">
<svg class="inst-icon" aria-hidden="true"><use href="#i-list"/></svg>Показывать лог</button>
<span class="inst-menu-sep"></span>
<button class="inst-menu-item" type="button" role="menuitem" data-tone="error">Удалить прогон</button>
<button class="inst-menu-item" type="button" role="menuitem" aria-disabled="true">Архивировать</button>
</div>
</div>
Контракт
| Что | Обязательно | Почему |
|---|---|---|
role="menu" на контейнере |
да | Без него role="menuitem" невалиден |
role="menuitem" на пунктах |
да | Библиотека рисует состояние, но не выдумывает роль |
type="button" у пунктов-кнопок |
да | Иначе внутри формы меню её отправит |
aria-hidden="true" у иконки |
да | Имя пункту даёт подпись, а не иконка |
| Обёртка поповера | да | Меню само по себе не всплывает: верхний слой и закрытие приходят от popover |
Перемещение стрелками и бегущий tabindex выполняет
instrument.js. Открытие, закрытие по Escape и по
клику мимо, возврат фокуса на кнопку скрипта не требуют вовсе — их берёт на
себя Popover API.
Доступность
| Клавиатура | Tab до кнопки, Enter — открыть, Escape — закрыть. Стрелки внутри меню ставит приложение: без них меню проходится табом, что для длинного списка мучительно |
| Роли | role="menu" + role="menuitem" обязательны. aria-checked без role="menuitem" не озвучивается |
| Недоступный пункт | aria-disabled="true", а не disabled: пункт остаётся в обходе стрелками и получает бегущий tabindex, но не выбирается — ни щелчок, ни Enter не зовут приложение (поведение). disabled убирает пункт из обхода силами платформы, и о действии не узнают вовсе |
| Цвет не единственный носитель | Разрушительный пункт красный и назван словом «Удалить». Отмеченный несёт aria-checked, а не только цвет |
| Цель нажатия | Пункт высотой --control-h-sm во всю ширину поповера: попасть мышью проще, чем в текст |
| Перенос | white-space: nowrap — пункт не переносится. Длинную подпись сокращайте, а не надейтесь на перенос |
Устройство
Подпись группы, разделитель и горячая клавиша — три разные работы, и путать их дорого.
<div class="inst-menu" role="menu">
<span class="inst-menu-label">Прогон #4127</span>
<button class="inst-menu-item" type="button" role="menuitem">Перезапустить
<span class="inst-menu-shortcut"><kbd>R</kbd></span></button>
<span class="inst-menu-sep"></span>
<button class="inst-menu-item" type="button" role="menuitem">Экспорт</button>
</div>
| Класс | Работа |
|---|---|
inst-menu-label |
Подпись группы. Не интерактивна и не попадает в обход |
inst-menu-item |
Пункт. <button> для действия, <a> для перехода |
inst-menu-sep |
Разделитель групп. <span>, а не <hr>: он оформление, и озвучивать его нечем |
inst-menu-shortcut |
Горячая клавиша у дальнего края |
Горячая клавиша прижимается к дальнему краю и тише подписи: она подсказка, а не второе название пункта.
Пункт — это <button> или <a>, смотря по работе. Действие — кнопка, переход
по адресу — ссылка: кнопка, ведущая на адрес, ломает средний клик и «открыть в
новой вкладке».
Состояния
Разметка
<div class="inst-popover" popover id="menu-kinds">
<div class="inst-menu" role="menu">
<span class="inst-menu-label">Вид</span>
<button class="inst-menu-item" type="button" role="menuitem">Обычный</button>
<button class="inst-menu-item" type="button" role="menuitem" aria-checked="true">Отмеченный</button>
<a class="inst-menu-item" href="#menu" role="menuitem" aria-current="page">Текущий адрес</a>
<span class="inst-menu-sep"></span>
<button class="inst-menu-item" type="button" role="menuitem" data-tone="error">Разрушительный</button>
<button class="inst-menu-item" type="button" role="menuitem" aria-disabled="true">Недоступный</button>
</div>
</div>
<button class="inst-btn" type="button" popovertarget="menu-kinds">Виды пунктов</button>
Пункт с пояснением. Обёртка .inst-menu-item-main делает пункт двухстрочным
сама — модификатора для этого нет, потому что это не выбор автора разметки, а
следствие того, что внутри. Меню выбора без пояснения превращается в список
кодовых слов.
Разметка
<div class="inst-popover" popover id="menu-model">
<div class="inst-menu" role="menu">
<button class="inst-menu-item" type="button" role="menuitem">
<span class="inst-menu-item-main">Быстрый
<span class="inst-menu-item-sub">Для коротких прогонов</span></span></button>
<button class="inst-menu-item" type="button" role="menuitem" aria-checked="true">
<span class="inst-menu-item-main">Полный
<span class="inst-menu-item-sub">Для сложных задач</span></span></button>
<button class="inst-menu-item" type="button" role="menuitem">
<span class="inst-menu-item-main">Экономный
<span class="inst-menu-item-sub">Дешевле всего на повседневном</span></span></button>
</div>
</div>
<button class="inst-btn" type="button" popovertarget="menu-model">Режим прогона</button>
| Состояние пункта | Как ставится | Что происходит |
|---|---|---|
| обычный | — | --text-primary |
| наведение | :hover |
--surface-hover |
| отмеченный | aria-checked="true" |
--accent-text и средняя насыщенность |
| текущий | aria-current (любое значение, кроме false) |
То же оформление, что у отмеченного |
| разрушительный | data-tone="error" |
--err-text, а на наведении --err-bg |
| недоступный | aria-disabled="true" или disabled |
Прозрачность 0.5, мышь снята |
JS
Подключите модуль один раз на страницу — инициализировать компоненты по
отдельности не нужно, instrument.js работает делегированием и видит узлы, пришедшие
позже.
<script type="module" src="instrument.js"></script>
Что делает instrument.js
Пример в шапке живой: откройте меню и пройдите по пунктам стрелками.
Стрелки между пунктами, Home, End, перебор по кругу и бегущий tabindex.
Выделения у пункта нет и не будет: пункт меню — действие, а не выбор, и
aria-selected на нём соврал бы вспомогательной технологии.
Открытие, закрытие, возврат фокуса и закрытие по Escape берёт на себя
Popover API — скрипта они не требуют.
События
Своих нет. Пункт — это <button>, и его click работает так же, как везде.
menu.addEventListener('click', (e) => {
const item = e.target.closest('[role="menuitem"]');
if (!item) return;
menu.hidePopover();
run(item.dataset.action);
});
Меню закрывается до действия: действие может открыть модалку, и меню осталось бы висеть поверх неё.
API
| Имя | Что делает |
|---|---|
| класс | |
inst-menu | Контейнер. Колонка пунктов с зазором --space-1 |
inst-menu-item | Пункт: <button> или <a> |
inst-menu-item-main | Обёртка названия и пояснения. Её наличие делает пункт двухстрочным — модификатора для этого нет |
inst-menu-item-sub | Пояснение под названием. Для меню выбора, где одно название ни о чём не говорит |
inst-menu-label | Подпись группы. Не интерактивна |
inst-menu-sep | Разделитель толщиной в волосок |
inst-menu-shortcut | Горячая клавиша у дальнего края пункта |
| Имя | Значение | Что делает |
|---|---|---|
| атрибут | ||
data-tone | — | neutral running ok warn error. Закрыт. На пункте осмыслен только error |
aria-checked | — | true — пункт отмечен |
aria-current | — | Любое значение, кроме false |
aria-disabled | — | true — пункт недоступен |
| Имя | Значение | Откуда |
|---|---|---|
| токен | ||
--space-1 | 2px | одно на все ячейки |
--space-2 | 4px | одно на все ячейки |
--space-3 | 6px | одно на все ячейки |
--space-6 | 16px | одно на все ячейки |
--gap-inline | var(--space-4) | база +14: плотность · масштаб |
--control-h-sm | 24px | база +14: плотность · масштаб |
--radius-sm | 6px | база +4: масштаб |
--text-2xs | 0.6875rem | база +4: масштаб |
--text-sm | 0.875rem | база +4: масштаб |
--weight-medium | 600 | одно на все ячейки |
--hairline | 1px | база +1: экран |
--border-subtle | light-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06)) | одно на все ячейки |
--surface-hover | light-dark(oklch(0 0 0 / 0.035), oklch(1 0 0 / 0.045)) | одно на все ячейки |
--accent-text | light-dark(var(--a-5), var(--a-3)) | база +1: тема |
--err-text | light-dark(var(--err-5), var(--err-3)) | база +1: тема |
--err-bg | light-dark(var(--err-1), color-mix(in oklab, var(--err-4) 16%, transparent)) | одно на все ячейки |
--text-muted | light-dark(var(--n-8), var(--n-6)) | одно на все ячейки |
--leading-ui | 1.4 | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument