GrDropdownMenu

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда действия над объектом.

Когда брать

  • действия над объектом — «⋯» у строки таблицы, у карточки, у файла: типовое меню собирается пропом items;
  • пунктов много и они разнородны — группы, заголовки, разделители и колонки уже есть;
  • часть пунктов недоступнаdisabled-пункт остаётся в обходе с клавиатуры и объявляется, а не исчезает;
  • нужна разметка без сборки вручную — слой, роли и клавиатура приезжают из GrDropdown целиком.

Когда взять другое

НужноБерите
Пункты нестандартные, разметку пишете самиGrDropdown
Внутри форма или фильтрGrPopover
Действий два-три и они помещаются в рядGrButtonGroup
Навигация по разделам приложенияGrSidebar
Поиск по командам всего приложенияGrCommandPalette

Роли: почему их нельзя пропустить

Панель GrDropdown объявляет role="menu", а эта роль делает всех своих потомков презентационными. Поэтому роли здесь не украшение, а условие того, что меню вообще существует для скринридера:

  • пункт — role="menuitem" (или menuitemcheckbox/menuitemradio);
  • разделитель — role="separator";
  • группа — role="group" с именем из заголовка через aria-labelledby;
  • заголовок группы — role="presentation";
  • список, колонки и колонка — role="none": обёртка между menu и menuitem ломает aria-required-children.

Фокус и выключенные пункты

Пункты не табируемы (tabindex="-1"): в паттерне menu табируемым остаётся триггер, а внутри панели фокус водят стрелки — этим распоряжается GrDropdown. Иначе Tab ходил бы по пунктам, и меню было бы просто списком кнопок.

disabled гасится aria-disabled, а не нативным disabled: пункт остаётся фокусируемым и попадает в обход стрелками, поэтому пользователь узнаёт, что действие существует, но сейчас недоступно (рекомендация WAI-ARIA APG). Клик и Enter при этом перехватываются, а фон и текст берутся из disabled-токенов, а не из opacity.

Пункты-ссылки

href сам делает пункт ссылкой — as="a" для этого не нужен. target и rel задаются явно, external — шорткат для target="_blank" с rel="noopener noreferrer".

У выключенной ссылки href снимается: перехват клика не спасает от средней кнопки мыши и «открыть в новой вкладке» из контекстного меню браузера. Тот же приём в GrButton.

В декларативной модели это поля href, target, rel, external у пункта.

Пункты-переключатели

role="menuitemcheckbox" и role="menuitemradio" требуют aria-checked — компонент выставляет его в обоих состояниях, иначе AT прочитает пункт как обычную команду. Место под отметку занято всегда, когда пункт переключаемый: иначе строки «включено» и «выключено» разъезжаются по горизонтали.

<GrDropdownMenuItem role="menuitemcheckbox" :checked="showArchived" @click="toggle">
  Показывать архив
</GrDropdownMenuItem>

Иконка и сочетание клавиш задаются пропами icon / shortcut либо слотами #icon / #shortcut (слот сильнее).

Меню из модели

Композиция остаётся для нестандартных пунктов: GrDropdownMenuList — обёртка списка, GrDropdownMenuGroup и GrDropdownMenuHeader — раздел с заголовком, GrDropdownMenuItem и GrDropdownMenuDivider — пункт и разделитель, GrDropdownMenuColumns с GrDropdownMenuColumn — раскладка в колонки. Все они приезжают из того же subpath, что и меню.

Но девять меню из десяти однотипны — их проще описать массивом:

<GrDropdownMenu :items="items" @select="onSelect" />
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Переименовать', shortcut: '⌘R' },
  { type: 'divider' },
  { type: 'group', title: 'Вид', items: [
    { key: 'compact', label: 'Компактно', role: 'menuitemradio', checked: true },
  ] },
  { key: 'delete', label: 'Удалить', variant: 'danger' },
]

select не эмитится для disabled-пункта. Пункт с href рендерится ссылкой. Слот по умолчанию сильнее модели: передали и то и другое — победит слот.

Модель умеет то же, что композиция. Пункт принимает as — тег или компонент роутера, — и align; группа знает titleAlign, dividers и uppercase. Это не удобство: без as пункт-ссылка из модели остаётся обычным <a>, то есть в SPA переход идёт перезагрузкой страницы, а обойти это нечем — разложить модель в пропы можно только внутри компонента. Стоило этому полю отстать от GrDropdownMenuItem, и потребителю приходилось переписывать обход модели целиком ради одной ссылки.

const items: GrDropdownMenuEntry[] = [
  { key: 'profile', label: 'Профиль', href: '/profile', as: RouterLink },
]

Оформление

variant="danger" красит пункт ролью --gr-danger-text, а не насыщенным тоном: насыщенный тон как цвет текста не проходит контраст. Disabled гасится фоном (--gr-muted), а не opacity, и не пропускает ни клик, ни клавиатурную активацию — обработчик на самом пункте останавливается stopImmediatePropagation.

Пункт скруглён и вписан в поле панели. Фон подсветки лежит на самом пункте, а панель GrDropdown скруглена и содержимое не обрезает: прямоугольник во всю ширину заливал бы угловые сегменты, вырезанные её радиусом. Поэтому у пункта свой радиус ступенью мельче панельного — тот же приём, что у опций GrSelect и GrAutocomplete.

Гасить поле панели своим p-0 для этого нельзя: оба класса попадают в один атрибут с равной специфичностью, и победителя выбирает порядок правил в сгенерированном CSS, а не разметка. Нужна панель без поля — это отдельный канал, а не перекрытие классом.

Линии borderTop / borderBottom не доходят до краёв — и это не оплошность. Список лежит в поле панели: 1 px рамки плюс 4 px p-1. Угол панели скруглён на 16 px, то есть на глубине 5 px дуга ещё идёт — правило во всю ширину упиралось бы не в вертикальный край, а в неё, и у каждого угла было бы видно клин, в который сходятся линия и рамка. Поэтому линия рисуется псевдоэлементом с инсетом в 8 px, тем же, что у GrDropdownMenuDivider :inset. Пункты при этом остаются во всю ширину: их ширина — часть того же попадания подсветки в поле панели.

Правило общее для скруглённых поверхностей: ничто во всю ширину не подходит к краю панели ближе, чем её радиус. Геометрию держит e2e-проверка (apps/showcase/e2e/geometry.spec.ts) — в jsdom классов UnoCSS не существует, и куда попал конец линии, там не увидеть.

Разделителей между пунктами это не касается: dividers и GrDropdownMenuDivider лежат далеко от углов и читаются нормально во всю ширину.

Все классы каталога живут в grDropdownMenuStyles.ts и объявлены в safelist: .ts-хелпер бандлер выносит в общий чанк, вне области скана компонента, и без safelist у изолированного потребителя пропали бы выравнивание, колонки и цвета.

Управление извне: `v-model:open`

Проп open и событие update:open прокидываются в обёрнутый GrDropdown как есть — контракт тот же, что у GrDropdown.

Playground 11

Загружается…

Код
<GrDropdownMenu />

Установка

npm i @feugene/granularity

Импорт

import { GrDropdownMenu } from '@feugene/granularity/components/GrDropdownMenu'

API

Props

PropTypeпо умолчаниюОписание
openboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`) — прокидывается в `GrDropdown`. Без пропа меню ведёт себя само (uncontrolled).
disabledboolean | undefinedfalseМеню не открывается ничем; триггер остаётся фокусируемым.
placementPlacement | undefined"bottom-end"Размещение панели относительно триггера; переворот при нехватке места остаётся.
itemsGrDropdownMenuEntry[] | undefinedundefinedДекларативное меню: пункты, группы и разделители массивом. Слот по умолчанию сильнее — он для меню, которое из модели не собирается.
triggerGrDropdownTrigger | undefined"click"Чем открывается панель. В любом режиме работают клик и клавиатура.
teleportTostring | HTMLElement | undefinedundefinedТочечное переопределение точки монтирования; по умолчанию — общий портал.
contentClassstring | undefined""Дополнительные классы content-контейнера.
listClassstring | undefined""Дополнительные классы для wrapper'а списка.
dividersboolean | undefinedfalseРазделители между пунктами.
widthGrDropdownWidth | undefined"12rem"Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту.
offsetnumber | undefined8Зазор между триггером и панелью, px.
openDelaynumber | undefined120Задержка открытия по наведению, мс.
closeDelaynumber | undefined160Задержка закрытия после ухода курсора, мс.
closeOnContentClickboolean | undefinedtrueЗакрывать по клику внутри content.
borderTopboolean | undefinedfalseВерхний бордер контейнера списка.
borderBottomboolean | undefinedfalseНижний бордер контейнера списка.

Slots

SlotTypeОписание
default{ close: () => void; }Пункты меню. Слот-пропы прокидываются от `GrDropdown` как есть.
trigger{ open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; }Триггер панели. `triggerProps` обязаны попасть на сам интерактивный элемент, а не на обёртку вокруг него: `aria-expanded` и `aria-controls` читаются с того узла, который получает фокус.

Events

EventTypeОписание
update:open[value: boolean]
select[item: GrDropdownMenuAction]

Примеры 5

Меню быстрых действий

Строим компактное action-menu поверх GrDropdownMenu, сохраняя привычный trigger/content contract от GrDropdown.

Last action: Not selected yet

Quick Actions
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrButton, GrDropdownMenu, GrDropdownMenuItem } from '@feugene/granularity'

const lastAction = ref('Not selected yet')

const actions = [
  'Duplicate page',
  'Move to archive',
  'Copy public URL',
]
</script>

<template>
  <div class="flex flex-wrap items-start gap-3">
    <GrDropdownMenu placement="bottom-start" width="15rem">
      <template #trigger="{ open, triggerProps }">
        <GrButton v-bind="triggerProps" variant="outline">
          {{ open ? 'Close quick actions' : 'Open quick actions' }}
        </GrButton>
      </template>

      <GrDropdownMenuItem
        v-for="action in actions"
        :key="action"
        @click="lastAction = action"
      >
        {{ action }}
      </GrDropdownMenuItem>
    </GrDropdownMenu>

    <GrBadge tone="neutral">
      Last action: {{ lastAction }}
    </GrBadge>
  </div>
</template>

Разделы с группами и опасной зоной

Для richer menus используем GrDropdownMenuGroup и GrDropdownMenuDivider, чтобы отделять publish-flow и destructive actions.

Selected action
Publish now
grouped menu

Grouped Actions
<script setup lang="ts">
import { ref } from 'vue'

import {
  GrBadge,
  GrButton,
  GrDropdownMenu,
  GrDropdownMenuDivider,
  GrDropdownMenuGroup,
  GrDropdownMenuItem,
} from '@feugene/granularity'

const selectedAction = ref('Publish now')
</script>

<template>
  <div class="grid gap-3 sm:grid-cols-[auto_1fr] sm:items-start">
    <GrDropdownMenu width="16rem">
      <template #trigger="{ open, triggerProps }">
        <GrButton v-bind="triggerProps">
          {{ open ? 'Hide workspace actions' : 'Workspace actions' }}
        </GrButton>
      </template>

      <GrDropdownMenuGroup title="Publish" :uppercase="false" dividers>
        <GrDropdownMenuItem @click="selectedAction = 'Publish now'">
          Publish now
        </GrDropdownMenuItem>
        <GrDropdownMenuItem @click="selectedAction = 'Schedule for review'">
          Schedule for review
        </GrDropdownMenuItem>
      </GrDropdownMenuGroup>

      <GrDropdownMenuDivider />

      <GrDropdownMenuGroup title="Danger zone" :uppercase="false" dividers>
        <GrDropdownMenuItem variant="danger" @click="selectedAction = 'Delete draft'">
          Delete draft
        </GrDropdownMenuItem>
      </GrDropdownMenuGroup>
    </GrDropdownMenu>

    <div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Selected action
      </div>
      <div class="mt-2 flex items-center gap-3">
        <div class="text-sm font-600 text-[var(--gr-fg)]">
          {{ selectedAction }}
        </div>
        <GrBadge size="sm" tone="primary">
          grouped menu
        </GrBadge>
      </div>
    </div>
  </div>
</template>

Шпаргалка сочетаний клавиш

Минималистичный cheat-sheet хоткеев: GrDropdownMenuHeader + одноколоночный GrDropdownMenuList, где в каждой строке действие слева и хоткей-чипы справа (justify-between). Клавиши рендерим компонентом GrKbd — без ручной вёрстки <kbd>.

Shortcut Grid
<script setup lang="ts">
import {
  GrButton,
  GrDropdownMenu,
  GrDropdownMenuHeader,
  GrDropdownMenuList,
  GrKbd,
} from '@feugene/granularity'

// Каждый хоткей — массив клавиш: рендерим их как отдельные `GrKbd`-чипы,
// так «⌘ K» читается чище, чем слипшееся «⌘K».
const shortcuts = [
  { action: 'Search', keys: ['', 'K'] },
  { action: 'Save draft', keys: ['', 'S'] },
  { action: 'Assign owner', keys: ['A'] },
  { action: 'Archive', keys: ['', ''] },
]
</script>

<template>
  <!--
    Минималистичный cheat-sheet: одна колонка, в каждой строке действие слева и
    хоткей справа (`justify-between`). Клавиши — компонент `GrKbd` (дефолтный размер).
  -->
  <GrDropdownMenu width="16rem" placement="bottom-start" :close-on-content-click="false">
    <template #trigger="{ open, triggerProps }">
      <GrButton v-bind="triggerProps" variant="outline">
        {{ open ? 'Hide shortcuts' : 'Keyboard shortcuts' }}
      </GrButton>
    </template>

    <GrDropdownMenuHeader title="Keyboard shortcuts" />

    <GrDropdownMenuList>
      <div
        v-for="shortcut in shortcuts"
        :key="shortcut.action"
        class="flex items-center justify-between gap-6 px-4 py-2 text-[13px] text-[var(--gr-fg)]"
      >
        <span class="truncate">{{ shortcut.action }}</span>
        <span class="flex shrink-0 items-center gap-1">
          <GrKbd
            v-for="(key, index) in shortcut.keys"
            :key="index"
          >
            {{ key }}
          </GrKbd>
        </span>
      </div>
    </GrDropdownMenuList>
  </GrDropdownMenu>
</template>

Линии, разделяющие блоки

borderTop / borderBottom отбивают список от шапки и подвала. Правило рисуется псевдоэлементом с инсетом, а не рамкой бокса: у края панели линия во всю ширину упирается в дугу скругления, и вместо двух линий глаз видит клин.

Edge Lines
<script setup lang="ts">
import {
  GrButton,
  GrDropdownMenu,
  GrDropdownMenuHeader,
  GrDropdownMenuItem,
  GrDropdownMenuList,
} from '@feugene/granularity'

const actions = ['Duplicate', 'Move to archive', 'Copy public URL']
</script>

<template>
  <div class="flex flex-wrap items-start gap-4">
    <!--
      Линии у самого края панели: список — единственный блок, и правило
      приходится на полосу скругления. Рисуется оно псевдоэлементом с инсетом,
      поэтому концы не задевают дугу угла.
    -->
    <GrDropdownMenu
      width="14rem"
      placement="bottom-start"
      border-top
      border-bottom
      data-testid="menu-edge-lines"
    >
      <template #trigger="{ open, triggerProps }">
        <GrButton v-bind="triggerProps" variant="outline">
          {{ open ? 'Скрыть' : 'Линии у края' }}
        </GrButton>
      </template>

      <GrDropdownMenuItem v-for="action in actions" :key="action">
        {{ action }}
      </GrDropdownMenuItem>
    </GrDropdownMenu>

    <!-- Тот же проп по прямому назначению: отбить список от шапки и подвала. -->
    <GrDropdownMenu width="14rem" placement="bottom-start" border-top :close-on-content-click="false">
      <template #trigger="{ open, triggerProps }">
        <GrButton v-bind="triggerProps" variant="outline">
          {{ open ? 'Скрыть' : 'Отбивка от шапки' }}
        </GrButton>
      </template>

      <GrDropdownMenuHeader title="Документ" />

      <GrDropdownMenuList border-top>
        <GrDropdownMenuItem v-for="action in actions" :key="action">
          {{ action }}
        </GrDropdownMenuItem>
      </GrDropdownMenuList>
    </GrDropdownMenu>
  </div>
</template>

Меню из модели

Пункты, группы и разделители задаются массивом items, а menuitemcheckbox/menuitemradio дают состояние прямо в меню — композиция подкомпонентов остаётся для нестандартных случаев.

Density: cozy · archived: hidden · last action:

Declarative
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrDropdownMenuAction, GrDropdownMenuEntry } from '@feugene/granularity'
import { GrButton, GrDropdownMenu } from '@feugene/granularity'

const density = ref<'compact' | 'cozy'>('cozy')
const showArchived = ref(false)
const lastAction = ref('')

// Модель вместо композиции: девять меню из десяти однотипны, и собирать их
// из пяти компонентов вручную незачем.
const items = computed<GrDropdownMenuEntry[]>(() => [
  { key: 'rename', label: 'Rename', shortcut: '⌘R' },
  { key: 'duplicate', label: 'Duplicate', shortcut: '⌘D' },
  { type: 'divider' },
  {
    type: 'group',
    title: 'View',
    items: [
      { key: 'compact', label: 'Compact rows', role: 'menuitemradio', checked: density.value === 'compact' },
      { key: 'cozy', label: 'Cozy rows', role: 'menuitemradio', checked: density.value === 'cozy' },
      { key: 'archived', label: 'Show archived', role: 'menuitemcheckbox', checked: showArchived.value },
    ],
  },
  { type: 'divider' },
  // Выключенный пункт остаётся в обходе стрелками и объявляется как недоступный:
  // пользователь узнаёт, что действие есть, но сейчас не работает.
  { key: 'export', label: 'Export…', disabled: true },
  { key: 'docs', label: 'Open docs', href: 'https://github.com/fureev', external: true },
  { key: 'delete', label: 'Delete', variant: 'danger', shortcut: '' },
])

function onSelect(item: GrDropdownMenuAction): void {
  if (item.key === 'compact' || item.key === 'cozy')
    density.value = item.key

  if (item.key === 'archived')
    showArchived.value = !showArchived.value

  lastAction.value = item.label
}
</script>

<template>
  <div class="grid gap-3">
    <GrDropdownMenu :items="items" placement="bottom-start" width="15rem" @select="onSelect">
      <template #trigger="{ open, triggerProps }">
        <GrButton v-bind="triggerProps" variant="outline">
          {{ open ? 'Close board actions' : 'Board actions' }}
        </GrButton>
      </template>
    </GrDropdownMenu>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Density: <span class="font-semibold text-[var(--gr-fg)]">{{ density }}</span> ·
      archived: <span class="font-semibold text-[var(--gr-fg)]">{{ showArchived ? 'shown' : 'hidden' }}</span> ·
      last action: <span class="font-semibold text-[var(--gr-fg)]">{{ lastAction }}</span>
    </div>
  </div>
</template>

Доступность

Паттерн APG
menu
Клавиши
то же, что у GrDropdown: пункты не табируемы (tabindex="-1"), фокус водят стрелки. Выключенные пункты из обхода **не** выпадают — aria-disabled вместо нативного disabled

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты