GrDropdown
Opens a floating list of actions or options next to the trigger.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a menu with items of your own — you write the markup of the items, and the component takes the layer, the positioning and the keyboard;
- the trigger is non-standard — an avatar, a table cell, a button with a badge: the
#triggerslot accepts anything; - the panel opens on hover —
openDelayandcloseDelayseparate an intent from an accidental movement of the mouse; - the opening is governed by the parent —
v-model:opentogether withtrigger="manual".
When to take something else
| Need | Take |
|---|---|
| The items are ordinary: text, an icon, groups, separators | GrDropdownMenu |
| There is a form, a filter or a palette inside — not a menu | GrPopover |
| A text hint without interaction | GrTooltip |
| Selecting the value of a form field | GrSelect |
| A search across the commands of the whole application | GrCommandPalette |
The component declares role="menu" rigidly and walks the focus across the items. For a
form or a confirmation that is the wrong semantics — a screen reader would announce a
menu where there is none; that case is covered by GrPopover with a configurable role.
The trigger: `triggerProps` is mandatory
The component does not know what exactly the consumer will put into the #trigger slot,
so it gives the click, the keyboard and the ARIA away as an object that has to be bound
to a real focusable element:
<GrDropdown>
<template #trigger="{ triggerProps }">
<GrButton variant="outline" v-bind="triggerProps">Actions</GrButton>
</template>
<template #content>
<button type="button" role="menuitem">Duplicate</button>
</template>
</GrDropdown>
The wrapper of the slot deliberately does not catch clicks: otherwise the panel would be
toggled by any click inside it — nested buttons and links included. A forgotten v-bind
prints a warning in a dev build: a silently non-working trigger would be looked for in
someone else’s code.
The slot also gives away open, toggle and close — for markup of your own for the
state.
The placement and the width
placement— any floating-ui value (bottom-endby default). Flipping when there is not enough room (flip) always works:placementis a preference rather than an order, and thetransform-originof the panel follows the actual side;offset— the gap between the trigger and the panel, in px;width— a CSS length: a number is treated as pixels, a string goes as it is, andautogives the width to the content. The Tailwind scales (width="48"→w-48) are gone; a string without units is treated as pixels and complains in a dev build.
The opening modes
trigger="click" (the default) or "hover". In the hover mode the delays are set by
openDelay/closeDelay: without the first the panel jumps out on any crossing of the
cursor, without the second it cannot be kept while moving from the trigger to the panel —
there is an offset gap between them.
The click and the keyboard work in both modes: a menu available only with the mouse is not available from the keyboard at all.
disabled closes the panel and does not let it be opened by a click, by the keyboard or
by hovering. The trigger stays focusable in the process and gets aria-disabled — the
native disabled would throw it out of the tab order together with the explanation of
what is going on.
The imperative API
open(), close() and toggle() through a ref on the component — the same set as in
the other overlays. Unlike the modal ones, a menu keeps its state itself: it has no
model, and the methods change it directly. The imperative path does not bypass
disabled — otherwise it would open what is closed to the click and to the keyboard.
The same three methods arrive in the #trigger slot (:open, :toggle, :close) — a
ref is needed when the menu has to be opened from outside the markup of the trigger.
The keyboard
Enter/Space/↓ open the panel with the focus on the first item, ↑ on the last.
Inside: ↓/↑ across the items, Home/End to the edges, Tab closes, Esc closes and
returns the focus to the trigger (that is disposed of by the shared layer stack rather
than by the component itself).
A printable character is a typeahead by the first letter: the buffer accumulates while the pauses between presses are shorter than 600 ms, and repeating the same letter means “the next item starting with it” rather than a search for “aa”. The search runs over the visible text of the item.
Space is not intercepted while the buffer is empty: menu items are buttons, and the
space is their native activation key. It enters the query only when a search is already
running (the typeahead rule from the WAI-ARIA APG).
Controls inside the panel
With closeOnContentClick={false} it is not a menu that lives in the panel but content —
fields, checkboxes, switches. The keys the focused control can handle itself are left to
it: printable characters and Home/End do not go into the navigation across the items
while the focus is in an input, a textarea, a select or a contenteditable.
The arrows are a deliberate exception: they stay with the panel even inside a field. Tab
closes the panel, and without the arrows there would be no way out of a field inside it.
They are also how the field is reached in the first place: everything focusable enters the
navigation ring, not only the menu items.
Control from the outside: `v-model:open`
Besides the imperative API the panel is controllable declaratively: without the open
prop the previous uncontrolled behaviour holds, with it the parent owns the state and
update:open accompanies every change (the shared contract of panel overlays, as in
GrPopover). The hover delays in the controlled mode are the same: the component emits,
the parent applies.
Playground 7
Loading…
<GrDropdown />Install
npm i @feugene/granularityImport
import { GrDropdown } from '@feugene/granularity/components/GrDropdown'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
open | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`). Without the prop the panel runs itself (uncontrolled), with it — listen to `update:open` and change the prop. |
disabled | boolean | undefined | false | The panel is opened by nothing; the trigger stays focusable. |
placement | Placement | undefined | "bottom-end" | The placement of the panel relative to the trigger; `flip` when there is not enough room still applies. |
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 overlay portal (`#gr-portal` or the `portalTarget` from `GrConfigProvider`). |
contentClass | string | undefined | "" | Extra classes for the content container. |
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 the panel on a click inside the content. |
Slots
| Slot | Type | Description |
|---|---|---|
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, and the click arrives together with them. |
content | { close: () => void; } | The content of the menu. |
Events
| Event | Type | Description |
|---|---|---|
update:open | [value: boolean] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
close | () => void | — |
toggle | () => void | — |
Examples 4
Basic actions menu
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrDropdown } from '@feugene/granularity'
const lastAction = ref('No action yet')
function select(action: string) {
lastAction.value = action
}
</script>
<template>
<div class="grid gap-3">
<GrDropdown>
<template #trigger="{ open, triggerProps }">
<GrButton variant="outline" v-bind="triggerProps">
{{ open ? 'Close menu' : 'Open menu' }}
</GrButton>
</template>
<template #content>
<div class="grid gap-1">
<button type="button" role="menuitem" class="rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--gr-accent)]" @click="select('Preview')">
Preview
</button>
<button type="button" role="menuitem" class="rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--gr-accent)]" @click="select('Duplicate')">
Duplicate
</button>
<button type="button" role="menuitem" class="rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--gr-accent)]" @click="select('Archive')">
Archive
</button>
</div>
</template>
</GrDropdown>
<GrBadge>
{{ lastAction }}
</GrBadge>
</div>
</template>Alignment and width presets
<script setup lang="ts">
import { GrButton, GrDropdown } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-4 lg:grid-cols-3">
<GrDropdown placement="bottom-start" width="12rem">
<template #trigger="{ triggerProps }">
<GrButton variant="outline" v-bind="triggerProps">Left</GrButton>
</template>
<template #content>
<div class="grid gap-1 px-3 py-2 text-sm">
<div class="font-semibold">Left aligned</div>
<div class="text-[var(--gr-muted-fg)]">
Anchored to the left edge of a toolbar or list item.
</div>
</div>
</template>
</GrDropdown>
<GrDropdown placement="top" :offset="16" :width="240">
<template #trigger="{ triggerProps }">
<GrButton v-bind="triggerProps">Center</GrButton>
</template>
<template #content>
<div class="grid gap-1 px-3 py-2 text-sm">
<div class="font-semibold">Center aligned</div>
<div class="text-[var(--gr-muted-fg)]">
Works well for compact pickers.
</div>
</div>
</template>
</GrDropdown>
<GrDropdown placement="bottom-end" width="auto">
<template #trigger="{ triggerProps }">
<GrButton variant="ghost-border" v-bind="triggerProps">Auto width</GrButton>
</template>
<template #content>
<div class="whitespace-nowrap px-3 py-2 text-sm">
Width adapts to content width.
</div>
</template>
</GrDropdown>
</div>
</template>Persistent content with manual close
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrButton, GrDropdown } from '@feugene/granularity'
const options = computed(() => [
{ value: 'errors', label: 'Errors' },
{ value: 'warnings', label: 'Warnings' },
{ value: 'passed', label: 'Passed' },
])
const selected = ref<string[]>(['errors'])
const selectedLabels = computed(() =>
options.value
.filter(option => selected.value.includes(option.value))
.map(option => option.label)
.join(', '),
)
function toggleOption(option: string) {
selected.value = selected.value.includes(option)
? selected.value.filter(item => item !== option)
: [...selected.value, option]
}
</script>
<template>
<div class="grid gap-3">
<GrDropdown :close-on-content-click="false" width="16rem">
<template #trigger="{ triggerProps }">
<GrButton variant="outline" v-bind="triggerProps">Filters</GrButton>
</template>
<template #content="{ close }">
<div class="grid gap-3 px-3 py-2 text-sm">
<div class="font-semibold">Visible states</div>
<label v-for="option in options" :key="option.value" class="flex items-center gap-2">
<input
:checked="selected.includes(option.value)"
type="checkbox"
@change="toggleOption(option.value)"
>
<span>{{ option.label }}</span>
</label>
<GrButton size="sm" class="justify-self-start" @click="close">
Apply filters
</GrButton>
</div>
</template>
</GrDropdown>
<GrBadge>
{{ selectedLabels }}
</GrBadge>
</div>
</template>Hover trigger and disabled state
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrDropdown, GrSwitch } from '@feugene/granularity'
const disabled = ref(false)
const lastAction = ref('—')
const items = ['Экспорт в CSV', 'Экспорт в XLSX', 'Отправить на почту']
</script>
<template>
<div class="grid gap-3">
<GrSwitch v-model="disabled" class="justify-self-start">
disabled
</GrSwitch>
<div class="flex flex-wrap items-center gap-3">
<!-- Наведение открывает панель, но клик и клавиатура продолжают работать:
меню, доступное только мышью, недоступно с клавиатуры вовсе. -->
<GrDropdown trigger="hover" :disabled="disabled" width="14rem">
<template #trigger="{ triggerProps }">
<GrButton variant="outline" v-bind="triggerProps">
Действия (наведение)
</GrButton>
</template>
<template #content>
<div class="grid gap-1">
<button
v-for="item in items"
:key="item"
type="button"
role="menuitem"
class="rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--gr-accent)]"
@click="lastAction = item"
>
{{ item }}
</button>
</div>
</template>
</GrDropdown>
<GrBadge>{{ lastAction }}</GrBadge>
</div>
</div>
</template>Accessibility
- APG pattern
menu