GrContextMenu
Brings actions to the pointer: right-click a row, a tree node or a canvas.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- actions over a row of a list or a node of a tree — a right click where the user is already looking, instead of a trip to the ”⋯” button at the end of the row;
- quick operations on the selection — rename, duplicate, delete: the same items as in the toolbar, but without moving the mouse across the whole screen;
- actions over an area — a canvas, a map, the free space of a list: there is nothing to click, and a menu at the cursor remains the only place for “Paste”;
- a menu that depends on what was clicked —
beforeOpengives the target away before the opening, anditemshave time to be assembled for the particular row.
When to take something else
| Need | Take |
|---|---|
| The menu hangs on a visible button or ”⋯” | GrDropdownMenu |
| The items are non-standard, you write the markup | GrDropdown |
| There is a form, a filter or a palette inside — not a menu | GrPopover |
| A search across the commands of the whole application | GrCommandPalette |
| A confirmation of a dangerous action | GrConfirmDialog |
The items are assembled for the target rather than set once and for all
beforeOpen arrives before the opening, and it is the only moment at which the menu can be assembled for whatever
was clicked: a folder has one set of actions, a file another. No separate way to cancel the opening is needed — an empty
model simply will not open.
<GrContextMenu :items="items" @before-open="onBeforeOpen" @select="onSelect">
<GrTree :data="data" node-key="id"/>
</GrContextMenu>
function onBeforeOpen(context: GrContextMenuOpenContext): void {
// The target is taken from the DOM rather than from the mouse event: the same
// code serves Shift+F10, which has no mouse event at all.
const row = context.target?.closest('[data-gr-tree-node-key]')
items.value = row ? itemsFor(row.dataset.grTreeNodeKey!) : []
}
The props reach the component on the next render, so the menu opens one tick after the call — otherwise it would show the items of the previous target.
Two ways in, and they do not duplicate each other
The wrapper — the content of the default slot. It catches the right click and, more importantly, the keyboard:
keydown bubbles from the focused element, and only an ancestor can catch it.
openAt(event) through a ref — when the target is not derived from the DOM: the event came from something other than
a pointer, the coordinates are computed by the page itself (a canvas, a map), or the menu belongs to an object that is
not in the markup.
<GrContextMenu ref="menu" trigger="manual" :items="items" @select="onSelect">
<canvas @contextmenu.prevent="onCanvasMenu"/>
</GrContextMenu>
trigger="manual" switches off opening by pointer only. The keyboard path always stays — otherwise the menu would be
unavailable from the keyboard, and that handler would have to be written again on every page.
The keyboard is an acceptance condition, not a polish
A menu available by right click alone does not exist for the keyboard. The wrapper listens for Shift+F10 and the
ContextMenu key, and the anchor becomes the rectangle of the focused element rather than a point: the menu belongs
to the row and flips together with it when there is not enough room below.
Inside an input field the call is not intercepted — the native menu with spell checking and the clipboard is more useful there.
The rows a menu opens over are worth making focusable — otherwise there is nowhere to return the focus after closing,
and Tab will start from the beginning of the document. GrTree already meets that condition.
`Shift`+right click is given to the browser
The native menu is not an obstacle but a tool: translating the page, viewing the source, saving an image.
Shift+right click is passed through to the browser (in Firefox that is a documented way), and it is switched off with
the allowNativeMenu prop.
Ctrl is deliberately not part of the escape hatch: on macOS Ctrl+click is the right click, and the menu would stop
opening for part of the users.
Limits
- there are no nested submenus. The model of the items is flat — the same as in
GrDropdownMenu. A two-level menu requires navigation of its own (ArrowRightopens,ArrowLeftcloses) and a safe triangle for the mouse; that is a separate component, not a prop; - a long menu at the bottom edge of the screen is shifted rather than shrunk with a scroll. Limiting the height of a panel is not implemented for any anchored layer in the package;
- the component does not emulate a long press on a touch screen.
contextmenuon a long press is sent by Android Chrome and is not sent by iOS Safari, and an emulation of your own conflicts with scrolling and text selection. Duplicate the actions with a visible button; - an empty menu does not open. A panel without a single item has nothing to focus, and it would become a trap with
only
Escfor a way out.
Playground 10
Loading…
<GrContextMenu />Install
npm i @feugene/granularityImport
import { GrContextMenu } from '@feugene/granularity/components/GrContextMenu'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
open | boolean | undefined | undefined | Whether the menu is open. Without the prop the component runs the state itself. |
disabled | boolean | undefined | false | — |
ariaLabel | string | undefined | undefined | — |
placement | Placement | undefined | "bottom-start" | — |
items | GrDropdownMenuEntry[] | undefined | undefined | A declarative menu. The `#content` slot is stronger. |
trigger | GrContextMenuTrigger | undefined | "contextmenu" | — |
offsetPx | number | undefined | 0 | The gap from the anchor. Zero by default: the menu sticks to the cursor. |
minWidth | string | number | undefined | "11rem" | A menu made of short words must not be one word wide. |
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 | Close on scrolling of the page. The anchor is a point of the viewport, and the panel would keep hanging on the screen while the content under it moves away. Native menus behave the same way. |
allowNativeMenu | boolean | undefined | true | Pass `Shift`+right click through to the browser. |
Slots
| Slot | Type | Description |
|---|---|---|
default | { open: boolean; } | The area the menu is called over. |
content | { close: () => void; } | The content of the menu. |
Events
| Event | Type | Description |
|---|---|---|
update:open | [value: boolean] | — |
select | [item: GrDropdownMenuAction] | — |
beforeOpen | [context: GrContextMenuOpenContext] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
openAt | (at: MouseEvent | GrFloatingAnchorRect) => void | — |
openAtElement | (element: Element) => void | — |
close | () => void | — |
toggle | () => void | — |
Examples 2
Tree
Правый клик по узлу — или 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>Area
Последнее действие: —
<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>Accessibility
- APG pattern
menu