GrDropdown

Package: @feugene/granularitycoreGroup: overlays

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 #trigger slot accepts anything;
  • the panel opens on hoveropenDelay and closeDelay separate an intent from an accidental movement of the mouse;
  • the opening is governed by the parentv-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.

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

Playground 7

Loading…

Code
<GrDropdown />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
openboolean | 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.
disabledboolean | undefinedfalseThe panel is opened by nothing; the trigger stays focusable.
placementPlacement | undefined"bottom-end"The placement of the panel relative to the trigger; `flip` when there is not enough room still applies.
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.
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 the panel on a click inside the content.

Slots

SlotTypeDescription
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

EventTypeDescription
update:open[value: boolean]

Methods / Expose

Methods / ExposeTypeDescription
close() => void
toggle() => void

Examples 4

Basic actions menu

No action yet

Basic 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

Alignment
<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

Errors

Persistent Content
<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

Hover
<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

Full keyboard contract of the package

Component documentationAll components