GrDropdownMenu
Берут, когда действия над объектом.
Когда брать
- действия над объектом — «⋯» у строки таблицы, у карточки, у файла: типовое меню собирается пропом
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
open | boolean | undefined | undefined | Контролируемое состояние панели (`v-model:open`) — прокидывается в `GrDropdown`. Без пропа меню ведёт себя само (uncontrolled). |
disabled | boolean | undefined | false | Меню не открывается ничем; триггер остаётся фокусируемым. |
placement | Placement | undefined | "bottom-end" | Размещение панели относительно триггера; переворот при нехватке места остаётся. |
items | GrDropdownMenuEntry[] | undefined | undefined | Декларативное меню: пункты, группы и разделители массивом. Слот по умолчанию сильнее — он для меню, которое из модели не собирается. |
trigger | GrDropdownTrigger | undefined | "click" | Чем открывается панель. В любом режиме работают клик и клавиатура. |
teleportTo | string | HTMLElement | undefined | undefined | Точечное переопределение точки монтирования; по умолчанию — общий портал. |
contentClass | string | undefined | "" | Дополнительные классы content-контейнера. |
listClass | string | undefined | "" | Дополнительные классы для wrapper'а списка. |
dividers | boolean | undefined | false | Разделители между пунктами. |
width | GrDropdownWidth | undefined | "12rem" | Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту. |
offset | number | undefined | 8 | Зазор между триггером и панелью, px. |
openDelay | number | undefined | 120 | Задержка открытия по наведению, мс. |
closeDelay | number | undefined | 160 | Задержка закрытия после ухода курсора, мс. |
closeOnContentClick | boolean | undefined | true | Закрывать по клику внутри content. |
borderTop | boolean | undefined | false | Верхний бордер контейнера списка. |
borderBottom | boolean | undefined | false | Нижний бордер контейнера списка. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | { close: () => void; } | Пункты меню. Слот-пропы прокидываются от `GrDropdown` как есть. |
trigger | { open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; } | Триггер панели. `triggerProps` обязаны попасть на сам интерактивный элемент, а не на обёртку вокруг него: `aria-expanded` и `aria-controls` читаются с того узла, который получает фокус. |
Events
| Event | Type | Описание |
|---|---|---|
update:open | [value: boolean] | — |
select | [item: GrDropdownMenuAction] | — |
Примеры 5
Меню быстрых действий
Строим компактное action-menu поверх GrDropdownMenu, сохраняя привычный trigger/content contract от GrDropdown.
<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.
<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>.
<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 отбивают список от шапки и подвала. Правило рисуется псевдоэлементом с инсетом, а не рамкой бокса: у края панели линия во всю ширину упирается в дугу скругления, и вместо двух линий глаз видит клин.
<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 дают состояние прямо в меню — композиция подкомпонентов остаётся для нестандартных случаев.
<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