GrDropdown
Берут, когда меню со своими пунктами.
Когда брать
- меню со своими пунктами — разметку пунктов пишете вы, а слой, позиционирование и клавиатуру берёт компонент;
- триггер нестандартный — аватар, ячейка таблицы, кнопка с бейджем: слот
#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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
open | boolean | undefined | undefined | Контролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп. |
disabled | boolean | undefined | false | Панель не открывается ничем; триггер остаётся фокусируемым. |
placement | Placement | undefined | "bottom-end" | Размещение панели относительно триггера; `flip` при нехватке места остаётся. |
trigger | GrDropdownTrigger | undefined | "click" | Чем открывается панель. В любом режиме работают клик и клавиатура. |
teleportTo | string | HTMLElement | undefined | undefined | Точечное переопределение точки монтирования. По умолчанию — общий портал оверлеев (`#gr-portal` либо `portalTarget` из `GrConfigProvider`). |
contentClass | string | undefined | "" | Дополнительные классы контейнера content. |
width | GrDropdownWidth | undefined | "12rem" | Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту. |
offset | number | undefined | 8 | Зазор между триггером и панелью, px. |
openDelay | number | undefined | 120 | Задержка открытия по наведению, мс. |
closeDelay | number | undefined | 160 | Задержка закрытия после ухода курсора, мс. |
closeOnContentClick | boolean | undefined | true | Закрывать панель по клику внутри content. |
Slots
| Slot | Type | Описание |
|---|---|---|
trigger | { open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; } | Триггер панели. `triggerProps` обязаны попасть на сам интерактивный элемент, а не на обёртку вокруг него: `aria-expanded` и `aria-controls` читаются с того узла, который получает фокус, а клик приезжает вместе с ними. |
content | { close: () => void; } | Содержимое меню. |
Events
| Event | Type | Описание |
|---|---|---|
update:open | [value: boolean] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
close | () => void | — |
toggle | () => void | — |
Примеры 4
Базовое меню действий
Стартовый сценарий для GrDropdown: trigger/content slots, короткий action list и автоматическое закрытие по клику.
<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 и ожидаемую ширину выпадающего контента.
<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 и компонент не должен закрываться после каждого клика.
<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 закрывает панель и не даёт открыть её ничем, оставляя триггер фокусируемым.
<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её закрывает, и иначе из поля не выйти