GrDropdownMenu
Берут, когда действия над объектом.
Обзор
Когда брать
- действия над объектом — «⋯» у строки таблицы, у карточки, у файла: типовое меню собирается пропом
items; - пунктов много и они разнородны — группы, заголовки, разделители и колонки уже есть;
- часть пунктов недоступна —
disabled-пункт остаётся в обходе с клавиатуры и объявляется, а не исчезает; - нужна разметка без сборки вручную — слой, роли и клавиатура приезжают из
GrDropdownцеликом.
Когда взять другое
| Нужно | Берите |
|---|---|
| Пункты нестандартные, разметку пишете сами | GrDropdown |
| Внутри форма или фильтр | GrPopover |
| Действий два-три и они помещаются в ряд | GrButtonGroup |
| Навигация по разделам приложения | GrSidebar |
| Поиск по командам всего приложения | GrCommandPalette |
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrDropdownMenu placement="bottom-start" :items="items" />Примеры 4 у каждого — живое демо и его код
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
placement | Placement | undefined | "bottom-end" | Размещение панели относительно триггера; переворот при нехватке места остаётся. |
offset | number | undefined | 8 | Зазор между триггером и панелью, px. |
disabled | boolean | undefined | false | Меню не открывается ничем; триггер остаётся фокусируемым. |
openv-model | boolean | undefined | undefined | Контролируемое состояние панели (`v-model:open`) — прокидывается в `GrDropdown`. Без пропа меню ведёт себя само (uncontrolled). |
items | GrDropdownMenuEntry[] | undefined | undefined | Декларативное меню: пункты, группы и разделители массивом. Слот по умолчанию сильнее — он для меню, которое из модели не собирается. |
trigger | GrDropdownTrigger | undefined | "click" | Чем открывается панель. В любом режиме работают клик и клавиатура. |
teleportTo | string | HTMLElement | undefined | undefined | Точечное переопределение точки монтирования; по умолчанию — общий портал. |
contentClass | string | undefined | "" | Дополнительные классы content-контейнера. |
listClass | string | undefined | "" | Дополнительные классы для wrapper'а списка. |
dividers | boolean | undefined | false | Разделители между пунктами. |
width | GrDropdownWidth | undefined | "12rem" | Ширина панели: число — пиксели, строка — CSS-длина, `auto` — по контенту. |
openDelay | number | undefined | 120 | Задержка открытия по наведению, мс. |
closeDelay | number | undefined | 160 | Задержка закрытия после ухода курсора, мс. |
closeOnContentClick | boolean | undefined | true | Закрывать по клику внутри content. |
borderTop | boolean | undefined | false | Верхний бордер контейнера списка. |
borderBottom | boolean | undefined | false | Нижний бордер контейнера списка. |
Слоты
| Slot | Type | Описание |
|---|---|---|
default | { close: () => void; } | Пункты меню. Слот-пропы прокидываются от `GrDropdown` как есть. |
trigger |
| Триггер панели. `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
const grDropdownMenuConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDropdownMenu">;Файлdist/types/components/GrDropdownMenu/safelist.d.ts
const grDropdownMenuSafelist: string[];Файлdist/types/components/GrDropdownMenu/menuModel.d.ts
Декларативная модель меню.
Композиция из подкомпонентов остаётся основным способом — она нужна там, где
пункт нестандартный. Но девять меню из десяти однотипны («иконка, подпись,
шорткат, иногда разделитель»), и собирать их из пяти компонентов вручную
значит каждый раз заново решать, где 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[];
}interface GrDropdownMenuSeparator {
type: 'divider';
inset?: boolean;
}interface GrDropdownMenuSection {
type: 'group';
title?: string;
/** Выравнивание заголовка группы. */
titleAlign?: GrDropdownMenuHeaderAlign;
/** Разделители между пунктами группы. */
dividers?: boolean;
/** Заголовок капсом. */
uppercase?: boolean;
items: GrDropdownMenuAction[];
}type GrDropdownMenuEntry = GrDropdownMenuAction | GrDropdownMenuSeparator | GrDropdownMenuSection;Файлdist/types/components/GrDropdownMenu/GrDropdownMenu.vue.d.ts
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;
}interface GrDropdownMenuEmits {
/** Выбран пункт декларативного меню. */
(e: 'select', item: GrDropdownMenuAction): void;
/** Панель открылась/закрылась (`v-model:open`). */
(e: 'update:open', value: boolean): void;
}Файлdist/types/components/GrDropdownMenu/grDropdownMenuStyles.d.ts
type GrDropdownMenuItemAlign = 'left' | 'center' | 'right';type GrDropdownMenuItemVariant = 'default' | 'danger';type GrDropdownMenuHeaderAlign = GrDropdownMenuItemAlign;type GrDropdownMenuColumnAlign = GrDropdownMenuItemAlign;Файлdist/types/components/GrDropdownMenu/GrDropdownMenuColumn.vue.d.ts
interface GrDropdownMenuColumnProps {
align?: GrDropdownMenuColumnAlign;
}Файлdist/types/components/GrDropdownMenu/GrDropdownMenuColumns.vue.d.ts
interface GrDropdownMenuColumnsProps {
cols?: GrDropdownMenuColumnsCount;
}Файлdist/types/components/GrDropdownMenu/GrDropdownMenuDivider.vue.d.ts
interface GrDropdownMenuDividerProps {
inset?: boolean;
}Файлdist/types/components/GrDropdownMenu/GrDropdownMenuGroup.vue.d.ts
interface GrDropdownMenuGroupProps {
title?: string;
titleAlign?: GrDropdownMenuHeaderAlign;
dividers?: boolean;
uppercase?: boolean;
}Файлdist/types/components/GrDropdownMenu/GrDropdownMenuHeader.vue.d.ts
interface GrDropdownMenuHeaderProps {
title?: string;
align?: GrDropdownMenuHeaderAlign;
uppercase?: boolean;
}Файлdist/types/components/GrDropdownMenu/GrDropdownMenuItem.vue.d.ts
Роль пункта. menuitemcheckbox/menuitemradio — пункты-переключатели: они
обязаны нести aria-checked, иначе AT прочитает их как обычные команды.
type GrDropdownMenuItemRole = 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';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
role="none" на обёртке обязателен: role="menu" у панели объявляет своих
потомков презентационными, и любой div между панелью и пунктом ломает
aria-required-children, а пункты теряют принадлежность к меню.
interface GrDropdownMenuListProps {
dividers?: boolean;
borderTop?: boolean;
borderBottom?: boolean;
}Файлdist/types/components/GrDropdownMenu/GrDropdownMenuSub.vue.d.ts
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
type GrDropdownMenuSubInstance = ComponentExposed<typeof GrDropdownMenuSubComponent>;Доступность
Паттерн APG
menu
Клавиши
SpaceEscTab
| Клавиша | Действие |
|---|---|
| — | то же, что у GrDropdown: пункты не табируемы (tabindex="-1"), фокус водят стрелки. Выключенные пункты из обхода **не** выпадают — aria-disabled вместо нативного disabled. Пункт с подменю: → |
| — | Enter |
| Space | раскрыть и встать на первый пункт; внутри подменю ← — закрыть его и вернуться на раскрыватель |
| Esc | закрыть **только** верхний уровень (он верхний в стеке слоёв) |
| Tab | закрыть меню целиком |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Роли: почему их нельзя пропустить
Панель 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 прочитает пункт как
обычную команду. Место под отметку занято всегда, когда пункт переключаемый:
иначе строки «включено» и «выключено» разъезжаются по горизонтали.
<GrDropdownMenuItem role="menuitemcheckbox" :checked="showArchived" @click="toggle">
Показывать архив
</GrDropdownMenuItem>Иконка и сочетание клавиш задаются пропами icon / shortcut либо слотами
#icon / #shortcut (слот сильнее).
Меню из модели
Композиция остаётся для нестандартных пунктов: GrDropdownMenuList — обёртка
списка, GrDropdownMenuGroup и GrDropdownMenuHeader — раздел с заголовком,
GrDropdownMenuItem и GrDropdownMenuDivider — пункт и разделитель,
GrDropdownMenuColumns с GrDropdownMenuColumn — раскладка в колонки. Все они
приезжают из того же subpath, что и меню.
Но девять меню из десяти однотипны — их проще описать массивом:
<GrDropdownMenu :items="items" @select="onSelect" />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, и потребителю приходилось переписывать обход
модели целиком ради одной ссылки.
const items: GrDropdownMenuEntry[] = [
{ key: 'profile', label: 'Профиль', href: '/profile', as: RouterLink },
]Второй уровень: подменю
Пункт с непустыми children становится раскрывателем: он ничего не выполняет и
select по нему не приходит — он открывает панель следующего уровня. В
композиции то же самое даёт GrDropdownMenuSub.
const items: GrDropdownMenuEntry[] = [
{ key: 'rename', label: 'Переименовать' },
{ key: 'export', label: 'Экспорт', children: [
{ key: 'pdf', label: 'PDF' },
{ key: 'csv', label: 'CSV' },
] },
]<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.
Пропы этого примера