A menu that opens at a point, for a right-click on something that is not a button: a row, a canvas, a bar in a schedule (also called a right-click menu). The entries and the keyboard are those of the menu.
import { ContextMenu, MenuItem, MenuSeparator } from "@umriss-ui/core";
It is controlled: on contextmenu you keep the point and open it there, and onOpenChange reports the wish to close. Focus goes to the first entry and back to where it was on closing.
A right-click is invisible to a keyboard. Open the same menu on Shift+F10 or the menu key at the focused element, and keep the actions reachable some other way too.
On contextmenu, keep the event's point and pass it as position; ariaLabel names what the menu acts on.
import { useState } from "react";
import type { MouseEvent } from "react";
import { ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
export default function AtThePointer() {
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
const [last, setLast] = useState("-");
const openHere = (event: MouseEvent) => {
event.preventDefault();
setAt({ x: event.clientX, y: event.clientY });
};
return (
<Stack gap={3} align="flex-start">
<div
onContextMenu={openHere}
style={{
width: "100%",
maxWidth: 320,
height: 120,
display: "grid",
placeItems: "center",
border: "1px dashed var(--u-edge-color-strong)",
borderRadius: "var(--u-radius-md)",
}}
>
<Text size="sm" tone="muted">
Right-click anywhere in here
</Text>
</div>
<ContextMenu
open={at !== null}
position={at ?? { x: 0, y: 0 }}
onOpenChange={(open) => {
if (!open) setAt(null);
}}
ariaLabel="Actions for the surface"
>
<MenuItem onSelect={() => setLast("Split")}>Split here</MenuItem>
<MenuItem onSelect={() => setLast("Note added")}>Add a note</MenuItem>
<MenuSeparator />
<MenuItem tone="danger" onSelect={() => setLast("Removed")}>
Remove
</MenuItem>
</ContextMenu>
<Text size="xs" tone="muted">
Last: {last}
</Text>
</Stack>
);
}
Right-click opens it at the pointer; Shift+F10 or the menu key opens it at the focused row, so no reader is left out.
import { useState } from "react";
import type { KeyboardEvent, MouseEvent } from "react";
import { ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
const SHIPMENTS = [
{ id: "SH-1042", customer: "Harlow Bakery", window: "09:00–11:00" },
{ id: "SH-1043", customer: "Pine & Oak Florist", window: "10:00–12:00" },
{ id: "SH-1044", customer: "Moreau Optics", window: "11:30–13:30" },
];
/* A focusable row owes a visible focus: the library's ring, from its token. */
const STYLE = `
.shipment-row:focus-visible {
outline: none;
box-shadow: var(--u-focus-ring);
}
`;
export default function OnARow() {
const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
const [last, setLast] = useState("-");
const byPointer = (id: string) => (event: MouseEvent) => {
event.preventDefault();
setMenu({ id, x: event.clientX, y: event.clientY });
};
const byKey = (id: string) => (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
event.preventDefault();
const box = event.currentTarget.getBoundingClientRect();
setMenu({ id, x: box.left + 16, y: box.bottom });
};
const run = (what: string) => () => setLast(`${what} ${menu?.id ?? ""}`);
return (
<Stack gap={3} style={{ maxWidth: 480 }}>
<style>{STYLE}</style>
<div role="list" aria-label="Shipments">
{SHIPMENTS.map((shipment) => (
<div
key={shipment.id}
role="listitem"
tabIndex={0}
className="shipment-row"
onContextMenu={byPointer(shipment.id)}
onKeyDown={byKey(shipment.id)}
style={{
display: "flex",
gap: "var(--u-space-3)",
padding: "var(--u-space-2) var(--u-space-3)",
borderBottom: "1px solid var(--u-edge-color)",
background: menu?.id === shipment.id ? "var(--u-color-accent-subtle)" : undefined,
}}
>
<Text as="span" size="sm" mono>
{shipment.id}
</Text>
<Text as="span" size="sm" style={{ flex: 1 }}>
{shipment.customer}
</Text>
<Text as="span" size="sm" tone="muted" mono>
{shipment.window}
</Text>
</div>
))}
</div>
<ContextMenu
open={menu !== null}
position={menu ?? { x: 0, y: 0 }}
onOpenChange={(open) => {
if (!open) setMenu(null);
}}
ariaLabel={`Actions for shipment ${menu?.id ?? ""}`}
>
<MenuItem onSelect={run("Tracked")}>Track the shipment</MenuItem>
<MenuItem onSelect={run("Rescheduled")}>Move to tomorrow</MenuItem>
<MenuSeparator />
<MenuItem tone="danger" onSelect={run("Returned")}>
Return to sender
</MenuItem>
</ContextMenu>
<Text size="xs" tone="muted">
Last: {last}
</Text>
</Stack>
);
}
| Key | Action |
|---|---|
↓ / ↑ |
Moves to the next or previous entry, wrapping at the ends; disabled entries are skipped. |
Home / End |
Moves to the first or last entry. |
Escape |
Closes the menu and returns focus to where it was. |
Enter or Space |
Runs the focused entry and closes the menu. |
Shift+F10 or the menu key |
Opens the menu where you listen for them, as the row example does. |
ContextMenuProps| Name | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | — | Whether the menu stands. Controlled, like Popover: a right-click
opens it only because the caller says so.Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard |
onOpenChangerequired | (open: boolean) => void | — | Reports every wish to change the state – an entry chosen, Escape, an
outside click. Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard |
positionrequired | { x: number; y: number } | — | Where the menu opens, in client coordinates – typically clientX and
clientY of the event that asked for it. At a window edge the panel
flips and clamps as every popover does.Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard |
childrenrequired | ReactNode | — | The entries – MenuItem and MenuSeparator, as in Menu.Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabelrequired | string | — | Accessible name of the menu: what it acts on. A context menu has no
trigger whose label could name it. Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard |
What umriss deliberately does not build, and why: ADR-0032.