GrContextMenu

  • ядро
  • Слои
  • API · 15 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

  • действия над строкой списка или узлом дерева — правый клик там, где пользователь уже смотрит, вместо поездки к кнопке «⋯» в конце строки;
  • быстрые операции над выделенным — переименовать, дублировать, удалить: те же пункты, что в тулбаре, но без движения мыши через весь экран;
  • действия над областью — холст, карта, свободное место списка: кликать некуда, и меню у курсора остаётся единственным местом для «Вставить»;
  • меню, зависящее от того, по чему кликнули — beforeOpen отдаёт цель до открытия, и items успевают собраться под конкретную строку.

Когда взять другое

НужноБерите
Меню висит на видимой кнопке или «⋯»GrDropdownMenu
Пункты нестандартные, разметку пишете самиGrDropdown
Внутри форма, фильтр или палитра — не менюGrPopover
Поиск по командам всего приложенияGrCommandPalette
Подтверждение опасного действияGrConfirmDialog

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

  • Меню, собранное под то, по чему кликнули

    У папки и у файла разные действия. before-open приходит до открытия, и страница пересобирает items под строку под курсором — по DOM, поэтому Shift+F10 на строке в фокусе идёт тем же путём. В модели есть группа, подменю «Move to», сочетания и опасный пункт; у пустого места под списком своё меню.

    ContractsRight-click a row or the empty space · Shift+F10 on a focused row
    • Clients18 files
    • Suppliers7 files
    • Northwind MSA 2026.pdf412 KB · signed
    • Contoso NDA.pdf96 KB · awaiting signature
  • Слоты, для которых меню открывает страница

    Неделю рисует страница, поэтому меню она открывает сама — openAt(event) — и помнит слот; trigger="manual" выключает открытие обёрткой. Пустой слот предлагает «Book» и «Block», занятый — «Clear», а клавиатурный путь остаётся в обоих случаях.

    Lena Brandt · week 41Right-click a slot to book or block it
    MonTueWedThuFri09:00
     
     
     
     
     
    11:00
     
    Boiler service · Kalla 
     
     
     
    13:00
     
     
     
    Install · Orsa 
     
    15:00
     
     
     
     
     
  • Те же действия по правому клику и кнопке

    У сенсорного экрана нет правой кнопки, поэтому у каждой строки есть и «⋯»: openAtElement привязывает то же меню к ней, а не к курсору. «Pin to top» — menuitemcheckbox, и его checked следует за строкой.

    Sent invoicesRight-click a row, or use its «⋯» button
    Northwind Traders · pinnedINV-0418 · €1,284.50
    Tailspin ToysINV-0417 · €2,150.00
    Contoso LtdINV-0415 · €432.00

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
placementPlacement | undefined"bottom-start"Сторона раскрытия от точки вызова. По умолчанию `bottom-start` — вниз и вправо от курсора.
disabledboolean | undefinedfalseМеню не открывается ничем — ни мышью, ни клавиатурой, ни из кода.
openv-modelboolean | undefinedundefinedОткрыто ли меню. Без пропа компонент ведёт состояние сам.
ariaLabelstring | undefinedundefinedДоступное имя панели `role="menu"`. По умолчанию — переведённое «Context menu».
itemsGrDropdownMenuEntry[] | undefinedundefinedДекларативное меню. Слот `#content` сильнее.
triggerGrContextMenuTrigger | undefined"contextmenu"Чем открывается меню: `contextmenu` — правым кликом по области, `manual` — только из кода. `Shift+F10` и клавиша `ContextMenu` работают в обоих режимах. По умолчанию `contextmenu`.
offsetPxnumber | undefined0Зазор от якоря. Ноль по умолчанию: меню липнет к курсору.
minWidthstring | number | undefined"11rem"Меню из коротких слов не должно быть шириной в слово.
labelledBystring | undefinedundefined`id` видимого заголовка, который называет меню, — альтернатива `ariaLabel`.
teleportTostring | HTMLElement | undefinedundefinedТочечное переопределение точки монтирования; по умолчанию — общий портал оверлеев.
contentClassstring | undefinedundefinedДополнительные классы панели.
listClassstring | undefinedundefinedДополнительные классы списка пунктов внутри панели.
dividersboolean | undefinedfalseРазделители между пунктами.
closeOnScrollboolean | undefinedtrueЗакрывать при прокрутке страницы. Якорь — точка вьюпорта, и панель осталась бы висеть на экране, пока контент под ней уезжает. Так же ведут себя нативные меню.

Слоты

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

События

EventСигнатураОписание
update:open(event: "update:open", value: boolean): void—
select(event: "select", item: GrDropdownMenuAction): void—
beforeOpen(event: "beforeOpen", context: GrContextMenuOpenContext): void—

Методы

MethodsСигнатураОписание
openAt(at: MouseEvent | GrFloatingAnchorRect) => voidОткрыть у точки курсора (`MouseEvent`) или у произвольного прямоугольника вьюпорта.
openAtElement(element: Element) => voidОткрыть у прямоугольника элемента — клавиатурный путь для внешних композитов.
close() => voidЗакрыть меню из кода; `update:open` придёт с `false`.
toggle() => voidОткрыть закрытое меню или закрыть открытое.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrContextMenuTrigger, GrContextMenuSource, GrContextMenuOpenContext } from '@feugene/granularity/components/GrContextMenu'

Файлdist/types/components/GrContextMenu/GrContextMenu.vue.d.ts

GrContextMenuTriggertype

Чем открывается меню. manual — только императивно, из кода потребителя.

type GrContextMenuTrigger = 'contextmenu' | 'manual';
GrContextMenuSourcetype

Откуда пришло открытие: указатель, клавиатура или вызов из кода.

type GrContextMenuSource = 'pointer' | 'keyboard' | 'api';
GrContextMenuOpenContextinterface
interface GrContextMenuOpenContext {
    source: GrContextMenuSource;
    /** По чему кликнули или что было в фокусе — отсюда потребитель берёт строку. */
    target: HTMLElement | null;
    anchor: GrFloatingAnchorRect;
    event: MouseEvent | KeyboardEvent | null;
}
GrContextMenuPropsinterface
interface GrContextMenuProps {
    /** Декларативное меню. Слот `#content` сильнее. */
    items?: GrDropdownMenuEntry[];
    /** Открыто ли меню. Без пропа компонент ведёт состояние сам. */
    open?: boolean;
    /**
     * Чем открывается меню: `contextmenu` — правым кликом по области, `manual` — только из кода.
     * `Shift+F10` и клавиша `ContextMenu` работают в обоих режимах. По умолчанию `contextmenu`.
     */
    trigger?: GrContextMenuTrigger;
    /** Меню не открывается ничем — ни мышью, ни клавиатурой, ни из кода. */
    disabled?: boolean;
    /** Сторона раскрытия от точки вызова. По умолчанию `bottom-start` — вниз и вправо от курсора. */
    placement?: UseFloatingPlacement;
    /** Зазор от якоря. Ноль по умолчанию: меню липнет к курсору. */
    offsetPx?: number;
    /** Меню из коротких слов не должно быть шириной в слово. */
    minWidth?: number | string;
    /** Доступное имя панели `role="menu"`. По умолчанию — переведённое «Context menu». */
    ariaLabel?: string;
    /** `id` видимого заголовка, который называет меню, — альтернатива `ariaLabel`. */
    labelledBy?: string;
    /** Точечное переопределение точки монтирования; по умолчанию — общий портал оверлеев. */
    teleportTo?: string | HTMLElement;
    /** Дополнительные классы панели. */
    contentClass?: string;
    /** Дополнительные классы списка пунктов внутри панели. */
    listClass?: string;
    /** Разделители между пунктами. */
    dividers?: boolean;
    /**
     * Закрывать при прокрутке страницы. Якорь — точка вьюпорта, и панель осталась
     * бы висеть на экране, пока контент под ней уезжает. Так же ведут себя
     * нативные меню.
     */
    closeOnScroll?: boolean;
    /** Пропускать `Shift`+правый клик к браузеру. */
    allowNativeMenu?: boolean;
}
GrContextMenuEmitsinterface
interface GrContextMenuEmits {
    /** Меню открылось или закрылось (`v-model:open`). */
    (e: 'update:open', value: boolean): void;
    /**
     * Меню собирается открыться у этой цели. Приходит **до** открытия, чтобы
     * потребитель успел пересобрать `items` под неё: пункты для папки и для файла
     * разные, а узнать цель иначе неоткуда. Пустая модель после этого просто не
     * откроется — отдельный способ отменить открытие не нужен.
     */
    (e: 'beforeOpen', context: GrContextMenuOpenContext): void;
    /** Выбран пункт из `items`; пункт с подменю его не шлёт. После выбора меню закрывается. */
    (e: 'select', item: GrDropdownMenuAction): void;
}

Файлdist/types/components/GrContextMenu/config.d.ts

grContextMenuConfigconst
const grContextMenuConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrContextMenu">;

Файлdist/types/components/GrContextMenu/index.d.ts

GrContextMenuInstancetype
type GrContextMenuInstance = ComponentExposed<typeof GrContextMenuComponent>;

Доступность

Паттерн APG

menu

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

Использование из прозы библиотеки

Пункты собираются под цель, а не задаются раз и навсегда

beforeOpen приходит до открытия, и это единственный момент, когда меню можно собрать под то, по чему кликнули: у папки одни действия, у файла другие. Отдельного способа отменить открытие не нужно — пустая модель просто не откроется.

vue
<GrContextMenu :items="items" @before-open="onBeforeOpen" @select="onSelect">
  <GrTree :data="data" node-key="id"/>
</GrContextMenu>
ts
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: событие пришло не от указателя, координаты считает сама страница (холст, карта), или меню принадлежит объекту, которого в разметке нет.

vue
<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, поэтому и подменю здесь то же самое: пункт с непустыми children раскрывает следующий уровень, select по нему не приходит.

ts
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Переименовать' },
  { key: 'export', label: 'Экспорт', children: [{ key: 'pdf', label: 'PDF' }] },
]

Клавиатура, коридор для курсора и правила закрытия цепочки описаны там же — «Второй уровень: подменю». Здесь отличается только корень: выбор в подменю закрывает контекстное меню, а Escape на верхнем уровне возвращает к нижнему, не закрывая всё.

Границы
  • длинное меню у нижнего края экрана сдвигается, а не сжимается со скроллом. Ограничение высоты панели в пакете не реализовано ни у одного якорного слоя;
  • долгого нажатия на сенсорном экране компонент не эмулирует. contextmenu по long-press шлёт Android Chrome и не шлёт iOS Safari, а своя эмуляция конфликтует со скроллом и выделением текста. Дублируйте действия видимой кнопкой;
  • пустое меню не открывается. Панель без единого пункта нечем фокусировать, и она стала бы ловушкой, из которой есть только Esc.