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
inputevent, 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.
Submenus
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-menuInstalls 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.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react/context-menu.
The theme comes with it. <ContextMenu.Root> injects its stylesheet once and prepends it to
<head>, so anything your page already has outranks it on source order - a popup with no
styles is transparent text lying over whatever it was opened above, which is broken rather
than merely plain. styles={false} turns it off, and @enigmax/primitives/context-menu.css
is the same sheet if you would rather import it.
Every colour and distance is a custom property on :root:
:root {
--enigma-menu-bg: #12161f;
--enigma-menu-active-bg: #1a1f2b;
--enigma-menu-danger: #d7875f;
--enigma-menu-radius: 10px;
--enigma-menu-min-width: 200px;
--enigma-menu-z: 10000;
}| 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].