Примитивы
36

Menu

Список команд, привязанный к тому, что его открыло. Третье, что висит на place(), и отличие от двух других — в том, что читатель с ним делает: тултип читают, в поповере работают, а из меню запускают команду и оно исчезает, поэтому любая строка его закрывает. Это и не Select: селект сообщает выбранное значение и объявляет его выбранным, меню же запускает и не сообщает ничего — потому здесь ни одна строка не бывает aria-selected, а в списке нет галочки. Фокус — вторая половина того же отличия. Селект держит фокус на триггере и указывает оттуда aria-activedescendant; меню переносит фокус внутрь списка, а Tab его закрывает, а не гоняет по кругу: меню, из которого нельзя выйти вперёд, — это ловушка, а не меню. Передайте point, и оно откроется по координатам, а не рядом с триггером, — это и есть контекстное меню.

чистая разметка — библиотеки на странице нет
отрендерил aura-react
отрендерил aura-react
отрендерил aura-react
отрендерил aura-react

Код

index.html
<button type="button" class="aura-btn aura-btn--secondary"
        aria-haspopup="menu" aria-expanded="true" aria-controls="m1-menu">Actions</button>

<div data-state="open" class="aura-popup aura-menu">
  <ul id="m1-menu" role="menu" aria-label="Actions" aria-activedescendant="m1-item-0" tabindex="-1"
      class="aura-listbox aura-scroll">
    <li id="m1-item-0" role="menuitem" data-active="true" class="aura-listbox__option">
      <span class="aura-listbox__label">Open</span>
    </li>
    <li id="m1-item-1" role="menuitem" data-active="false" class="aura-listbox__option">
      <span class="aura-listbox__label">Rename<span class="aura-listbox__description">Give it another name</span></span>
    </li>
    <li role="separator" class="aura-listbox__separator"></li>
    <li id="m1-item-2" role="menuitem" data-active="false" class="aura-listbox__option aura-listbox__option--danger">
      <span class="aura-listbox__label">Delete</span>
    </li>
  </ul>
</div>
Demo.tsx
import { Button, Menu, type MenuItem } from '@ilomee/aura-react';

const items: MenuItem[] = [
  { value: 'open', label: 'Open' },
  { value: 'rename', label: 'Rename', description: 'Give it another name' },
  { value: 'duplicate', label: 'Duplicate' },
  { value: 'archive', label: 'Archive', disabled: true },
  { value: 'delete', label: 'Delete', danger: true, separatorBefore: true },
];

/**
 * Нажмите на триггер и походите стрелками. Фокус переходит внутрь списка, курсор виртуальный — один
 * элемент в фокусе, а `aria-activedescendant` указывает на строку, — и набор буквы перескакивает на
 * строку, которая с неё начинается. Enter запускает строку, Escape закрывает, ничего не запуская, а
 * Tab выходит из меню совсем, а не гоняет по кругу внутри. Отключённую строку стрелки перешагивают,
 * а не встают на неё, а опасная отделена от остальных чертой.
 */
export default function Demo() {
  return (
    <Menu items={items} label="Actions" onSelect={(value) => console.log(value)}>
      <Button variant="secondary">Actions</Button>
    </Menu>
  );
}
Demo.vue
<script setup lang="ts">
import { Menu, Button } from '@ilomee/aura-vue';

const items = [
  { value: 'open', label: 'Open' },
  { value: 'rename', label: 'Rename', description: 'Give it another name' },
  { value: 'delete', label: 'Delete', danger: true, separatorBefore: true },
];
</script>

<template>
  <Menu :items="items" label="Actions" @select="run">
    <Button variant="secondary">Actions</Button>
  </Menu>
</template>
Demo.svelte
<script lang="ts">
  import { Menu } from '@ilomee/aura-svelte';

  const items = [
    { value: 'open', label: 'Open' },
    { value: 'delete', label: 'Delete', danger: true, separatorBefore: true },
  ];
</script>

<Menu {items} label="Actions" onselect={run}>
  {#snippet trigger(props)}
    <button type="button" class="aura-btn aura-btn--secondary" {...props}>Actions</button>
  {/snippet}
</Menu>

Используется как [auraMenu] — директива, которую вы вешаете на свой элемент.

demo.component.ts
<button auraButton variant="secondary" [auraMenu]="items" label="Actions" (select)="run($event)">
  Actions
</button>

Панель строится через Renderer2, а не шаблоном: содержимое — это данные, а не разметка автора, и компонент, вставленный в body, поставил бы собственный host-элемент туда, где остальные три рисуют <div class="aura-popup">. Директива снимает собственный атрибут auraMenu в ngOnInit, как и auraPopover. Шаблона строки нет: строка — это подпись и пояснение, а шаблонный вход ради двух строк никому не нужен.

Пропсы

У чистой разметки нет пропсов — классы ниже и есть весь API. Четыре пакета не делают ничего сверх того, что проставляют вам те же самые классы.
ИмяТипПо умолчаниюПримечания
itemsMenuItem[] | nullКоманды. value опознаёт команду в обработчике, label — её текст; description, disabled, danger, group и separatorBefore — остальное. null рисует пустой список, а не бросает исключение.
childrenReactElementОткрывающий контрол. Клонируется, а не оборачивается. Необязателен: с point триггера нет вовсе.
labelstringДоступное имя меню. role="menu" без имени объявляется как группа без подписи.
openдвустороннийboolean
defaultOpenbooleanfalse
onOpenChangeсобытие(open: boolean) => void
onSelectсобытие(value: string) => voidЗапущенная строка. Меню закрывается само — этим оно и является меню.
point{ x: number; y: number } | nullОткрыть по этим координатам вьюпорта, а не рядом с триггером, — clientX/clientY, не смещения страницы.
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | stringШирина панели — пиксели или любая CSS-длина. Без неё панель по ширине самой длинной строки, но не уже 200px и не шире 320px. Это никогда не ширина триггера: меню часто открывают из чего-то более узкого, чем оно само, а по координатам триггера нет вовсе.
renderItem(item, state) => ReactNodeЗаменяет содержимое строки, но не её <li>.
ИмяТипПо умолчаниюПримечания
itemsMenuItem[] | null
defaultслотslotТриггер. Ровно один элемент, клонируется. С point необязателен.
itemслотslotЗаменяет содержимое строки. Получает { item, index, active, disabled }.
labelstring
openдвустороннийboolean
update:openсобытие(open: boolean) => void
selectсобытие(value: string) => void
point{ x: number; y: number } | null
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | string
ИмяТипПо умолчаниюПримечания
itemsAuraMenuItem[] | null
triggerSnippet<[TriggerProps]>Получает пропсы для раскрытия на контроле. С point необязателен.
itemSnippet<[item, state]>Заменяет содержимое строки.
labelstring
openдвустороннийboolean
onselectсобытие(value: string) => void
point{ x: number; y: number } | null
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | string
ИмяТипПо умолчаниюПримечания
auraMenuAuraMenuItem[] | nullКоманды; названы по селектору, чтобы привязка читалась прямо на триггере.
labelstring
openдвустороннийboolean
openChangeсобытие(open: boolean) => void
selectсобытие(value: string) => void
point{ x: number; y: number } | null
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | string

Какие классы отдаёт

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

КлассРольПримечания
.aura-popupбазаОбязательный. Без него модификатор не работает.
.aura-menuбазаОбязательный. Без него модификатор не работает.
.aura-listboxбазаОбязательный. Без него модификатор не работает.
.aura-listbox__optionэлемент
.aura-listbox__separatorэлемент

Токены и правила

components.css
слой
var(--z-dropdown) — under a modal, on purpose
ширина
its rows, floored at 200px and capped at 320px — or var(--menu-w)
высота
min(320px, 60vh), then it scrolls — .aura-listbox’s own
опасная строка
var(--danger), inverted under the cursor