GrContextMenu

Package: @feugene/granularitycoreGroup: overlays

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 clickedbeforeOpen gives the target away before the opening, and items have time to be assembled for the particular row.

When to take something else

NeedTake
The menu hangs on a visible button or ”⋯”GrDropdownMenu
The items are non-standard, you write the markupGrDropdown
There is a form, a filter or a palette inside — not a menuGrPopover
A search across the commands of the whole applicationGrCommandPalette
A confirmation of a dangerous actionGrConfirmDialog

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.

Scrolling closes the menu

The anchor is a point of the viewport that has no element, so on scrolling the panel would keep hanging on the screen while the content under it moves away. Native menus behave the same way; the behaviour is switched off with the closeOnScroll prop.

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 (ArrowRight opens, ArrowLeft closes) 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. contextmenu on 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 Esc for a way out.

Playground 10

Loading…

Code
<GrContextMenu />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
openboolean | undefinedundefinedWhether the menu is open. Without the prop the component runs the state itself.
disabledboolean | undefinedfalse
ariaLabelstring | undefinedundefined
placementPlacement | undefined"bottom-start"
itemsGrDropdownMenuEntry[] | undefinedundefinedA declarative menu. The `#content` slot is stronger.
triggerGrContextMenuTrigger | undefined"contextmenu"
offsetPxnumber | undefined0The gap from the anchor. Zero by default: the menu sticks to the cursor.
minWidthstring | number | undefined"11rem"A menu made of short words must not be one word wide.
labelledBystring | undefinedundefined
teleportTostring | HTMLElement | undefinedundefined
contentClassstring | undefinedundefined
listClassstring | undefinedundefined
dividersboolean | undefinedfalse
closeOnScrollboolean | undefinedtrueClose 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.
allowNativeMenuboolean | undefinedtruePass `Shift`+right click through to the browser.

Slots

SlotTypeDescription
default{ open: boolean; }The area the menu is called over.
content{ close: () => void; }The content of the menu.

Events

EventTypeDescription
update:open[value: boolean]
select[item: GrDropdownMenuAction]
beforeOpen[context: GrContextMenuOpenContext]

Methods / Expose

Methods / ExposeTypeDescription
openAt(at: MouseEvent | GrFloatingAnchorRect) => void
openAtElement(element: Element) => void
close() => void
toggle() => void

Examples 2

Tree

Reports
Q1 revenue.xlsx
Q2 revenue.xlsx
Contracts
Acme Inc.pdf
Globex.pdf

Правый клик по узлу — или Shift + F10 с клавиатуры. Последнее действие:

Tree
<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

Правый клик по этой области

Последнее действие:

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

Full keyboard contract of the package

Component documentationAll components