ContextMenu
A menu that opens at the pointer on right click, or on a long press on a touch screen, with the items of a Dropdown.
src/components/button
- button.styles.ts
- button.test.tsx
- button.tsx
- index.ts
- Right-click a file: the menu opens where the pointer is. On a touch screen, press and hold the file.
- The file stays marked while its menu is open; arrows, a typed letter and Enter work as in a Dropdown.
- Right-click another file while a menu is open: the menu moves to it.
Field marks
- Import
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from "cennge-ui";- Size
- 1.5 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA menu, opened by the
contextmenuevent - Keyboard
- Arrow down Arrow up HomeEndEnter Esc
- State
- Uncontrolled;
onOpenChangereports each change - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Touch
- Opens on a long press of 700 ms
- Turned off
isDisabledon the trigger leaves the browser's own menu- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: overlays
When to use#
A ContextMenu puts actions where the pointer already is: on a file, a row, a picture. It suits tools people use every day and expect to behave like desktop apps.
Treat it as a shortcut, never the only way to an action. Nothing on the page shows that a right click does something, and not everyone can right-click or long-press.
Similar#
- Dropdown has the same items and opens from a button that everyone can see; give the actions of a ContextMenu a Dropdown as well.
- Command finds any action in the app by typing; a ContextMenu offers the few that fit the thing under the pointer.
Right-click an area#
ContextMenuTrigger hands the right click, and the long press on touch screens, to the one element inside it. The items are the Dropdown's: shortcut hints, a disabled item, a destructive one in red.
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, toast } from "cennge-ui";
export default function ScreenshotMenu() {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className="grid aspect-video w-full max-w-80 place-items-center rounded-large border-2 border-dashed border-border text-sm text-muted select-none">
screenshot-dark.png
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem shortcut="F2" onAction={() => toast("Renaming screenshot-dark.png")}>
Rename
</ContextMenuItem>
<ContextMenuItem onAction={() => toast("Set as the README image")}>Use in README</ContextMenuItem>
<ContextMenuItem isDisabled>Compare with previous</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem color="danger" onAction={() => toast.danger("Deleted screenshot-dark.png")}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}The same actions from a button#
One list of actions can render both menus: a Dropdown on the row for everyone, and a ContextMenu around it for people who right-click.
import {
Button,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
toast,
} from "cennge-ui";
const deploy = "payload-cache · main · 4f2c9e1";
const actions = [
{ label: "Redeploy", run: () => toast("Redeploying 4f2c9e1") },
{ label: "View build log", run: () => toast("Opening the build log") },
{ label: "Roll back to this deploy", run: () => toast.warning("Rolled back to 4f2c9e1") },
];
export default function DeployRow() {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className="flex w-full max-w-md items-center gap-3 rounded-medium bg-surface-3 py-2 pr-2 pl-4 text-sm">
<span className="size-2 shrink-0 rounded-full bg-success" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{deploy}</span>
<Dropdown>
<DropdownTrigger>
<Button size="sm" variant="light">
Actions
</Button>
</DropdownTrigger>
<DropdownContent align="end">
{actions.map((action) => (
<DropdownItem key={action.label} onAction={action.run}>
{action.label}
</DropdownItem>
))}
</DropdownContent>
</Dropdown>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{actions.map((action) => (
<ContextMenuItem key={action.label} onAction={action.run}>
{action.label}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
);
}Let the browser's menu through#
With isDisabled the trigger stops taking the right click and the browser's own menu opens. Here that happens while part of the commit message is selected, so the browser's Copy and Search are there for it; with nothing selected, the right click opens the commit's actions.
4baadf3fix(playground): read the repositories' update dates in UTC
import { useEffect, useRef, useState } from "react";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, toast } from "cennge-ui";
const commit = { hash: "4baadf3", message: "fix(playground): read the repositories' update dates in UTC" };
async function copyHash() {
try {
await navigator.clipboard.writeText(commit.hash);
toast.success(`Copied ${commit.hash}`);
} catch {
toast.danger("Could not copy", { description: commit.hash });
}
}
export default function CommitRow() {
const message = useRef<HTMLParagraphElement>(null);
const [hasSelection, setHasSelection] = useState(false);
// Text selected in the message: right click opens the browser's menu, with Copy and Search.
useEffect(() => {
const update = () => {
const selection = document.getSelection();
setHasSelection(Boolean(selection && !selection.isCollapsed && message.current?.contains(selection.anchorNode)));
};
document.addEventListener("selectionchange", update);
return () => document.removeEventListener("selectionchange", update);
}, []);
return (
<ContextMenu>
<ContextMenuTrigger isDisabled={hasSelection}>
<div className="flex w-full max-w-md items-baseline gap-3 rounded-medium bg-surface-3 px-4 py-3 text-sm">
<code className="shrink-0 font-mono text-primary-text">{commit.hash}</code>
<p ref={message} className="min-w-0">
{commit.message}
</p>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onAction={() => toast(`Checked out ${commit.hash}`)}>Check out</ContextMenuItem>
<ContextMenuItem onAction={() => toast(`Reverting ${commit.hash}`)}>Revert</ContextMenuItem>
<ContextMenuItem onAction={copyHash}>Copy hash</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}Accessibility#
The trigger gets no role and no ARIA attribute, so nothing tells a screen reader that an area has a menu; that is why the same actions need a visible way in. Once open, the menu and its items have the roles menu and menuitem and the keyboard of a Dropdown, and Escape closes it. While it is open, the rest of the page is hidden from screen readers and ignores the pointer, unless isModal is false.
Styling#
While its menu is open, the trigger's element has data-state="open". Style it to keep the thing marked, as the files on the plate do with data-[state=open]:bg-primary-bg. The menu itself looks like a Dropdown's: at least 13rem wide, and scrolling when it is taller than the room in the window.
Props#
ContextMenu#
ContextMenuTrigger#
ContextMenuContent#
Also takes every attribute of <div>.