GrDropdownMenu
A ready-made actions menu for a button, link or context invocation.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- actions over an object — the ”⋯” of a table row, of a card, of a file: the typical menu is assembled with the
itemsprop; - there are many items and they are heterogeneous — groups, headings, separators and columns are already there;
- some of the items are unavailable — a
disableditem stays in the keyboard walk and is announced rather than disappearing; - markup is needed without assembling it by hand — the layer, the roles and the keyboard arrive from
GrDropdownas a whole.
When to take something else
| Need | Take |
|---|---|
| The items are non-standard, you write the markup | GrDropdown |
| There is a form or a filter inside | GrPopover |
| There are two or three actions and they fit in a row | GrButtonGroup |
| Navigation across the sections of the application | GrSidebar |
| A search across the commands of the whole application | GrCommandPalette |
The roles: why they cannot be skipped
The panel of GrDropdown declares role="menu", and that role makes all of its
descendants presentational. The roles here are therefore not an ornament but the
condition on which the menu exists for a screen reader at all:
- an item is
role="menuitem"(ormenuitemcheckbox/menuitemradio); - a separator is
role="separator"; - a group is
role="group"with a name from its heading througharia-labelledby; - the heading of a group is
role="presentation"; - the list, the columns and a column are
role="none": a wrapper betweenmenuandmenuitembreaksaria-required-children.
The focus and the disabled items
The items are not tabbable (tabindex="-1"): in the menu pattern it is the trigger that
stays tabbable, and inside the panel the focus is walked by the arrows — that is disposed
of by GrDropdown. Otherwise Tab would walk the items and the menu would be a mere
list of buttons.
disabled is dimmed with aria-disabled rather than with the native disabled: the item
stays focusable and enters the walk with the arrows, so the user learns that the action
exists but is currently unavailable (a recommendation of the WAI-ARIA APG). The click and
Enter are intercepted in the process, and the background and the text come from the
disabled tokens rather than from opacity.
Link items
href makes an item a link by itself — as="a" is not needed for that. target and rel
are set explicitly, and external is a shortcut for target="_blank" with
rel="noopener noreferrer".
On a disabled link the href is removed: intercepting the click does not save you
from the middle mouse button and from “open in a new tab” in the browser’s context menu.
The same device is used in GrButton.
In the declarative model those are the href, target, rel and external fields of an
item.
Toggle items
role="menuitemcheckbox" and role="menuitemradio" require aria-checked — the component
sets it in both states, otherwise AT reads the item as an ordinary command. The room for
the mark is always taken when the item is toggleable: otherwise the “on” and “off” rows
shift horizontally against each other.
<GrDropdownMenuItem role="menuitemcheckbox" :checked="showArchived" @click="toggle">
Show the archive
</GrDropdownMenuItem>
The icon and the keyboard shortcut are set with the icon / shortcut props or with the
#icon / #shortcut slots (a slot is stronger).
Styling
variant="danger" colours an item with the --gr-danger-text role rather than with a
saturated tone: a saturated tone as a text colour does not pass contrast. Disabled is
dimmed with a background (--gr-muted) rather than with opacity, and lets through
neither a click nor a keyboard activation — a handler on the item itself is stopped with
stopImmediatePropagation.
An item is rounded and fitted into the padding of the panel. The background of the
highlight lies on the item itself, and the panel of GrDropdown is rounded and does not
clip its content: a rectangle across the full width would fill the corner segments cut
out by its radius. The item therefore has a radius of its own, one step smaller than that
of the panel — the same device as in the options of GrSelect and GrAutocomplete.
Suppressing the padding of the panel with your own p-0 is not allowed for that: both
classes land in the same attribute with equal specificity, and the winner is chosen by the
order of the rules in the generated CSS rather than by the markup. If a panel without
padding is needed, that is a separate channel, not an override with a class.
The borderTop / borderBottom lines do not reach the edges — and that is not an
oversight. The list lies inside the padding of the panel: 1 px of border plus 4 px of
p-1. The corner of the panel is rounded by 16 px, that is, at a depth of 5 px the arc is
still running — a rule across the full width would run not into the vertical edge but into
it, and at every corner a wedge where the line and the border converge would be visible.
The line is therefore drawn with a pseudo-element inset by 8 px, the same as in
GrDropdownMenuDivider :inset. The items stay full width in the process: their width is
part of the same fitting of the highlight into the padding of the panel.
The rule is general for rounded surfaces: nothing full width comes closer to the edge of
a panel than its radius. The geometry is held by an e2e check
(apps/showcase/e2e/geometry.spec.ts) — in jsdom the UnoCSS classes do not exist, and
there is no seeing where the end of a line landed.
None of this concerns the separators between items: dividers and
GrDropdownMenuDivider lie far from the corners and read fine across the full width.
All of the classes of the catalogue live in grDropdownMenuStyles.ts and are declared in
the safelist: a .ts helper is moved by the bundler into a shared chunk, outside the scan
area of the component, and without the safelist an isolated consumer would lose the
alignment, the columns and the colours.
Control from the outside: `v-model:open`
The open prop and the update:open event are passed into the wrapped GrDropdown as
they are — the contract is the same as in GrDropdown.
Playground 11
Loading…
<GrDropdownMenu />Install
npm i @feugene/granularityImport
import { GrDropdownMenu } from '@feugene/granularity/components/GrDropdownMenu'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
open | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`) — passed into `GrDropdown`. Without the prop the menu runs itself (uncontrolled). |
disabled | boolean | undefined | false | The menu is opened by nothing; the trigger stays focusable. |
placement | Placement | undefined | "bottom-end" | The placement of the panel relative to the trigger; the flip when there is not enough room still applies. |
items | GrDropdownMenuEntry[] | undefined | undefined | A declarative menu: items, groups and separators as an array. The default slot is stronger — it is for a menu that is not assembled from a model. |
trigger | GrDropdownTrigger | undefined | "click" | What opens the panel. The click and the keyboard work in any mode. |
teleportTo | string | HTMLElement | undefined | undefined | A pointed override of the mounting point; by default — the shared portal. |
contentClass | string | undefined | "" | Extra classes for the content container. |
listClass | string | undefined | "" | Extra classes for the wrapper of the list. |
dividers | boolean | undefined | false | Separators between the items. |
width | GrDropdownWidth | undefined | "12rem" | The width of the panel: a number is pixels, a string is a CSS length, `auto` is by the content. |
offset | number | undefined | 8 | The gap between the trigger and the panel, in px. |
openDelay | number | undefined | 120 | The delay before opening on hover, in ms. |
closeDelay | number | undefined | 160 | The delay before closing after the cursor leaves, in ms. |
closeOnContentClick | boolean | undefined | true | Close on a click inside the content. |
borderTop | boolean | undefined | false | The top border of the container of the list. |
borderBottom | boolean | undefined | false | The bottom border of the container of the list. |
Slots
| Slot | Type | Description |
|---|---|---|
default | { close: () => void; } | The items of the menu. The slot props are passed from `GrDropdown` as they are. |
trigger | { open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; } | The trigger of the panel. `triggerProps` are obliged to land on the interactive element itself rather than on a wrapper around it: `aria-expanded` and `aria-controls` are read from the node that gets the focus. |
Events
| Event | Type | Description |
|---|---|---|
update:open | [value: boolean] | — |
select | [item: GrDropdownMenuAction] | — |
Examples 5
Quick actions menu
<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>Grouped sections with danger zone
<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>Shortcut cheat-sheet 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>Lines that separate blocks
<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>Menu from a model
<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>Accessibility
- APG pattern
menu