GrDropdownMenu

  • ядро
  • Слои
  • API · 16 props
  • ⌨ клавиши

Берут, когда действия над объектом.

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

Обзор

Когда брать

  • действия над объектом — «⋯» у строки таблицы, у карточки, у файла: типовое меню собирается пропом items;
  • пунктов много и они разнородны — группы, заголовки, разделители и колонки уже есть;
  • часть пунктов недоступна — disabled-пункт остаётся в обходе с клавиатуры и объявляется, а не исчезает;
  • нужна разметка без сборки вручную — слой, роли и клавиатура приезжают из GrDropdown целиком.

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

НужноБерите
Пункты нестандартные, разметку пишете самиGrDropdown
Внутри форма или фильтрGrPopover
Действий два-три и они помещаются в рядGrButtonGroup
Навигация по разделам приложенияGrSidebar
Поиск по командам всего приложенияGrCommandPalette

Playground контролы выводятся из API, а не пишутся руками

Сцена

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

0 изменено· состояние — в адресе

Примеры 4 у каждого — живое демо и его код

  • Действия над строкой таблицы

    Меню, описанное items: значки, сочетания клавиш, разделители, пункт danger и подменю из children — оно открывает следующий уровень и в select не приходит. Выключенный пункт остаётся в обходе стрелками, а у аннулированного счёта disabled держит закрытым всё меню, оставляя триггер фокусируемым.

    InvoiceCustomerStatusAmountActions
    INV-2026-0418Northwind TradersOpen€1,284.50
    INV-2026-0417Contoso LtdPaid€432.00
    INV-2026-0409Fabrikam IncVoid€96.00

    3 invoices · open a row menu with the ⋯ button or Enter

  • Меню аккаунта из частей

    Композиция для меню, которое модель не описывает: своя шапка, GrDropdownMenuList и GrDropdownMenuItem с href, GrDropdownMenuSub для темы. Каждый уровень — свой слой, поэтому Esc закрывает подменю, а не всё меню.

    Acme
  • Переключатели, которые не закрывают меню

    Группы пунктов menuitemradio и menuitemcheckbox с checked: скринридер слышит состояние, а не только подпись. close-on-content-click="false" даёт поменять несколько настроек за одно открытие, а список следует сразу.

    Issues 3
    • ACM-212 Webhook retriesDoing
    • ACM-209 EU invoice bucketDone
    • ACM-204 Drop CSV exportTodo
  • Меню «Создать» в две колонки

    GrDropdownMenuColumns разводит два вида сущностей без длинного одного списка, каждая колонка — под заголовком GrDropdownMenuGroup. width принимает любую CSS-длину — auto даёт ширину по содержимому, — а border-top у последнего списка рисует линию, которая не доходит до скруглённых углов.

    BillingOctober 2026 · 214 invoices

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
placementPlacement | undefined"bottom-end"Размещение панели относительно триггера; переворот при нехватке места остаётся.
offsetnumber | undefined8Зазор между триггером и панелью, px.
disabledboolean | undefinedfalseМеню не открывается ничем; триггер остаётся фокусируемым.
openv-modelboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`) — прокидывается в `GrDropdown`. Без пропа меню ведёт себя само (uncontrolled).
itemsGrDropdownMenuEntry[] | undefinedundefinedДекларативное меню: пункты, группы и разделители массивом. Слот по умолчанию сильнее — он для меню, которое из модели не собирается.
triggerGrDropdownTrigger | undefined"click"Чем открывается панель. В любом режиме работают клик и клавиатура.
teleportTostring | HTMLElement | undefinedundefinedТочечное переопределение точки монтирования; по умолчанию — общий портал.
contentClassstring | undefined""Дополнительные классы content-контейнера.
listClassstring | undefined""Дополнительные классы для wrapper'а списка.
dividersboolean | undefinedfalseРазделители между пунктами.
widthGrDropdownWidth | undefined"12rem"Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту.
openDelaynumber | undefined120Задержка открытия по наведению, мс.
closeDelaynumber | undefined160Задержка закрытия после ухода курсора, мс.
closeOnContentClickboolean | undefinedtrueЗакрывать по клику внутри content.

Слоты

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

События

EventСигнатураОписание
update:open(event: "update:open", value: boolean): void—
select(event: "select", item: GrDropdownMenuAction): void—

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrDropdownMenuAction, GrDropdownMenuSeparator, GrDropdownMenuSection } from '@feugene/granularity/components/GrDropdownMenu'

Файлdist/types/components/GrDropdownMenu/config.d.ts

grDropdownMenuConfigconst
const grDropdownMenuConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDropdownMenu">;

Файлdist/types/components/GrDropdownMenu/safelist.d.ts

grDropdownMenuSafelistconst
const grDropdownMenuSafelist: string[];

Файлdist/types/components/GrDropdownMenu/menuModel.d.ts

GrDropdownMenuActioninterface

Декларативная модель меню. Композиция из подкомпонентов остаётся основным способом — она нужна там, где пункт нестандартный. Но девять меню из десяти однотипны («иконка, подпись, шорткат, иногда разделитель»), и собирать их из пяти компонентов вручную значит каждый раз заново решать, где role, где disabled, где ключ.

interface GrDropdownMenuAction {
    /** Ключ пункта — он же приходит в `select`. */
    key: string;
    label: string;
    icon?: Component;
    shortcut?: string;
    disabled?: boolean;
    variant?: GrDropdownMenuItemVariant;
    role?: GrDropdownMenuItemRole;
    /** Состояние переключателя для `menuitemcheckbox`/`menuitemradio`. */
    checked?: boolean;
    /** Пункт-ссылка: рендерится как `<a>` с этим `href`. */
    href?: string;
    target?: string;
    rel?: string;
    /** Шорткат для `target="_blank"` + `rel="noopener noreferrer"`. */
    external?: boolean;
    /**
     * Чем рендерить пункт: тег или компонент роутера.
     *
     * Без него пункт-ссылка из `items` остаётся обычным `<a>`, то есть в
     * SPA переход идёт перезагрузкой страницы — а обойти это в модели нечем:
     * `itemProps` внутренняя, слота на пункт нет. Проп есть у самого
     * `GrDropdownMenuItem` и у соседей по кольцу (`GrLink`, `GrBreadcrumbs`,
     * `GrSidebarItem`), из этого ряда выпадала только модель меню.
     */
    as?: string | Component;
    /** Выравнивание содержимого пункта. */
    align?: GrDropdownMenuItemAlign;
    /**
     * Пункты второго уровня. Пункт с ними ничего не выполняет — он раскрывает
     * подменю, поэтому `select` по нему не приходит.
     *
     * Тип записи, а не действия: внутри подменю осмысленны и разделители, и
     * заголовки групп, и следующий уровень.
     */
    children?: GrDropdownMenuEntry[];
}
GrDropdownMenuSeparatorinterface
interface GrDropdownMenuSeparator {
    type: 'divider';
    inset?: boolean;
}
GrDropdownMenuSectioninterface
interface GrDropdownMenuSection {
    type: 'group';
    title?: string;
    /** Выравнивание заголовка группы. */
    titleAlign?: GrDropdownMenuHeaderAlign;
    /** Разделители между пунктами группы. */
    dividers?: boolean;
    /** Заголовок капсом. */
    uppercase?: boolean;
    items: GrDropdownMenuAction[];
}
GrDropdownMenuEntrytype
type GrDropdownMenuEntry = GrDropdownMenuAction | GrDropdownMenuSeparator | GrDropdownMenuSection;

Файлdist/types/components/GrDropdownMenu/GrDropdownMenu.vue.d.ts

GrDropdownMenuPropsinterface
interface GrDropdownMenuProps {
    /** Размещение панели относительно триггера; переворот при нехватке места остаётся. */
    placement?: UseFloatingPlacement;
    /** Зазор между триггером и панелью, px. */
    offset?: number;
    /** Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту. */
    width?: GrDropdownWidth;
    /** Чем открывается панель. В любом режиме работают клик и клавиатура. */
    trigger?: GrDropdownTrigger;
    /** Задержка открытия по наведению, мс. */
    openDelay?: number;
    /** Задержка закрытия после ухода курсора, мс. */
    closeDelay?: number;
    /** Меню не открывается ничем; триггер остаётся фокусируемым. */
    disabled?: boolean;
    /** Куда телепортировать панель (`body` по умолчанию). */
    /** Точечное переопределение точки монтирования; по умолчанию — общий портал. */
    teleportTo?: string | HTMLElement;
    /** Закрывать по клику внутри content. */
    closeOnContentClick?: boolean;
    /** Дополнительные классы content-контейнера. */
    contentClass?: string;
    /**
     * Декларативное меню: пункты, группы и разделители массивом. Слот по
     * умолчанию сильнее — он для меню, которое из модели не собирается.
     */
    items?: GrDropdownMenuEntry[];
    /** Разделители между пунктами. */
    dividers?: boolean;
    /** Верхний бордер контейнера списка. */
    borderTop?: boolean;
    /** Нижний бордер контейнера списка. */
    borderBottom?: boolean;
    /** Дополнительные классы для wrapper'а списка. */
    listClass?: string;
    /**
     * Контролируемое состояние панели (`v-model:open`) — прокидывается в
     * `GrDropdown`. Без пропа меню ведёт себя само (uncontrolled).
     */
    open?: boolean;
}
GrDropdownMenuEmitsinterface
interface GrDropdownMenuEmits {
    /** Выбран пункт декларативного меню. */
    (e: 'select', item: GrDropdownMenuAction): void;
    /** Панель открылась/закрылась (`v-model:open`). */
    (e: 'update:open', value: boolean): void;
}

Файлdist/types/components/GrDropdownMenu/grDropdownMenuStyles.d.ts

GrDropdownMenuItemAligntype
type GrDropdownMenuItemAlign = 'left' | 'center' | 'right';
GrDropdownMenuItemVarianttype
type GrDropdownMenuItemVariant = 'default' | 'danger';
GrDropdownMenuHeaderAligntype
type GrDropdownMenuHeaderAlign = GrDropdownMenuItemAlign;
GrDropdownMenuColumnAligntype
type GrDropdownMenuColumnAlign = GrDropdownMenuItemAlign;

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuColumn.vue.d.ts

GrDropdownMenuColumnPropsinterface
interface GrDropdownMenuColumnProps {
    align?: GrDropdownMenuColumnAlign;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuColumns.vue.d.ts

GrDropdownMenuColumnsPropsinterface
interface GrDropdownMenuColumnsProps {
    cols?: GrDropdownMenuColumnsCount;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuDivider.vue.d.ts

GrDropdownMenuDividerPropsinterface
interface GrDropdownMenuDividerProps {
    inset?: boolean;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuGroup.vue.d.ts

GrDropdownMenuGroupPropsinterface
interface GrDropdownMenuGroupProps {
    title?: string;
    titleAlign?: GrDropdownMenuHeaderAlign;
    dividers?: boolean;
    uppercase?: boolean;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuHeader.vue.d.ts

GrDropdownMenuHeaderPropsinterface
interface GrDropdownMenuHeaderProps {
    title?: string;
    align?: GrDropdownMenuHeaderAlign;
    uppercase?: boolean;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuItem.vue.d.ts

GrDropdownMenuItemRoletype

Роль пункта. menuitemcheckbox/menuitemradio — пункты-переключатели: они обязаны нести aria-checked, иначе AT прочитает их как обычные команды.

type GrDropdownMenuItemRole = 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';
GrDropdownMenuItemPropsinterface
interface GrDropdownMenuItemProps {
    as?: string | Component;
    disabled?: boolean;
    /** Пункт-ссылка. У выключенного пункта не рендерится: ссылка осталась бы рабочей. */
    href?: string;
    target?: string;
    rel?: string;
    /** Шорткат для `target="_blank"` + `rel="noopener noreferrer"`. */
    external?: boolean;
    align?: GrDropdownMenuItemAlign;
    variant?: GrDropdownMenuItemVariant;
    role?: GrDropdownMenuItemRole;
    /** Состояние переключателя. Осмысленно с `role="menuitemcheckbox|radio"`. */
    checked?: boolean;
    /** Иконка слева. Слот `#icon` сильнее. */
    icon?: Component;
    /** Подпись сочетания клавиш справа. Слот `#shortcut` сильнее. */
    shortcut?: string;
    /**
     * Подменю этого пункта раскрыто. Ставит `GrDropdownMenuSub`: подсветка живёт
     * на пункте, а состояние — у подменю.
     */
    expanded?: boolean;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuList.vue.d.ts

GrDropdownMenuListPropsinterface

role="none" на обёртке обязателен: role="menu" у панели объявляет своих потомков презентационными, и любой div между панелью и пунктом ломает aria-required-children, а пункты теряют принадлежность к меню.

interface GrDropdownMenuListProps {
    dividers?: boolean;
    borderTop?: boolean;
    borderBottom?: boolean;
}

Файлdist/types/components/GrDropdownMenu/GrDropdownMenuSub.vue.d.ts

GrDropdownMenuSubPropsinterface
interface GrDropdownMenuSubProps {
    /** Подпись пункта. Слот `#label` сильнее. */
    label?: string;
    /** Иконка слева. Слот `#icon` сильнее. */
    icon?: Component;
    /** Пункт не раскрывается ничем; из таб-порядка не выпадает. */
    disabled?: boolean;
    variant?: GrDropdownMenuItemVariant;
    align?: GrDropdownMenuItemAlign;
    /** Размещение панели подменю; переворот при нехватке места остаётся. */
    placement?: UseFloatingPlacement;
    /** Зазор между пунктом и панелью, px. */
    offset?: number;
    /** Меню из коротких слов не должно быть шириной в слово. */
    minWidth?: number | string;
    /** Задержка раскрытия по наведению, мс. */
    openDelay?: number;
    /** Задержка закрытия после ухода курсора, мс. */
    closeDelay?: number;
    /** Разделители между пунктами подменю. */
    dividers?: boolean;
    contentClass?: string;
    listClass?: string;
}

Файлdist/types/components/GrDropdownMenu/index.d.ts

GrDropdownMenuSubInstancetype
type GrDropdownMenuSubInstance = ComponentExposed<typeof GrDropdownMenuSubComponent>;

Доступность

Паттерн APG

menu

Клавиши

SpaceEscTab

КлавишаДействие
—то же, что у GrDropdown: пункты не табируемы (tabindex="-1"), фокус водят стрелки. Выключенные пункты из обхода **не** выпадают — aria-disabled вместо нативного disabled. Пункт с подменю: →
—Enter
Spaceраскрыть и встать на первый пункт; внутри подменю ← — закрыть его и вернуться на раскрыватель
Escзакрыть **только** верхний уровень (он верхний в стеке слоёв)
Tabзакрыть меню целиком

Использование из прозы библиотеки

Роли: почему их нельзя пропустить

Панель GrDropdown объявляет role="menu", а эта роль делает всех своих потомков презентационными. Поэтому роли здесь не украшение, а условие того, что меню вообще существует для скринридера:

  • пункт — role="menuitem" (или menuitemcheckbox/menuitemradio);
  • разделитель — role="separator";
  • группа — role="group" с именем из заголовка через aria-labelledby;
  • заголовок группы — role="presentation";
  • список, колонки и колонка — role="none": обёртка между menu и menuitem ломает aria-required-children.
Фокус и выключенные пункты

Пункты не табируемы (tabindex="-1"): в паттерне menu табируемым остаётся триггер, а внутри панели фокус водят стрелки — этим распоряжается GrDropdown. Иначе Tab ходил бы по пунктам, и меню было бы просто списком кнопок.

disabled гасится aria-disabled, а не нативным disabled: пункт остаётся фокусируемым и попадает в обход стрелками, поэтому пользователь узнаёт, что действие существует, но сейчас недоступно (рекомендация WAI-ARIA APG). Клик и Enter при этом перехватываются, а фон и текст берутся из disabled-токенов, а не из opacity.

Пункты-ссылки

href сам делает пункт ссылкой — as="a" для этого не нужен. target и rel задаются явно, external — шорткат для target="_blank" с rel="noopener noreferrer".

У выключенной ссылки href снимается: перехват клика не спасает от средней кнопки мыши и «открыть в новой вкладке» из контекстного меню браузера. Тот же приём в GrButton.

В декларативной модели это поля href, target, rel, external у пункта.

Пункты-переключатели

role="menuitemcheckbox" и role="menuitemradio" требуют aria-checked — компонент выставляет его в обоих состояниях, иначе AT прочитает пункт как обычную команду. Место под отметку занято всегда, когда пункт переключаемый: иначе строки «включено» и «выключено» разъезжаются по горизонтали.

vue
<GrDropdownMenuItem role="menuitemcheckbox" :checked="showArchived" @click="toggle">
  Показывать архив
</GrDropdownMenuItem>

Иконка и сочетание клавиш задаются пропами icon / shortcut либо слотами #icon / #shortcut (слот сильнее).

Меню из модели

Композиция остаётся для нестандартных пунктов: GrDropdownMenuList — обёртка списка, GrDropdownMenuGroup и GrDropdownMenuHeader — раздел с заголовком, GrDropdownMenuItem и GrDropdownMenuDivider — пункт и разделитель, GrDropdownMenuColumns с GrDropdownMenuColumn — раскладка в колонки. Все они приезжают из того же subpath, что и меню.

Но девять меню из десяти однотипны — их проще описать массивом:

vue
<GrDropdownMenu :items="items" @select="onSelect" />
ts
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Переименовать', shortcut: '⌘R' },
  { type: 'divider' },
  { type: 'group', title: 'Вид', items: [
    { key: 'compact', label: 'Компактно', role: 'menuitemradio', checked: true },
  ] },
  { key: 'delete', label: 'Удалить', variant: 'danger' },
]

select не эмитится для disabled-пункта. Пункт с href рендерится ссылкой. Слот по умолчанию сильнее модели: передали и то и другое — победит слот.

Модель умеет то же, что композиция. Пункт принимает as — тег или компонент роутера, — и align; группа знает titleAlign, dividers и uppercase. Это не удобство: без as пункт-ссылка из модели остаётся обычным <a>, то есть в SPA переход идёт перезагрузкой страницы, а обойти это нечем — разложить модель в пропы можно только внутри компонента. Стоило этому полю отстать от GrDropdownMenuItem, и потребителю приходилось переписывать обход модели целиком ради одной ссылки.

ts
const items: GrDropdownMenuEntry[] = [
  { key: 'profile', label: 'Профиль', href: '/profile', as: RouterLink },
]
Второй уровень: подменю

Пункт с непустыми children становится раскрывателем: он ничего не выполняет и select по нему не приходит — он открывает панель следующего уровня. В композиции то же самое даёт GrDropdownMenuSub.

ts
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Переименовать' },
  { key: 'export', label: 'Экспорт', children: [
    { key: 'pdf', label: 'PDF' },
    { key: 'csv', label: 'CSV' },
  ] },
]
vue
<GrDropdownMenuSub label="Экспорт">
  <GrDropdownMenuItem @click="save('pdf')">PDF</GrDropdownMenuItem>
</GrDropdownMenuSub>

Глубина не ограничена, children принимает те же записи, что и корень: внутри подменю осмысленны и разделители, и группы, и следующий уровень.

Уровни — самостоятельные слои, а не вложенная разметка. Каждая панель уезжает в портал, поэтому в DOM они лежат рядом, а не одна в другой. Из этого следует всё остальное: Escape закрывает верхний уровень, а не всё меню; выбор в подменю закрывает цепочку целиком; клик по раскрывателю до панели родителя не доходит, иначе closeOnContentClick закрыл бы меню ровно в момент раскрытия следующего уровня.

Курсору дан коридор. Путь от пункта к его подменю идёт наискось и проходит по соседним пунктам. Пока курсор внутри треугольника «точка ухода → ближний край раскрытой панели», уход считается движением к цели: панель не закрывается, а соседи своё подменю не раскрывают — они раскроют его, как только станет ясно, что курсор шёл не к панели. Без этого подменю теряется на полпути.

Клавиатура — ../keyboard.md: → раскрывает, ← возвращает на раскрыватель, Tab закрывает меню целиком.

href и роли-переключатели с children несовместимы: пункт не может быть одновременно ссылкой и раскрывателем — в dev-сборке это предупреждение.

Оформление

variant="danger" красит пункт ролью --gr-danger-text, а не насыщенным тоном: насыщенный тон как цвет текста не проходит контраст. Disabled гасится фоном (--gr-muted), а не opacity, и не пропускает ни клик, ни клавиатурную активацию — обработчик на самом пункте останавливается stopImmediatePropagation.

Пункт скруглён и вписан в поле панели. Фон подсветки лежит на самом пункте, а панель GrDropdown скруглена и содержимое не обрезает: прямоугольник во всю ширину заливал бы угловые сегменты, вырезанные её радиусом. Поэтому у пункта свой радиус ступенью мельче панельного — тот же приём, что у опций GrSelect и GrAutocomplete.

Гасить поле панели своим p-0 для этого нельзя: оба класса попадают в один атрибут с равной специфичностью, и победителя выбирает порядок правил в сгенерированном CSS, а не разметка. Нужна панель без поля — это отдельный канал, а не перекрытие классом.

Линии borderTop / borderBottom не доходят до краёв — и это не оплошность. Список лежит в поле панели: 1 px рамки плюс 4 px p-1. Угол панели скруглён на 16 px, то есть на глубине 5 px дуга ещё идёт — правило во всю ширину упиралось бы не в вертикальный край, а в неё, и у каждого угла было бы видно клин, в который сходятся линия и рамка. Поэтому линия рисуется псевдоэлементом с инсетом в 8 px, тем же, что у GrDropdownMenuDivider :inset. Пункты при этом остаются во всю ширину: их ширина — часть того же попадания подсветки в поле панели.

Правило общее для скруглённых поверхностей: ничто во всю ширину не подходит к краю панели ближе, чем её радиус. Геометрию держит e2e-проверка (apps/showcase/e2e/geometry.spec.ts) — в jsdom утилиты не превращаются в CSS, и куда попал конец линии, там не увидеть.

Разделителей между пунктами это не касается: dividers и GrDropdownMenuDivider лежат далеко от углов и читаются нормально во всю ширину.

Все классы каталога живут в grDropdownMenuStyles.ts и объявлены в safelist: .ts-хелпер бандлер выносит в общий чанк, вне области скана компонента, и без safelist у изолированного потребителя пропали бы выравнивание, колонки и цвета.

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

Проп open и событие update:open прокидываются в обёрнутый GrDropdown как есть — контракт тот же, что у GrDropdown.