GrContextMenu
Берут, когда действия над строкой списка или узлом дерева.
Когда брать
- действия над строкой списка или узлом дерева — правый клик там, где пользователь уже смотрит, вместо поездки к кнопке «⋯» в конце строки;
- быстрые операции над выделенным — переименовать, дублировать, удалить: те же пункты, что в тулбаре, но без движения мыши через весь экран;
- действия над областью — холст, карта, свободное место списка: кликать некуда, и меню у курсора остаётся единственным местом для «Вставить»;
- меню, зависящее от того, по чему кликнули —
beforeOpenотдаёт цель до открытия, иitemsуспевают собраться под конкретную строку.
Когда взять другое
| Нужно | Берите |
|---|---|
| Меню висит на видимой кнопке или «⋯» | GrDropdownMenu |
| Пункты нестандартные, разметку пишете сами | GrDropdown |
| Внутри форма, фильтр или палитра — не меню | GrPopover |
| Поиск по командам всего приложения | GrCommandPalette |
| Подтверждение опасного действия | GrConfirmDialog |
Пункты собираются под цель, а не задаются раз и навсегда
beforeOpen приходит до открытия, и это единственный момент, когда меню можно собрать под то, по чему кликнули: у
папки одни действия, у файла другие. Отдельного способа отменить открытие не нужно — пустая модель просто не откроется.
<GrContextMenu :items="items" @before-open="onBeforeOpen" @select="onSelect">
<GrTree :data="data" node-key="id"/>
</GrContextMenu>
function onBeforeOpen(context: GrContextMenuOpenContext): void {
// Цель берём из DOM, а не из события мыши: тот же код обслуживает Shift+F10,
// у которого события мыши нет вовсе.
const row = context.target?.closest('[data-gr-tree-node-key]')
items.value = row ? itemsFor(row.dataset.grTreeNodeKey!) : []
}
Пропы доезжают до компонента следующим рендером, поэтому меню открывается на тик позже вызова — иначе оно показывало бы пункты от предыдущей цели.
Две точки входа, и они не дублируют друг друга
Обёртка — содержимое слота по умолчанию. Она ловит правый клик и, что важнее, клавиатуру: keydown всплывает от
сфокусированного элемента, и поймать его может только предок.
openAt(event) через ref — когда цель не выводится из DOM: событие пришло не от указателя, координаты считает сама
страница (холст, карта), или меню принадлежит объекту, которого в разметке нет.
<GrContextMenu ref="menu" trigger="manual" :items="items" @select="onSelect">
<canvas @contextmenu.prevent="onCanvasMenu"/>
</GrContextMenu>
trigger="manual" выключает только открытие указателем. Клавиатурный путь остаётся всегда — иначе меню было бы
недоступно с клавиатуры, и этот обработчик пришлось бы писать заново на каждой странице.
Клавиатура — условие приёмки, а не полировка
Меню, доступное только правым кликом, для клавиатуры не существует. Обёртка слушает Shift+F10 и клавишу ContextMenu,
и якорем становится прямоугольник сфокусированного элемента, а не точка: меню принадлежит строке и переворачивается
вместе с ней, когда снизу не хватает места.
Внутри поля ввода вызов не перехватывается — там нативное меню с проверкой орфографии и буфером обмена полезнее.
Строки, по которым открывается меню, стоит делать фокусируемыми — иначе после закрытия фокус возвращать некуда, и
Tab пойдёт с начала документа. GrTree это условие уже выполняет.
`Shift`+правый клик отдан браузеру
Нативное меню — не помеха, а инструмент: перевод страницы, просмотр кода, сохранение картинки. Shift+правый клик
пропускается к браузеру (в Firefox это задокументированный способ), выключается пропом allowNativeMenu.
Ctrl в escape-hatch не входит намеренно: на macOS Ctrl+клик и есть правый клик, и меню перестало бы открываться у
части пользователей.
Прокрутка закрывает меню
Якорь — точка вьюпорта, у которой нет элемента, поэтому при прокрутке панель осталась бы висеть на экране, пока контент
под ней уезжает. Так же ведут себя нативные меню; поведение выключается пропом closeOnScroll.
Границы
- вложенных подменю нет. Модель пунктов плоская — та же, что у
GrDropdownMenu. Меню на два уровня требует своей навигации (ArrowRightоткрывает,ArrowLeftзакрывает) и safe-triangle для мыши; это отдельный компонент, а не проп; - длинное меню у нижнего края экрана сдвигается, а не сжимается со скроллом. Ограничение высоты панели в пакете не реализовано ни у одного якорного слоя;
- долгого нажатия на сенсорном экране компонент не эмулирует.
contextmenuпо long-press шлёт Android Chrome и не шлёт iOS Safari, а своя эмуляция конфликтует со скроллом и выделением текста. Дублируйте действия видимой кнопкой; - пустое меню не открывается. Панель без единого пункта нечем фокусировать, и она стала бы ловушкой, из которой есть
только
Esc.
Playground 10
Загружается…
<GrContextMenu />Установка
npm i @feugene/granularityИмпорт
import { GrContextMenu } from '@feugene/granularity/components/GrContextMenu'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
open | boolean | undefined | undefined | Открыто ли меню. Без пропа компонент ведёт состояние сам. |
disabled | boolean | undefined | false | — |
ariaLabel | string | undefined | undefined | — |
placement | Placement | undefined | "bottom-start" | — |
items | GrDropdownMenuEntry[] | undefined | undefined | Декларативное меню. Слот `#content` сильнее. |
trigger | GrContextMenuTrigger | undefined | "contextmenu" | — |
offsetPx | number | undefined | 0 | Зазор от якоря. Ноль по умолчанию: меню липнет к курсору. |
minWidth | string | number | undefined | "11rem" | Меню из коротких слов не должно быть шириной в слово. |
labelledBy | string | undefined | undefined | — |
teleportTo | string | HTMLElement | undefined | undefined | — |
contentClass | string | undefined | undefined | — |
listClass | string | undefined | undefined | — |
dividers | boolean | undefined | false | — |
closeOnScroll | boolean | undefined | true | Закрывать при прокрутке страницы. Якорь — точка вьюпорта, и панель осталась бы висеть на экране, пока контент под ней уезжает. Так же ведут себя нативные меню. |
allowNativeMenu | boolean | undefined | true | Пропускать `Shift`+правый клик к браузеру. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | { open: boolean; } | Область, по которой вызывается меню. |
content | { close: () => void; } | Содержимое меню. |
Events
| Event | Type | Описание |
|---|---|---|
update:open | [value: boolean] | — |
select | [item: GrDropdownMenuAction] | — |
beforeOpen | [context: GrContextMenuOpenContext] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
openAt | (at: MouseEvent | GrFloatingAnchorRect) => void | — |
openAtElement | (element: Element) => void | — |
close | () => void | — |
toggle | () => void | — |
Примеры 2
Меню под конкретный узел дерева
beforeOpen приходит до открытия, поэтому items успевают собраться под цель: у папки и файла действия разные. Цель берётся из DOM, а не из события мыши, — тот же код обслуживает и Shift+F10.
Правый клик по узлу — или Shift + F10 с клавиатуры. Последнее действие: —
<script setup lang="ts">
import { ref } from 'vue'
import type { GrContextMenuOpenContext, GrDropdownMenuAction, GrDropdownMenuEntry } from '@feugene/granularity'
import { GrCard, GrContextMenu, GrTree } from '@feugene/granularity'
type Node = {
id: string
label: string
kind: 'folder' | 'file'
children?: Node[]
}
const data: Node[] = [
{
id: 'reports',
label: 'Reports',
kind: 'folder',
children: [
{ id: 'q1', label: 'Q1 revenue.xlsx', kind: 'file' },
{ id: 'q2', label: 'Q2 revenue.xlsx', kind: 'file' },
],
},
{
id: 'contracts',
label: 'Contracts',
kind: 'folder',
children: [
{ id: 'acme', label: 'Acme Inc.pdf', kind: 'file' },
{ id: 'globex', label: 'Globex.pdf', kind: 'file' },
],
},
]
const index = new Map<string, Node>()
for (const node of data) {
index.set(node.id, node)
for (const child of node.children ?? []) index.set(child.id, child)
}
const current = ref<Node | null>(null)
const items = ref<GrDropdownMenuEntry[]>([])
const lastAction = ref('—')
function itemsFor(node: Node): GrDropdownMenuEntry[] {
return [
{ key: 'open', label: node.kind === 'folder' ? 'Открыть папку' : 'Открыть файл' },
{ key: 'rename', label: 'Переименовать', shortcut: 'F2' },
...(node.kind === 'file' ? [{ key: 'download', label: 'Скачать' }] : []),
{ type: 'divider' as const },
{ key: 'delete', label: 'Удалить', variant: 'danger' as const, shortcut: '⌫' },
]
}
/**
* Пункты собираются под цель до открытия — у папки и файла действия разные.
* Цель берём из DOM, а не из события мыши: тогда тот же код обслуживает и
* Shift+F10, у которого события мыши нет вовсе. Клик мимо узла оставляет
* пункты пустыми, и меню просто не открывается.
*/
function onBeforeOpen(context: GrContextMenuOpenContext): void {
const row = context.target?.closest<HTMLElement>('[data-gr-tree-node-key]')
const node = row ? index.get(row.dataset.grTreeNodeKey ?? '') : undefined
current.value = node ?? null
items.value = node ? itemsFor(node) : []
}
function onSelect(item: GrDropdownMenuAction): void {
lastAction.value = `${item.label}: ${current.value?.label ?? '—'}`
}
</script>
<template>
<GrCard class="grid gap-4 p-5">
<GrContextMenu :items="items" @before-open="onBeforeOpen" @select="onSelect">
<GrTree :data="data" node-key="id" :default-expanded-keys="['reports', 'contracts']" />
</GrContextMenu>
<p class="text-sm text-[var(--gr-muted-fg)]">
Правый клик по узлу — или <kbd>Shift</kbd> + <kbd>F10</kbd> с клавиатуры.
Последнее действие: <strong>{{ lastAction }}</strong>
</p>
</GrCard>
</template>Меню на области
Когда действия не зависят от того, по чему кликнули, хватает обёртки: она ловит и правый клик, и клавиатурный вызов. Shift+правый клик остаётся браузеру.
Последнее действие: —
<script setup lang="ts">
import { ref } from 'vue'
import type { GrDropdownMenuAction, GrDropdownMenuEntry } from '@feugene/granularity'
import { GrCard, GrContextMenu } from '@feugene/granularity'
const items: GrDropdownMenuEntry[] = [
{ key: 'paste', label: 'Вставить', shortcut: '⌘V' },
{ key: 'select-all', label: 'Выделить всё', shortcut: '⌘A' },
{ type: 'divider' },
{
type: 'group',
title: 'Вид',
items: [
{ key: 'grid', label: 'Сеткой' },
{ key: 'list', label: 'Списком' },
],
},
{ type: 'divider' },
{ key: 'clear', label: 'Очистить холст', variant: 'danger' },
]
const lastAction = ref('—')
function onSelect(item: GrDropdownMenuAction): void {
lastAction.value = item.label
}
</script>
<template>
<GrCard class="grid gap-4 p-5">
<!--
Обёртки достаточно, когда действия не зависят от того, по чему кликнули:
она же приносит клавиатурный вызов, который иначе пришлось бы писать руками.
-->
<GrContextMenu :items="items" @select="onSelect">
<div
tabindex="0"
class="grid h-40 place-items-center rounded-[var(--gr-radius-lg)] border border-dashed border-[var(--gr-brd)] text-sm text-[var(--gr-muted-fg)]"
>
Правый клик по этой области
</div>
</GrContextMenu>
<p class="text-sm text-[var(--gr-muted-fg)]">
Последнее действие: <strong>{{ lastAction }}</strong>
</p>
</GrCard>
</template>Доступность
- Паттерн APG
menu- Клавиши
Shift+F10и клавишаContextMenuна области — открыть у прямоугольника сфокусированного элемента (в поле ввода не перехватывается: там нативное меню полезнее); внутри панели — то же, что уGrDropdown, минус триггерная часть.Shift+правый клик отдаётся браузеру