Context menu

The right-click menu, with the behaviour a desktop one has - Copy, Cut and Paste built from what was clicked, submenus that survive a diagonal pointer, shortcuts on the right, a filter when a level is long, and rows fetched on demand.

A right-click menu is the one surface people already know how to use, which is exactly why a half-built one is so obvious: the submenu that closes while you are reaching for it, the row chosen by the release of the press that opened the menu, the shortcut printed for the wrong platform, the panel that opens off the bottom of the window.

Usage

import { ContextMenu } from "@enigmax/primitives/react/context-menu";

<ContextMenu
    title={file.name}
    items={[
        { id: "open", label: "Open", shortcut: "Enter", icon: <ArrowRight /> },
        { id: "rename", label: "Rename", shortcut: "F2" },
        { type: "separator" },
        { id: "delete", label: "Delete", shortcut: "Delete", destructive: true }
    ]}
    onSelect={(item) => run(item.id)}
>
    <FileRow file={file} />
</ContextMenu>

The children become the area a right-click opens the menu over. Everything it renders is a part, so any of them can be yours instead - a “…” button that opens the menu at its own corner is ContextMenu.Root with your own trigger:

<ContextMenu.Root items={rows} onSelect={run}>
    <MoreButton />
    <ContextMenu.Content />
</ContextMenu.Root>

A row

id Unique among its siblings. The path down the tree is built from these
label / description The line, and a second one under it
icon Any node
shortcut "Mod+C", "F2", "Delete" - written for the reader’s platform
disabled Listed, announced, never highlighted and never invoked
destructive The destructive colour, for a row that deletes something
checked / group A tick, or a radio dot when the rows share a group
items / loadItems A submenu, known up front or fetched
keywords Extra words the filter should match

{ type: "separator" } is a rule between two blocks and { type: "label", label: "..." } is a caption over one. Neither is reachable by the keyboard, and neither survives a filter that emptied the block it belonged to - a rule with nothing on either side of it is noise.

Nothing to show means nothing opens

A menu built from an empty list does not appear, and the press is left to the browser, whose own menu is more use than an empty box. That is the same rule as items being computed from what is selected: with nothing selected, there is nothing to offer.

<ContextMenu items={selection.length > 0 ? rows : []}>{list}</ContextMenu>

Copy, Cut and Paste

A custom menu REPLACES the browser’s, and the browser’s has these three. So they are on by default here rather than opt-in: a menu over a text field that cannot copy is not a smaller menu, it is a loss nobody decides on and the visitor discovers.

Which of them appear is read from what was right-clicked, at the moment of the press:

  • Copy, where there is selected text under the pointer.
  • Cut, where that selection is also in something writable.
  • Paste, in anything writable, and greyed rather than dropped when the clipboard is known to be empty - a row that disappears between two opens reads as an unreliable menu.

They go in front of your rows with a separator after them, because they act on the selection rather than on the thing the menu was opened over. Their ids are namespaced (enigma:clipboard:copy), so they cannot collide with yours, and they still reach onSelect after being performed, for a caller that wants to log or undo the edit.

<ContextMenu items={rows} clipboard={false} />                      {/* none of them */}
<ContextMenu items={rows} clipboard={{ cut: false }} />             {/* copy and paste */}
<ContextMenu items={rows} clipboard={{ labels: { copy: "Copiar" } }} />

{ copy, cut, paste, labels, icons } is the whole object: turn one off, rename them, or draw them with your own icons.

Three behaviours worth knowing, because each is a defect somewhere else:

  • A password field is pasted into and never copied out of. The clipboard is shared with every application on the machine and is not cleared; the browser’s own menu refuses too.
  • A selection elsewhere on the page does not count. The range has to intersect what was clicked, or a right-click away from a selection offers to copy something the visitor is not pointing at.
  • Ctrl+Z after a paste undoes the paste. Insertion goes through the browser’s own editing command first, which is the only path that joins its undo stack; the fallback writes through the element’s own setter and fires an input event, so a controlled React field sees it.

Whether the clipboard has anything in it is usually unknowable: reading it needs permission, and asking would put a browser prompt on screen just to decide how to draw a row. So the permission is queried and never requested. Where it is already granted, Paste greys out on an empty clipboard; everywhere else the row stays enabled and a paste with nothing to paste does nothing.

The heading

title names what the rows will happen to. A menu opened at the pointer is the one surface with no context around it, so without it the rows are the only clue - and “Delete” over a selection of twelve reads exactly like “Delete” over one file.

<ContextMenu title={picked.length > 1 ? `${picked.length} items selected` : file.name} ... />

A submenu can carry its own with title on the row that opens it.

They open after a beat of hovering and close after a longer one, and that second number is the one that matters: the way OUT of a submenu passes over its siblings, so a menu that closes on the first crossing cannot be reached diagonally at all. openDelay and closeDelay change them if your design genuinely needs otherwise.

A branch that has to be fetched is loadItems, and what it returns is cached under that row’s path:

{ id: "tags", label: "Tags", loadItems: () => fetch(`/files/${id}/tags`).then((r) => r.json()) }

The level says it is loading rather than looking empty, says what went wrong rather than looking empty, and on the second open shows the rows immediately - which is the whole reason a slow branch is bearable. cacheMs sets how long (five minutes by default, 0 to refetch every time) and instance.invalidate(path) drops one branch after a mutation.

Long levels

A submenu that lists files, branches, tags or devices is not short. Past twelve rows a level grows its own filter, matching the label, the description, the group and any keywords, fuzzy if you hand over Fuse’s constructor:

<ContextMenu items={rows} fuse={Fuse} />

Twelve rather than a smaller number because a menu you can read at a glance does not want one: a field there is chrome, a focus stop and a taller panel in exchange for nothing. Nine rows is still a shape; twelve is where it starts being a list.

It is only the default. searchable decides it outright - on the menu for its root level, on a row for the submenu under it - which is what a level filling itself from somewhere needs, since it has no rows to grow into a filter with:

<ContextMenu items={rows} searchable />            {/* whatever the count */}
<ContextMenu items={rows} searchable={false} />    {/* never */}

The rows render a window at a time, forty by default, growing as the level is scrolled and immediately towards wherever the highlight is heading. chunk={Infinity} renders the lot.

What it gives back

Opening Right-click at the pointer, a long press on a touch screen, Shift+F10 or the Menu key
Keyboard Arrows wrap, Home/End, Right opens a submenu and Left goes back, Enter chooses, typing jumps to a row
Escape Closes one level at a time, and hands focus back to the trigger
The press that opened it Does not also choose the row that ended up underneath it
Placement Flips left and up rather than opening off the screen, and closes on a scroll
Screen readers role="menu", menuitemcheckbox and menuitemradio where they apply, aria-keyshortcuts, the heading as the panel’s name
Alignment One checkable row reserves the tick column for the whole level, so the labels line up rather than the ticked one sitting a column to the right
Stacking Above a dialog it was opened from - a menu is the topmost transient surface, and --enigma-menu-z says so

Shortcuts

shortcut is written once, platform-neutrally, and printed the way the reader’s platform prints it: Mod+C is ⌘C on a Mac and Ctrl+C everywhere else. The same spec goes to the selection list, which is what makes the menu print what the list actually listens for.

Printing a shortcut does not bind it. The menu draws the label; the key press belongs to whatever owns the keyboard - the selection list, or your own handler.

Installation

Adds the package and prints the import to use. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.

enigma add context-menu

Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.

--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.

Prop
items The rows. An empty list opens no menu
title A heading naming what the rows act on
searchable The root’s filter: "auto" past twelve rows, or true / false outright
onSelect (item, path) - the row, and the ids from the root down to it
disabled Nothing opens, and the press is left to the browser
clipboard Copy, Cut and Paste. On by default; false drops them, an object tunes them
openDelay / closeDelay The hover timings, in ms
fuse / fuseOptions / matcher / searchKeys The filter, exactly as the search component takes them
cacheMs How long a fetched submenu stays cached
chunk Rows in the document at once
renderItem Draw a row yourself
longPress Open on a long press as well. On by default
loadingLabel / emptyLabel What a fetched level says while it waits, and if it came back empty
styles false leaves the markup bare for a theme of your own

Styling hooks: [data-enigma-menu-panel], [data-enigma-menu-title], [data-enigma-menu-item] with [data-active], [data-disabled], [data-destructive] and [data-submenu], [data-enigma-menu-shortcut], [data-enigma-menu-arrow], [data-enigma-menu-check], [data-enigma-menu-separator], [data-enigma-menu-label], [data-enigma-menu-status].