# ContextMenu

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/contextmenu/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

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.

```ts
import { ContextMenu, MenuItem, MenuSeparator } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/contextmenu/

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.

## Examples

### Open it at the pointer

On `contextmenu`, keep the event's point and pass it as `position`; `ariaLabel` names what the menu acts on.

```tsx
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>
  );
}
```

### Open it on a row, by pointer or keyboard

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.

```tsx
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>
  );
}
```

## When to use something else

- The actions have a visible trigger → Menu

## Keyboard

| 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. |

## API

### `ContextMenuProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `open` *required* | `boolean` | — | Whether the menu stands. Controlled, like `Popover`: a right-click opens it only because the caller says so.<br>Shown in: [Open it at the pointer](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#at-the-pointer), [Open it on a row, by pointer or keyboard](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#on-a-row) |
| `onOpenChange` *required* | `(open: boolean) => void` | — | Reports every wish to change the state – an entry chosen, Escape, an outside click.<br>Shown in: [Open it at the pointer](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#at-the-pointer), [Open it on a row, by pointer or keyboard](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#on-a-row) |
| `position` *required* | `{ 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.<br>Shown in: [Open it at the pointer](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#at-the-pointer), [Open it on a row, by pointer or keyboard](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#on-a-row) |
| `children` *required* | `ReactNode` | — | The entries – `MenuItem` and `MenuSeparator`, as in `Menu`.<br>Shown in: [Open it at the pointer](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#at-the-pointer), [Open it on a row, by pointer or keyboard](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#on-a-row) |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| `ariaLabel` *required* | `string` | — | Accessible name of the menu: what it acts on. A context menu has no trigger whose label could name it.<br>Shown in: [Open it at the pointer](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#at-the-pointer), [Open it on a row, by pointer or keyboard](https://romanhaendler.github.io/umriss-ui/core/contextmenu/#on-a-row) |

## Known limits

- It listens to no right-click itself: the element that is clicked is yours, and so is the point.
- No submenus, no checkable entries and no type-ahead, as in the menu.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
