Перейти к содержимому
Плотность
Тема

Оверлеи

Меню

JS Стрелки и бегущий tabindex делает кит. Открытие и закрытие берёт на себя 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>

Использование

<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-toneneutral running ok warn error. Закрыт. На пункте осмыслен только error
aria-checkedtrue — пункт отмечен
aria-currentЛюбое значение, кроме false
aria-disabledtrue — пункт недоступен
токен
--space-12px
--space-24px
--space-36px
--space-616px
--gap-inlinevar(--space-4)
--control-h-sm26px
--radius-sm5px
--text-2xs0.6875rem
--text-sm0.8125rem
--weight-medium500
--hairline1px
--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))
--err-textlight-dark(var(--err-5), var(--err-3))
--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