GrContextMenu

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда действия над строкой списка или узлом дерева.

Когда брать

  • действия над строкой списка или узлом дерева — правый клик там, где пользователь уже смотрит, вместо поездки к кнопке «⋯» в конце строки;
  • быстрые операции над выделенным — переименовать, дублировать, удалить: те же пункты, что в тулбаре, но без движения мыши через весь экран;
  • действия над областью — холст, карта, свободное место списка: кликать некуда, и меню у курсора остаётся единственным местом для «Вставить»;
  • меню, зависящее от того, по чему кликнули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

PropTypeпо умолчаниюОписание
openboolean | undefinedundefinedОткрыто ли меню. Без пропа компонент ведёт состояние сам.
disabledboolean | undefinedfalse
ariaLabelstring | undefinedundefined
placementPlacement | undefined"bottom-start"
itemsGrDropdownMenuEntry[] | undefinedundefinedДекларативное меню. Слот `#content` сильнее.
triggerGrContextMenuTrigger | undefined"contextmenu"
offsetPxnumber | undefined0Зазор от якоря. Ноль по умолчанию: меню липнет к курсору.
minWidthstring | number | undefined"11rem"Меню из коротких слов не должно быть шириной в слово.
labelledBystring | undefinedundefined
teleportTostring | HTMLElement | undefinedundefined
contentClassstring | undefinedundefined
listClassstring | undefinedundefined
dividersboolean | undefinedfalse
closeOnScrollboolean | undefinedtrueЗакрывать при прокрутке страницы. Якорь — точка вьюпорта, и панель осталась бы висеть на экране, пока контент под ней уезжает. Так же ведут себя нативные меню.
allowNativeMenuboolean | undefinedtrueПропускать `Shift`+правый клик к браузеру.

Slots

SlotTypeОписание
default{ open: boolean; }Область, по которой вызывается меню.
content{ close: () => void; }Содержимое меню.

Events

EventTypeОписание
update:open[value: boolean]
select[item: GrDropdownMenuAction]
beforeOpen[context: GrContextMenuOpenContext]

Methods / Expose

Methods / ExposeTypeОписание
openAt(at: MouseEvent | GrFloatingAnchorRect) => void
openAtElement(element: Element) => void
close() => void
toggle() => void

Примеры 2

Меню под конкретный узел дерева

beforeOpen приходит до открытия, поэтому items успевают собраться под цель: у папки и файла действия разные. Цель берётся из DOM, а не из события мыши, — тот же код обслуживает и Shift+F10.

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>

Меню на области

Когда действия не зависят от того, по чему кликнули, хватает обёртки: она ловит и правый клик, и клавиатурный вызов. Shift+правый клик остаётся браузеру.

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

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

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>

Доступность

Паттерн APG
menu
Клавиши
Shift+F10 и клавиша ContextMenu на области — открыть у прямоугольника сфокусированного элемента (в поле ввода не перехватывается: там нативное меню полезнее); внутри панели — то же, что у GrDropdown, минус триггерная часть. Shift+правый клик отдаётся браузеру

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты