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

Оверлеи

Меню

JS Стрелки и бегущий tabindex делает `instrument.js`. Открытие и закрытие берёт на себя Popover API

Список действий над объектом. Меню — это содержимое поповера, а не самостоятельный оверлей: верхний слой, закрытие и фокус приходят оттуда.

Компонент
Разметка
<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-12pxодно на все ячейки
--space-24pxодно на все ячейки
--space-36pxодно на все ячейки
--space-616pxодно на все ячейки
--gap-inlinevar(--space-4)база +14: плотность · масштаб
--control-h-sm24pxбаза +14: плотность · масштаб
--radius-sm6pxбаза +4: масштаб
--text-2xs0.6875remбаза +4: масштаб
--text-sm0.875remбаза +4: масштаб
--weight-medium600одно на все ячейки
--hairline1pxбаза +1: экран
--border-subtlelight-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06))одно на все ячейки
--surface-hoverlight-dark(oklch(0 0 0 / 0.035), oklch(1 0 0 / 0.045))одно на все ячейки
--accent-textlight-dark(var(--a-5), var(--a-3))база +1: тема
--err-textlight-dark(var(--err-5), var(--err-3))база +1: тема
--err-bglight-dark(var(--err-1), color-mix(in oklab, var(--err-4) 16%, transparent))одно на все ячейки
--text-mutedlight-dark(var(--n-8), var(--n-6))одно на все ячейки
--leading-ui1.4одно на все ячейки

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