Создание раскрывающегося меню

Создание раскрывающегося меню

В этом руководстве рассматриваются все всплывающие меню: раскрывающиеся, контекстные, дополнительные и строки меню, которые их открывают. В качестве примера рассмотрим меню команд приложения, в котором элементы изменяют интерфейс (создать, сохранить, настройки).

Два независимых выбора. Все остальные сведения в этой статье относятся к обоим типам.

  • Элемент, по одному на товар. Команда – это <button type="button">, а целевая страница – это <a href>, которая по-прежнему открывается в новой вкладке, копируется как URL и работает без скрипта. В раскрывающемся списке они могут быть смешаны в любом порядке: две ссылки и шесть команд или одна команда и шесть ссылок.
  • Модель взаимодействия (раскрывающийся список). Клавиши со стрелками, Home/End и Esc превращают его в меню: добавьте focusgroup, позвольте ему выводить menuitem для каждого элемента, будь то кнопка или ссылка, и сделайте его похожим на меню (непрерывная панель или триггер с индикатором раскрывающегося списка; см. раздел "Панель меню или отдельные кнопки меню" ниже). Короткий список ссылок, по которым пользователи Tab, – это навигация. Не используйте focusgroup, role="menu", role="menuitem" или aria-haspopup="menu". Оставьте popover, Esc и восстановление фокуса. Ознакомьтесь с руководством по панели навигации.

Контент никогда не определяет роли. Раскрывающийся список ссылок "Товары" может быть меню, а раскрывающийся список команд "Аккаунт" не может быть навигацией.

Используйте шаблон меню только для составного виджета с одной позицией табуляции и навигацией с помощью клавиш со стрелками. Используйте <div focusgroup="menu nomemory">, а не HTML-элемент <menu>. Не добавляйте role="menu" или role="menuitem" в общую разметку: нативная группа фокуса выводит эти роли, а явные роли блокируют этот вывод. Добавьте их только в резервный код для неподдерживаемых браузеров. nomemory – при повторном открытии меню фокус будет на первом пункте. Focusgroup перемещает фокус, а скрипт обрабатывает предварительный просмотр, активацию и восстановление фокуса.

Как открыть и разместить меню

Сделайте каждый триггер вызывающим команду с помощью commandfor и command="toggle-popover". Браузер открывает и закрывает всплывающее окно по щелчку, Enter и Space и сохраняет триггер внутри группы всплывающих окон, поэтому щелчок по открытому триггеру закрывает его. Не используйте popovertarget или popovertargetaction. В этом шаблоне используется commandfor.

Размещайте всплывающие окна сразу после триггеров. Браузеры добавляют неявную связь aria-details, только когда всплывающее окно не является следующим дочерним элементом, поэтому такое размещение позволяет избежать этого без дополнительных атрибутов.

<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
  commandfor="file-menu" command="toggle-popover">
  File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
  <!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
  <div focusgroup="menu nomemory" aria-labelledby="file-trigger">
    <button type="button">New file</button>
    <button type="button">Save</button>
    <button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
      commandfor="export-menu" command="toggle-popover">
      Export <span aria-hidden="true">›</span>
    </button>
    <!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
    <div id="export-menu" popover="auto">
      <div focusgroup="menu nomemory" aria-labelledby="export-trigger">
        <button type="button">PDF</button>
        <button type="button">Plain Text</button>
      </div>
    </div>
  </div>
</div>

Укажите название каждого меню с помощью атрибута aria-labelledby, указывающего на его триггер. Оставьте декоративные индикаторы подменю в CSS или добавьте aria-hidden="true". Задайте для триггеров с атрибутом aria-expanded="true" видимый активный стиль, чтобы пользователи могли определить, какое меню открыто или доступно для предварительного просмотра.

Выберите одну форму для триггеров верхнего уровня. Роль, модель клавиатуры и стиль должны совпадать: визуально разделенные кнопки указывают на одну остановку Tab каждая.

  • Строка меню. role="menubar" в контейнере (не во всплывающем окне), оформленная как одна непрерывная панель без промежутков между триггерами. Tab – остановка; ArrowLeft/ArrowRight – перемещение между триггерами; наведение указателя мыши – открытие меню. Используйте для панели команд приложения.
  • Отдельные кнопки меню. Каждый триггер – это отдельная остановка Tab, которая выглядит как кнопка. Нет role="menubar", нет клавиш со стрелками между триггерами, нет предварительного просмотра при фокусе, нет переключения при наведении. Если они используются для навигации, оберните их в <nav>, в противном случае роль не нужна. Используется для панелей инструментов и заголовков страниц.

В этом руководстве описывается форма панели меню. Если вы хотите использовать отдельные кнопки, оставьте все элементы внутри каждого меню и откажитесь от поведения на уровне строки меню.

Позиционирование меню с помощью CSS Anchor Positioning

Используйте позиционирование привязки CSS, а не координаты JavaScript. И command="toggle-popover", и showPopover({ source: trigger }) задают неявную привязку, поэтому не добавляйте правила anchor-name или position-anchor для каждого меню. Поведение меню не должно зависеть от позиционирования привязки.

/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
  position-area: block-end span-inline-end;
  inset: auto;
}

/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
  position-area: inline-end span-block-end;
  position-try-fallbacks: flip-inline, flip-block, --below;
}

/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
   covers its trigger. */
@position-try --below {
  position-area: block-end span-inline-end;
  margin-inline-start: 1rem;
  /* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
  min-inline-size: 0;
  max-inline-size: calc(100% - 1rem);
}

Оставьте --below в конце, чтобы это правило применялось, только если не подходит ни одна из сторон. Оно открывается прямо под триггером, поэтому на узком экране оно закрывает дочерние элементы под триггером; это приемлемый компромисс, позволяющий оставаться рядом с триггером. Также разрешите подменю уменьшаться до области (min-inline-size: 0, max-inline-size минус отступ). Неподходящие резервные варианты пропускаются, и если ни один из них не подходит, браузер сохраняет базовое размещение, поэтому подменю, которое на несколько пикселей шире, чем нужно, незаметно закрывает свой триггер.

О том, как работают резервные пути для простого всплывающего окна, читайте в статье Раскрывающиеся списки, адаптирующиеся к области просмотра. В этом руководстве описывается размещение группы кнопок, а не меню. Для меню приоритет имеют размещение подменю и резервный вариант --below, описанные выше.

Анимировать открытие и закрытие

Необязательное поле. Менее 150 мс, только opacity и небольшой translate, то же самое для подменю, внутри @media (prefers-reduced-motion: no-preference). Фокус перемещается на toggle, поэтому переход не задерживает пользователей клавиатуры.

@media (prefers-reduced-motion: no-preference) {
  /* Closing: this rule is in effect once :popover-open is removed. */
  [popover] {
    opacity: 0;
    translate: 0 -.25rem;
    transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
  }
  /* Opening: faster. */
  [popover]:popover-open {
    opacity: 1;
    translate: 0 0;
    transition-duration: .1s;
    @starting-style { opacity: 0; translate: 0 -.25rem; }
  }
  /* Switching between open menubar menus is instant, as in native menubars. */
  [role="menubar"]:has(> [popover]:popover-open) > [popover] {
    transition: none;
  }
}

@starting-style не повторяет [popover]: закрытое всплывающее окно – это display: none, а переходы не выполняются из неотрисованного состояния, поэтому оно предоставляет значения до открытия. Без него открывание происходит мгновенно, а закрывание анимируется. display и overlay с allow-discrete сохраняют всплывающее окно отрисованным и в верхнем слое до тех пор, пока не завершится переход закрытия.

Клавиатура и фокус

Не добавляйте вторую систему roving-tabindex в focusgroup. Скрипт охватывает только то, что не делают вызывающие объекты и группы фокуса:

  • Одна позиция табуляции для строки меню. Tab входит в первый триггер верхнего уровня. ArrowLeft и ArrowRight – перемещение между триггерами верхнего уровня и переход к началу или концу. Home и End на триггере открывают меню на первом или последнем элементе.
    • Примечание. К какому триггеру возвращается Tab. В APG можно использовать либо первый триггер, либо последний. В руководстве указан первый вариант, но в демоверсии позиция табуляции перемещается с помощью клавиш ArrowLeft и ArrowRight, поэтому после навигации с помощью клавиатуры сочетание клавиш Shift + Tab возвращает фокус на последний триггер. Указатель не перемещается, поэтому та же последовательность может привести к другому триггеру. Допустимы оба варианта. Выберите один и придерживайтесь его.
  • Предварительный просмотр. При фокусировке на триггере верхнего уровня открывается его меню и устанавливается значение aria-expanded="true". Фокус остается на триггере, и ни один элемент не выделяется. При размытии триггера закрывается меню, доступное только для предварительного просмотра.
  • Нажмите Enter и Пробел на предварительном просмотре. Они закрывают предварительный просмотр и сохраняют фокус на триггере. Это действие обрабатывается нативным действием toggle-popover, поэтому скрипт не должен перехватывать эти клавиши.
  • Стрелка вниз на предварительном просмотре. Преобразует его в открытое меню и фокусируется на первом элементе.
  • Нажмите на открытый или предварительно просмотренный триггер. Закрывает его, не открывая меню.
    • Известное различие в триггерах подменю. Нажатие указателем на триггер подменю также переключает его, поэтому оно может закрыть подменю, которое уже было предварительно просмотрено при наведении, или открыть то, которое ещё не появилось. Нативные меню только открывают его и никогда не закрывают. Это безопасно, поэтому гид принимает его.
  • Нажатие на закрытый триггер. Открывает его в нативном режиме. Фокус остается на триггере, ни один элемент не выделен. В меню можно войти, только открыв клавиатуру.
  • Указатель наведен на объект. Фокусировка без кольца, поэтому наведение и фокус – это один элемент.
  • Указатель наведен на другой триггер верхнего уровня, когда меню открыто. Закрывает его и показывает предварительную версию того, на которое наведен указатель, фокусируя его триггер без кольца. Если ничего не открыто, при наведении указателя ничего не происходит.
  • Указатель на триггере подменю. Показывает подменю после небольшой задержки (при наведении указателя мыши происходит то же самое). Закрывается при открытии другого элемента.
  • В меню. ArrowUp и ArrowDown – перемещение между включенными объектами. Home и End – переход к первому и последнему активному элементу.
  • Стрелка влево или Стрелка вправо в открытом меню верхнего уровня. Закрывает его и показывает соседнее меню, фокусируясь на его триггере, с переносом на любой конец. ArrowDown, а затем вводит его. Стрелки влево и вправо не позволяют перейти в меню.
  • Стрелка вправо на триггере подменю. Открывает подменю и переводит фокус на его первый пункт.
  • Нажатие клавиши Стрелка вправо на пункте подменю, у которого нет дочернего подменю. Закрывает иерархию и показывает следующее меню верхнего уровня, пока фокус остается на его триггере. ArrowDown, а затем вводит его.
  • Стрелка влево или Esc в подменю. Закрывает только это подменю и возвращает фокус на его триггер. Родительское меню остается открытым.
  • Нажмите Ввод или Пробел. Активирует его и закрывает все открытые меню.
  • Tab. Выход из меню. Не задерживайте фокус и не перебирайте все команды. После закрытия фокус не должен оставаться на скрытом контенте.

Событие command активируется до того, как toggle-popover откроет меню, поэтому фокус не может переместиться в меню. Переместите фокус в событие toggle. HTML восстанавливает фокус только для первого элемента popover="auto" в стеке, поэтому вложенные уровни должны явно восстанавливать фокус на своем триггере.

Реализация взаимодействия

Реализуйте описанные выше действия для собственной разметки. Стрелка, Home/End, перенос и обработка выхода по нажатию Tab – это обычный код keydown. Ниже перечислены некоторые распространенные ошибки, сгруппированные по типу ввода.

Управление клавиатурой и фокусом

  1. Один открытый контур. При открытии с помощью клавиатуры вызывается trigger.click(), поэтому toggle-popover вызывающего объекта – единственный способ открыть меню. Отметьте меню, чтобы обработчик toggle вошел в него. Откроется только клавиатура. При открытии с помощью указателя toggle фокусируется на триггере без кольца, поскольку pointerdown предотвращает фокусировку, а клавиши со стрелками по-прежнему должны работать.
  2. Предварительный просмотр при фокусировке. При фокусировке на триггере вызывается showPopover({ source: trigger }), чтобы меню привязывалось к нему. Игнорировать возврат фокуса из собственного меню триггера или из другого окна (флажок в окне blur, снят после окна focus, или переключение приложений повторно открывает последнее меню). Скрывать меню предварительного просмотра на blur, если фокус не перемещен в него.
  3. Переместите фокус в toggle, а не в command. command срабатывает до открытия меню.
  4. Возвращайте фокус на триггер самостоятельно. Установите фокус на триггер до hidePopover(). После активации указателя восстановите фокус без кольца (focus({ focusVisible: false })), или триггер покажет контур фокуса клавиатуры после клика мыши; активации клавиатуры сохраняют кольцо. Клик с event.detail === 0 был выполнен с помощью клавиатуры.
  5. Закрывайте по одному уровню за раз. Вызовите stopPropagation() для Escape и ArrowLeft в подменю, иначе прослушиватель родительского элемента также закроет его.

Обработка указателя

  1. Переключатели кликов указателя. Вызовите preventDefault() для pointerdown, чтобы клик не перемещал фокус и не запускал предварительный просмотр. Запомните, какой триггер был нажат. Нажатие на закрытый или предварительно просмотренный триггер открывает или закрывает его, не открывая меню. Меню открывается только при нажатии клавиш. Не перехватывайте клик.
  2. Всплывающие подсказки следуют за фокусом. Настройте стили :hover и :focus-visible по отдельности. При наведении указателя на элемент в pointermove вызовите focus({ focusVisible: false }). Браузеры, которые игнорируют этот параметр, не будут выделять элемент кольцом при наведении указателя.
  3. При наведении указателя мыши на переключатели открываются меню. При pointerenter на триггере верхнего уровня, когда открыто другое меню верхнего уровня: скрыть его, перевести фокус на триггер, на который наведен указатель, без кольца и показать предварительную версию при фокусе (см. выше). Если ничего не открыто, ничего не происходит.
  4. Безопасный треугольник. Фиксированный мост CSS слишком узок: он работает только тогда, когда указатель начинает движение вблизи края родительского элемента. Пусть CSS рисует мост, а скрипт только сообщает об указателе:
    • Запускайте только при matchMedia('(hover: hover)').matches. Это не относится к сенсорному экрану и клавиатуре.
    • Запускается, когда указатель покидает триггер подменю, которое открыто. Пропустить подменю --below, у которого нет боковой границы.
    • Задайте --bridge-x и --bridge-y (указатель в пикселях относительно подменю), --bridge-edge (ближний край подменю x: 0, когда указатель находится слева от него, его ширина при переворачивании) и --bridge-height в подменю, а также data-bridge, чтобы включить мост.
    • Остановитесь примерно через 300 мс, когда указатель достигнет пункта в подменю или когда подменю закроется. Удалите прослушиватель и атрибут. После этого ничего не запускается.
    • CSS задает для подменю ::before, обрезанный до треугольника от указателя до углов ближнего края. Он находится над соседними элементами на пути указателя, поэтому они никогда не получают наведение, которое закрыло бы подменю. clip-path также ограничивает проверку нажатия, поэтому остальная часть меню для родителей остается интерактивной.
    • Для подменю требуется overflow: visible (всплывающее окно по умолчанию обрезается), а псевдоэлемент выходит за его пределы, поэтому сместите точки фигуры на эту величину.
    • Дополнительное задание. Изогнутый путь (команды shape()) лучше соответствует реальным дугам указателя, чем прямой треугольник.
[popover] [popover] {
  overflow: visible;

  &[data-bridge]::before {
    content: "";
    position: absolute;
    inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
    clip-path: shape(
      from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
      line to calc(100vw + var(--bridge-edge)) 100vh,
      line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
      close
    );
  }
}
const canHover = matchMedia('(hover: hover)');

// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
  if (!canHover.matches || !submenu.matches(':popover-open')) return;
  const rect = submenu.getBoundingClientRect();
  if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
  const style = submenu.style;
  style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
  style.setProperty('--bridge-height', `${rect.height}px`);
  const update = ({ clientX, clientY, target }) => {
    if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
    style.setProperty('--bridge-x', `${clientX - rect.left}px`);
    style.setProperty('--bridge-y', `${clientY - rect.top}px`);
  };
  const timer = setTimeout(() => stop(), 300);
  const stop = () => {
    clearTimeout(timer);
    document.removeEventListener('pointermove', update);
    submenu.removeEventListener('toggle', stop);
    submenu.removeAttribute('data-bridge');
  };
  update(event);
  submenu.dataset.bridge = '';
  document.addEventListener('pointermove', update);
  submenu.addEventListener('toggle', stop, { once: true });
}

Обработчик pointermove для элемента 2 остается простым, так как для него не нужна логика треугольника.

Также:

  • Вне фокуса. Закрывать все открытые меню, когда фокус перемещается за пределы системы меню (прослушиватель document focusin).
  • Активация команд. Озвучить его, затем скрыть корневое меню и вернуть фокус на его триггер без кольца, если активация была выполнена с помощью указателя. Для элементов, которые можно отметить (menuitemcheckbox, menuitemradio), подождите около 300 мс, прежде чем закрывать меню, чтобы пользователь увидел, что флажок установлен. Оформляйте закрытый триггер как активный только в состоянии :focus-visible. В противном случае после выбора указателем он останется выделенным.
  • aria-expanded. Задайте его только в прослушивателе toggle. Оно охватывает состояния предварительного просмотра и открытия, а aria-expanded триггера остается "true", пока его меню находится в режиме предварительного просмотра или открыто.
  • Помощники. Резервные варианты ниже предполагают, что вы определяете вспомогательные элементы для разметки: triggers (все кнопки [commandfor]), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (фокус на триггере, без кольца, когда fromPointer, затем menu.hidePopover()), enterOnOpen (WeakSet меню, в которые можно войти после открытия) и pointerTrigger (триггер, нажатый указателем последним).
  • Используйте popover="auto" для подменю, размещенных после триггера подменю внутри родительского элемента focusgroup. Никогда popover="manual".
  • Вложенность позволяет браузеру держать родительское меню открытым, закрывать подменю одного уровня, закрывать дочерние подменю при закрытии корневого меню и обрабатывать закрытие при клике за пределами меню.
  • Триггер подменю сохраняет aria-haspopup="menu" и aria-expanded, а прослушиватель toggle синхронизирует aria-expanded.
  • Скрипт клавиатуры обрабатывает вложенные элементы Escape и ArrowLeft, как описано выше.
  • Триггер подменю никогда не запускает команду: один элемент управления не может одновременно активировать и открывать, а Enter не может означать и то, и другое. Сделайте действие по умолчанию первым пунктом подменю или добавьте отдельную команду рядом с триггером. Если вы вынуждены использовать комбинированный элемент управления, задокументируйте это как компромисс в отношении специальных возможностей.

Разделители и элементы с флажками

  • Разделяйте группы элементов с помощью <hr> внутри focusgroup: нативный separator, не фокусируемый, пропускается focusgroup и резервным вариантом. Не используйте стилизованный элемент <div>.
  • Логический параметр – это <button type="button"> с aria-checked и явным role="menuitemcheckbox". Focusgroup не выводит эти роли, поэтому это единственное место, где общая разметка задает роль элемента.
  • Один из элементов набора – <button type="button"> с role="menuitemradio" и aria-checked внутри role="group" с aria-label в той же группе фокуса.
  • Переключатели скриптов aria-checked.
  • Enter на флажке переключает его и закрывает меню; Space переключает его и оставляет меню открытым (APG). Переключатели закрываются при нажатии любой клавиши.
  • Отрисуйте галочку из [aria-checked="true"]::before, а не отдельный элемент значка (см. CSS ниже).

Не указано

  • Отзывы об активации. В нативных меню выбранный пункт мигает перед закрытием. При желании можно показать визуальное подтверждение длительностью около 100 мс перед hidePopover(). Не задерживайте восстановление фокуса или озвучивание.
  • Автозаполнение. У группы фокуса нет. Если нужно, на печатном keydown сопоставьте ярлыки включенных элементов текущего уровня и выделите следующее совпадение после текущего элемента, перенося по словам. Не перехватывать Space.

Проверьте перед завершением

Инструкция не гарантирует определенное поведение, поэтому протестируйте результат. Откройте страницу и проверьте каждый из этих элементов с помощью реальной клавиатуры и мыши. Исправьте и повторно протестируйте все, что не работает:

  • Клавиатура. Tab – переход к первому триггеру; ArrowDown – вход; Escape – закрытие одного уровня и возврат фокуса к его триггеру; Tab – закрытие всех меню.
  • Указатель открыт. При нажатии на триггер открывается его меню, фокус перемещается на триггер, а кольцо не появляется. При повторном нажатии меню закрывается.
  • Команда указателя. При нажатии на команду все меню закрываются, а на триггере не появляется кольцо фокусировки.
  • Подменю. Если переместить указатель по диагонали от середины триггера подменю к элементу подменю, оно останется открытым. Если переместить указатель к не связанному с ним элементу того же уровня, подменю закроется.
  • Узкий экран. При ширине 375 пикселей ни одно подменю не перекрывает свой триггер.
  • Флажки. Выбранные элементы будут отмечены флажком.
  • Никаких остатков. В консоли нет ошибок, а в триггерах нет popovertarget, popovertargetaction или aria-details.

Выравнивание контента в пунктах меню

Если в строке есть значок, метка и ярлык или индикатор подменю, зарезервируйте фиксированный слот в начале для значка или флажка и переместите последующий контент в конец строки. Значки имеют фиксированный размер, поэтому фиксированный слот позволяет выравнивать ярлыки независимо от того, есть ли у элемента значок. Не используйте subgrid для строк: любой дополнительный встроенный дочерний элемент (например, значок) становится собственным элементом сетки и нарушает макет. Скрывайте текст ярлыка от технологий специальных возможностей, если он только повторяет клавишу, которая уже управляет элементом.

[focusgroup~="menu"] {
  --menu-icon-size: 1.25rem;
}

/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
  display: flex;
  align-items: center;
  gap: .5rem;
}

/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
  content: "";
  flex: none;
  inline-size: var(--menu-icon-size);
}

/* Check mark for checked items. This selector must be at least as specific as the base
   ::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
  content: "✓";
}

/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
  margin-inline-start: auto;
}

Добавьте индикатор или ярлык в элемент <span class="hint" aria-hidden="true"> внутри кнопки. Триггер подменю уже сообщает о всплывающем окне.

Прогрессивное улучшение и резервные варианты

Резервные варианты и поддержка focusgroup в браузерах

Оставьте в разметке focusgroup="menu nomemory". Если 'focusGroup' in HTMLElement.prototype имеет значение false, установите Up, Down, Home и End, задайте role="menu" для каждого контейнера меню и role="menuitem" для кнопок без роли. Не изменяйте элементы с возможностью выбора, <hr> и role="group". Не устанавливайте его, если есть нативная группа фокуса. В любом случае за действия "Влево", "Вправо", "Предварительный просмотр", "Открыть" и "Закрыть" отвечает скрипт автора.

if (!('focusGroup' in HTMLElement.prototype)) {
  document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
    group.setAttribute('role', 'menu');
    group.querySelectorAll('button:not([role])').forEach(item => {
      item.setAttribute('role', 'menuitem');
    });
    group.addEventListener('keydown', event => {
      if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
      // Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
      const items = [...group.querySelectorAll('button')]
        .filter(item => item.closest('[focusgroup]') === group)
        .filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
      const current = items.indexOf(document.activeElement);
      if (current < 0) return;
      const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
        (current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
      event.preventDefault();
      event.stopImmediatePropagation();
      items[next].focus({ preventScroll: true });
    }, true);
  });
}

Резервные варианты и поддержка Popover в браузерах

Popover API в основном представляет собой прогрессивное улучшение, но его определяющие характеристики – продвижение верхнего слоя, легкое отклонение и вызов popovertarget – не имеют эквивалента только для CSS. Для старых браузеров нужен полифил или резервный вариант, если вы не хотите его использовать.

Полифил. Чтобы обеспечить поддержку атрибута popover в старых браузерах, условно загрузите @oddbird/popover-polyfill. ОБЯЗАТЕЛЬНО. Проверьте наличие свойства popover в HTMLElement.prototype и загрузите полифил, только если встроенная поддержка отсутствует. Не загружайте его без необходимости.

С помощью сборщика или карты импорта:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Если вы не используете сборщик, импортируйте из CDN в <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Ограничение, связанное со стилями. Полифил не может определить настоящий псевдокласс :popover-open, поэтому вместо него применяется класс .\:popover-open. ОБЯЗАТЕЛЬНО. Объедините эти два правила с помощью :is() или :where(), иначе браузеры, не поддерживающие :popover-open, будут игнорировать правило целиком.

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

В качестве альтернативы для устаревшего резервного варианта без полифила используйте position: fixed и вручную рассчитайте координаты с помощью getBoundingClientRect() или используйте позиционирование по умолчанию с помощью inset: auto, если это допустимо для вашего случая.

Резервные варианты и поддержка позиционирования якоря в браузерах

Без позиционирования привязки CSS всплывающее окно возвращается к центрированному размещению области просмотра UA. Никогда не используйте это для меню: оно отсоединяет меню от его триггера, а центрированное подменю закрывает родительское. Скройте правила привязки за @supports (position-area: block-end) и задайте положение меню с помощью скрипта, если привязка не сработает:

  • Разместите меню под триггером (верхний уровень) или рядом с ним (подменю), перевернув его на другую сторону и закрепив в области просмотра, когда оно переполнится.
  • Запустите его в обработчике toggle при открытии меню и примените результат в качестве left и top в меню. Используйте position: fixed с inset: auto и margin: 0, чтобы координаты были относительными для области просмотра.
  • Оставьте эти переопределения внутри @supports not (position-area: block-end), чтобы они не влияли на браузеры с позиционированием привязки.

Если вы используете более ранние реализации, в которых showPopover({ source }) не устанавливает неявную привязку (нет source в ToggleEvent.prototype), присвойте каждому триггеру уникальный anchor-name, а каждому меню – соответствующий position-anchor из скрипта, вместо того чтобы писать правила CSS для каждого меню.

Для браузеров, в которых вообще нет позиционирования привязки, см. Раскрывающиеся списки с учетом области просмотра для маршрута полифила.

Резервные варианты и поддержка команд Invoker в браузерах

Без вызывающих элементов command="toggle-popover" ничего не делает, поэтому скрипт должен переключать меню.

  • Определяйте интерфейс, а не экземпляр: 'commandForElement' in HTMLButtonElement.prototype.
  • Для каждого триггера click вызовите preventDefault(), чтобы браузер с вызывающими элементами не переключался дважды.
  • Если меню открыто, закройте его, нажав closeAndRestore(menu). или позвоните по номеру menu.showPopover({ source: trigger }).
  • Пометить меню в enterOnOpen, если клик не был выполнен с помощью нажатого триггера (pointerTrigger), чтобы в меню можно было войти только с помощью клавиатуры.
  • Enter и Space по-прежнему работают, потому что они генерируют клик по кнопкам.