GrDropdownMenu

Package: @feugene/granularitycoreGroup: overlays

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 items prop;
  • there are many items and they are heterogeneous — groups, headings, separators and columns are already there;
  • some of the items are unavailable — a disabled item 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 GrDropdown as a whole.

When to take something else

NeedTake
The items are non-standard, you write the markupGrDropdown
There is a form or a filter insideGrPopover
There are two or three actions and they fit in a rowGrButtonGroup
Navigation across the sections of the applicationGrSidebar
A search across the commands of the whole applicationGrCommandPalette

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" (or menuitemcheckbox/menuitemradio);
  • a separator is role="separator";
  • a group is role="group" with a name from its heading through aria-labelledby;
  • the heading of a group is role="presentation";
  • the list, the columns and a column are role="none": a wrapper between menu and menuitem breaks aria-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.

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).

A menu from a model

Composition remains for non-standard items: GrDropdownMenuList is the wrapper of the list, GrDropdownMenuGroup and GrDropdownMenuHeader are a section with a heading, GrDropdownMenuItem and GrDropdownMenuDivider are an item and a separator, and GrDropdownMenuColumns with GrDropdownMenuColumn are the layout into columns. All of them arrive from the same subpath as the menu.

But nine menus out of ten are of the same kind — it is simpler to describe them with an array:

<GrDropdownMenu :items="items" @select="onSelect" />
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Rename', shortcut: '⌘R' },
  { type: 'divider' },
  { type: 'group', title: 'View', items: [
    { key: 'compact', label: 'Compact', role: 'menuitemradio', checked: true },
  ] },
  { key: 'delete', label: 'Delete', variant: 'danger' },
]

select is not emitted for a disabled item. An item with href renders as a link. The default slot is stronger than the model: if both have been passed, the slot wins.

The model can do what composition can. An item accepts as — a tag or a router component — and align; a group knows titleAlign, dividers and uppercase. This is not a convenience: without as a link item from the model stays an ordinary <a>, that is, in an SPA the transition goes through a page reload, and there is no way around it — the model can be unfolded into props only inside the component. It was enough for that field to fall behind GrDropdownMenuItem for the consumer to have to rewrite the whole walk over the model for the sake of a single link.

const items: GrDropdownMenuEntry[] = [
  { key: 'profile', label: 'Profile', href: '/profile', as: RouterLink },
]

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…

Code
<GrDropdownMenu />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
openboolean | undefinedundefinedThe controlled state of the panel (`v-model:open`) — passed into `GrDropdown`. Without the prop the menu runs itself (uncontrolled).
disabledboolean | undefinedfalseThe menu is opened by nothing; the trigger stays focusable.
placementPlacement | undefined"bottom-end"The placement of the panel relative to the trigger; the flip when there is not enough room still applies.
itemsGrDropdownMenuEntry[] | undefinedundefinedA 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.
triggerGrDropdownTrigger | undefined"click"What opens the panel. The click and the keyboard work in any mode.
teleportTostring | HTMLElement | undefinedundefinedA pointed override of the mounting point; by default — the shared portal.
contentClassstring | undefined""Extra classes for the content container.
listClassstring | undefined""Extra classes for the wrapper of the list.
dividersboolean | undefinedfalseSeparators between the items.
widthGrDropdownWidth | undefined"12rem"The width of the panel: a number is pixels, a string is a CSS length, `auto` is by the content.
offsetnumber | undefined8The gap between the trigger and the panel, in px.
openDelaynumber | undefined120The delay before opening on hover, in ms.
closeDelaynumber | undefined160The delay before closing after the cursor leaves, in ms.
closeOnContentClickboolean | undefinedtrueClose on a click inside the content.
borderTopboolean | undefinedfalseThe top border of the container of the list.
borderBottomboolean | undefinedfalseThe bottom border of the container of the list.

Slots

SlotTypeDescription
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

EventTypeDescription
update:open[value: boolean]
select[item: GrDropdownMenuAction]

Examples 5

Quick actions menu

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>

Grouped sections with danger zone

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>

Shortcut cheat-sheet grid

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>

Lines that separate blocks

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>

Menu from a model

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>

Accessibility

APG pattern
menu

Full keyboard contract of the package

Component documentationAll components