GrDropdown

  • core
  • overlays
  • API · 11 props
  • ⌨ keys

Opens a floating list of actions or options next to the trigger.

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

Overview

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 #trigger slot accepts anything;
  • the panel opens on hover — openDelay and closeDelay separate an intent from an accidental movement of the mouse;
  • the opening is governed by the parent — v-model:open together with trigger="manual".

When to take something else

NeedTake
The items are ordinary: text, an icon, groups, separatorsGrDropdownMenu
There is a form, a filter or a palette inside — not a menuGrPopover
A text hint without interactionGrTooltip
Selecting the value of a form fieldGrSelect
A search across the commands of the whole applicationGrCommandPalette

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.

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • A card on hover

    trigger="hover" with open-delay tells intent from a passing cursor, and close-delay keeps the panel across the gap on the way to it. Click and keyboard still open it: a panel only a mouse can reach is no panel at all.

    ACM-212 · 2 h ago

    Retries are ready for review. Assigned to
    — she wrote the backoff policy.
  • Notifications under a bell

    Any trigger takes triggerProps on the real button — here inside a counter. v-model:open hands the state to the page, which marks everything read when the panel closes. Rows are menuitem buttons, so arrows move between them.

    Acme Billing
    2 unread notifications
  • Controls inside the panel

    close-on-content-click="false" keeps the panel open while checkboxes change the table, and the close from the slot ends it with «Done». The trigger reads open from the slot to turn its chevron.

    Invoices
    InvoiceCustomerDueAmount
    INV-0418Northwind Traders15 Oct€1,284.50
    INV-0417Contoso Ltd12 Oct€432.00
  • Upwards from the composer

    The composer lives at the bottom of a screen, so placement="top-start" opens the panel upwards — a preference, not an order: near an edge it flips. Two-line items are markup of your own, and a typed letter jumps to the matching title.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
placementPlacement | undefined"bottom-end"The placement of the panel relative to the trigger; `flip` when there is not enough room still applies.
offsetnumber | undefined8The gap between the trigger and the panel, in px.
disabledboolean | undefinedfalseThe panel is opened by nothing; the trigger stays focusable.
openv-modelboolean | undefinedundefinedThe 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.
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 overlay portal (`#gr-portal` or the `portalTarget` from `GrConfigProvider`).
contentClassstring | undefined""Extra classes for the content container.
widthGrDropdownWidth | undefined"12rem"The width of the panel: a number is pixels, a string is a CSS length, `auto` is by the content.
openDelaynumber | undefined120The delay before opening on hover, in ms.
closeDelaynumber | undefined160The delay before closing after the cursor leaves, in ms.
closeOnContentClickboolean | undefinedtrueClose the panel on a click inside the content.

Slots

SlotTypeDescription
trigger
{ open: boolean; toggle: () => void; close: () => void; trig{ 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

EventSignatureDescription
update:open(event: "update:open", value: boolean): void—

Methods

MethodsSignatureDescription
close() => voidClose the panel from code.
toggle() => voidToggle the panel: close it if open, open it if closed.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrDropdownTrigger, GrDropdownProps, GrDropdownEmits } from '@feugene/granularity/components/GrDropdown'

Filedist/types/components/GrDropdown/config.d.ts

grDropdownConfigconst
const grDropdownConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDropdown">;

Filedist/types/components/GrDropdown/safelist.d.ts

grDropdownSafelistconst
const grDropdownSafelist: string[];

Filedist/types/components/GrDropdown/GrDropdown.vue.d.ts

GrDropdownTriggertype
type GrDropdownTrigger = 'click' | 'hover';
GrDropdownPropsinterface
interface GrDropdownProps {
    placement?: UseFloatingPlacement;
    offset?: number;
    width?: GrDropdownWidth;
    trigger?: GrDropdownTrigger;
    openDelay?: number;
    closeDelay?: number;
    disabled?: boolean;
    closeOnContentClick?: boolean;
    contentClass?: string;
    teleportTo?: string | HTMLElement;
    open?: boolean;
}
GrDropdownEmitsinterface
interface GrDropdownEmits {
    (e: 'update:open', value: boolean): void;
}

Filedist/types/components/GrDropdown/grDropdownStyles.d.ts

GrDropdownWidthtype
type GrDropdownWidth = number | string;

Filedist/types/components/GrDropdown/index.d.ts

GrDropdownInstancetype
type GrDropdownInstance = ComponentExposed<typeof GrDropdownComponent>;

Accessibility

APG pattern

menu

Keys

↑Home / EndEnter / SpaceEsc

Usage from the library prose

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:

vue
<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-end by default). Flipping when there is not enough room (flip) always works: placement is a preference rather than an order, and the transform-origin of 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, and auto gives 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.