GrDropdown

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда меню со своими пунктами.

Когда брать

  • меню со своими пунктами — разметку пунктов пишете вы, а слой, позиционирование и клавиатуру берёт компонент;
  • триггер нестандартный — аватар, ячейка таблицы, кнопка с бейджем: слот #trigger принимает что угодно;
  • панель открывается по наведениюopenDelay и closeDelay разводят намерение и случайное движение мыши;
  • открытием управляет родительv-model:open вместе с trigger="manual".

Когда взять другое

НужноБерите
Пункты обычные: текст, иконка, группы, разделителиGrDropdownMenu
Внутри форма, фильтр или палитра — не менюGrPopover
Текстовая подсказка без интерактиваGrTooltip
Выбор значения поля формыGrSelect
Поиск по командам всего приложенияGrCommandPalette

Компонент жёстко объявляет role="menu" и водит фокус по пунктам. Для формы или подтверждения это неверная семантика — диктор объявит меню там, где меню нет; такой случай закрывает GrPopover с настраиваемой ролью.

Триггер: `triggerProps` обязателен

Компонент не знает, что именно потребитель положит в слот #trigger, поэтому клик, клавиатуру и ARIA он отдаёт объектом, который нужно привязать к настоящему фокусируемому элементу:

<GrDropdown>
  <template #trigger="{ triggerProps }">
    <GrButton variant="outline" v-bind="triggerProps">Действия</GrButton>
  </template>

  <template #content>
    <button type="button" role="menuitem">Дублировать</button>
  </template>
</GrDropdown>

Обёртка слота клики не ловит намеренно: иначе панель переключала бы любой клик внутри неё — включая вложенные кнопки и ссылки. Забытый v-bind в dev-сборке печатает предупреждение: молча неработающий триггер искали бы в чужом коде.

Слот отдаёт ещё open, toggle и close — для своей разметки состояния.

Размещение и ширина

  • placement — любое значение floating-ui (bottom-end по умолчанию). Переворот при нехватке места (flip) работает всегда: placement — это предпочтение, а не приказ, и transform-origin панели следует за фактической стороной;
  • offset — зазор между триггером и панелью, px;
  • width — CSS-длина: число трактуется как пиксели, строка идёт как есть, auto отдаёт ширину контенту. Tailwind-шкалы (width="48"w-48) больше нет; строка без единиц трактуется как пиксели и в dev-сборке ругается.

Режимы открытия

trigger="click" (по умолчанию) или "hover". В режиме наведения задержки задают openDelay/closeDelay: без первой панель выпрыгивает на любое пересечение курсором, без второй её не удержать при переходе с триггера на панель — между ними зазор offset.

Клик и клавиатура работают в обоих режимах: меню, доступное только мышью, недоступно с клавиатуры вовсе.

disabled закрывает панель и не даёт открыть её ни кликом, ни клавиатурой, ни наведением. Триггер при этом остаётся фокусируемым и получает aria-disabled — нативный disabled выкинул бы его из таб-порядка вместе с объяснением, что происходит.

Императивный API

open(), close() и toggle() через ref на компоненте — тот же состав, что у остальных оверлеев. В отличие от модальных, меню состояние держит само: модели у него нет, и методы правят его напрямую. disabled императив не обходит — иначе он открывал бы то, что закрыто для клика и клавиатуры.

Те же три метода приходят в слот #trigger (:open, :toggle, :close) — ref нужен, когда открывать меню надо снаружи разметки триггера.

Клавиатура

Enter/Space/ открывают панель с фокусом на первом пункте, — на последнем. Внутри: / по пунктам, Home/End к краям, Tab закрывает, Esc закрывает и возвращает фокус на триггер (этим распоряжается общий стек слоёв, а не сам компонент).

Печатный символ — typeahead по первой букве: буфер копится, пока паузы между нажатиями короче 600 мс, а повтор одной и той же буквы означает «следующий пункт на неё», а не поиск «аа». Ищется по видимому тексту пункта.

Space при пустом буфере не перехватывается: пункты меню — кнопки, и пробел для них родная клавиша активации. В запрос он попадает, только когда поиск уже идёт (правило typeahead из WAI-ARIA APG).

Контролы внутри панели

С closeOnContentClick={false} в панели живёт не меню, а содержимое — поля, чекбоксы, переключатели. Клавиши, которые сфокусированный контрол умеет сам, достаются ему: печатные символы и Home/End не уходят в навигацию по пунктам, пока фокус в input, textarea, select или contenteditable.

Стрелки — намеренное исключение: они остаются за панелью даже в поле. Tab панель закрывает, и без стрелок из поля внутри неё не было бы выхода. Ими же до поля и добираются: в кольцо навигации входит всё фокусируемое, а не только пункты меню.

Управление извне: `v-model:open`

Кроме императивного API панель управляема декларативно: без пропа open — прежнее uncontrolled-поведение, с ним состоянием владеет родитель, а update:open сопровождает каждое изменение (общий контракт панельных оверлеев, как у GrPopover). Hover-задержки в controlled-режиме те же: компонент эмитит, применяет родитель.

Playground 7

Загружается…

Код
<GrDropdown />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
openboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп.
disabledboolean | undefinedfalseПанель не открывается ничем; триггер остаётся фокусируемым.
placementPlacement | undefined"bottom-end"Размещение панели относительно триггера; `flip` при нехватке места остаётся.
triggerGrDropdownTrigger | undefined"click"Чем открывается панель. В любом режиме работают клик и клавиатура.
teleportTostring | HTMLElement | undefinedundefinedТочечное переопределение точки монтирования. По умолчанию — общий портал оверлеев (`#gr-portal` либо `portalTarget` из `GrConfigProvider`).
contentClassstring | undefined""Дополнительные классы контейнера content.
widthGrDropdownWidth | undefined"12rem"Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту.
offsetnumber | undefined8Зазор между триггером и панелью, px.
openDelaynumber | undefined120Задержка открытия по наведению, мс.
closeDelaynumber | undefined160Задержка закрытия после ухода курсора, мс.
closeOnContentClickboolean | undefinedtrueЗакрывать панель по клику внутри content.

Slots

SlotTypeОписание
trigger{ open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; }Триггер панели. `triggerProps` обязаны попасть на сам интерактивный элемент, а не на обёртку вокруг него: `aria-expanded` и `aria-controls` читаются с того узла, который получает фокус, а клик приезжает вместе с ними.
content{ close: () => void; }Содержимое меню.

Events

EventTypeОписание
update:open[value: boolean]

Methods / Expose

Methods / ExposeTypeОписание
close() => void
toggle() => void

Примеры 4

Базовое меню действий

Стартовый сценарий для GrDropdown: trigger/content slots, короткий action list и автоматическое закрытие по клику.

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>

Сторона и ширина панели

Отдельно сравниваем align и width, чтобы быстро проверить positioning и ожидаемую ширину выпадающего контента.

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>

Содержимое, которое не закрывается по клику

Показываем closeOnContentClick=false, когда внутри dropdown есть mini-form/filter pane и компонент не должен закрываться после каждого клика.

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>

Это типичный composition-case: dropdown используется не как простое menu, а как контейнер для mini-control surface.

Открытие по наведению и выключенный триггер

trigger="hover" открывает панель по наведению с задержками openDelay/closeDelay — курсор успевает дойти до пункта через зазор offset. Клик и клавиатура при этом продолжают работать: меню, доступное только мышью, недоступно с клавиатуры вовсе. disabled закрывает панель и не даёт открыть её ничем, оставляя триггер фокусируемым.

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>

Доступность

Паттерн APG
menu
Клавиши
Enter/Space/ на триггере — открыть с фокусом на первом пункте, — на последнем; / — по всему фокусируемому в панели, Home/End — к краям, печатный символ — typeahead по первой букве (буфер 600 мс, повтор буквы — следующий пункт на неё), Enter/Space — активировать, Esc — закрыть, Tab — закрыть. Пока фокус на контроле (input/textarea/select/contenteditable), печатные и Home/End достаются ему, а не панели; стрелки остаются за панелью — Tab её закрывает, и иначе из поля не выйти

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты