Оверлеи
Меню
Список действий над объектом. Меню — это содержимое поповера, а не самостоятельный оверлей: верхний слой, закрытие и фокус приходят оттуда.
Разметка
<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>
Использование
<div class="inst-popover inst-popover--anchored" id="menu-1" popover>
<div class="inst-menu" role="menu">
<button class="inst-menu-item" type="button" role="menuitem">Перезапустить</button>
</div>
</div>
| Что | Обязательно | Почему |
|---|---|---|
role="menu" на контейнере |
да | Без него role="menuitem" невалиден |
role="menuitem" на пунктах |
да | Кит рисует состояние, но не выдумывает роль |
type="button" у пунктов-кнопок |
да | Иначе внутри формы меню её отправит |
aria-hidden="true" у иконки |
да | Имя пункту даёт подпись, а не иконка |
| Обёртка поповера | да | Меню само по себе не всплывает: верхний слой и закрытие приходят от popover |
Перемещение стрелками и бегущий tabindex выполняет
kit.js. Открытие, закрытие по Escape и по
клику мимо, возврат фокуса на кнопку скрипта не требуют вовсе — их берёт на
себя Popover API.
Когда использовать
| Используйте | Возьмите другое |
|---|---|
| Несколько действий над объектом, которые не помещаются в строку | Два-три частых действия — группа кнопок на виду: меню прячет то, что нажимают каждый раз |
| Редкие и разрушительные действия, убранные из основного вида | Выбор значения формы — селект: у меню нет значения и оно не отправляется |
Переключаемая настройка вида — пункт с aria-checked |
Выбор одного из равных режимов на виду — сегментированный контрол |
| Навигация по разделам во всплывающем списке | Постоянная навигация приложения — боковая навигация |
Устройство
Подпись группы, разделитель и горячая клавиша — три разные работы, и путать их дорого.
<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>
| Состояние пункта | Как ставится | Что происходит |
|---|---|---|
| обычный | — | --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
Подключите модуль один раз на страницу — инициализировать компоненты по отдельности не нужно, кит работает делегированием и видит узлы, пришедшие позже.
<script type="module" src="src/kit.js"></script>
Что делает кит
Пример в шапке живой: откройте меню и пройдите по пунктам стрелками.
Стрелки между пунктами, 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);
});
Меню закрывается до действия: действие может открыть модалку, и меню осталось бы висеть поверх неё.
Правила
Так Действие — кнопка, переход — ссылка
Пункт, ведущий на адрес, обязан быть <a>: иначе ломаются средний клик и
«открыть в новой вкладке».
Не так Частое действие в меню
Меню прячет то, что нажимают каждый раз. Два-три частых действия стоят на виду — группа кнопок.
Так Разрушительный пункт назван словом
«Удалить прогон» красным. Тон — второй признак, а не единственный.
Не так role="menu" без стрелок
Роль обещает перемещение стрелками. Без него длинное меню проходится табом, что мучительно.
Доступность
| Клавиатура | Tab до кнопки, Enter — открыть, Escape — закрыть. Стрелки внутри меню ставит приложение: без них меню проходится табом, что для длинного списка мучительно |
| Роли | role="menu" + role="menuitem" обязательны. aria-checked без role="menuitem" не озвучивается |
| Недоступный пункт | aria-disabled="true", а не disabled, если пункт должен оставаться в порядке обхода и объяснять, почему он недоступен |
| Цвет не единственный носитель | Разрушительный пункт красный и назван словом «Удалить». Отмеченный несёт aria-checked, а не только цвет |
| Цель нажатия | Пункт высотой --control-h-sm во всю ширину поповера: попасть мышью проще, чем в текст |
| Перенос | white-space: nowrap — пункт не переносится. Длинную подпись сокращайте, а не надейтесь на перенос |
API
| Имя | Значение | Что делает |
|---|---|---|
| класс | ||
inst-menu | — | Контейнер. Колонка пунктов с зазором --space-1 |
inst-menu-item | — | Пункт: <button> или <a> |
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) | |
--control-h-sm | 26px | |
--radius-sm | 5px | |
--text-2xs | 0.6875rem | |
--text-sm | 0.8125rem | |
--weight-medium | 500 | |
--hairline | 1px | |
--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)) | |
--err-text | light-dark(var(--err-5), var(--err-3)) | |
--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