Monet
@etched/monet 0.0.1

Context menu

Ported

A menu that opens at the pointer on a right-click or a long press, and under the focused element on Shift+F10 or the context menu key. Nothing shows that it is there, so every action in it must also be reachable another way, such as a row's actions button. Contains a ContextMenuTrigger and a ContextMenuContent.

Right-click here
import { CopyIcon, PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
import { type CSSProperties } from "react";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "@etched/monet/context-menu";

// A dashed area to right-click, for the demos that are not a table.
const area: CSSProperties = {
  display: "grid",
  placeItems: "center",
  maxInlineSize: "22rem",
  blockSize: "7rem",
  borderRadius: "var(--et-card-radius)",
  boxShadow: "inset 0 0 0 1px var(--et-control-border)",
  color: "var(--et-text-subtle)",
};

export function Preview() {
  return (
    <ContextMenu>
      <ContextMenuTrigger style={area}>Right-click here</ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem icon={<PencilSimpleIcon />}>Rename</ContextMenuItem>
        <ContextMenuItem icon={<CopyIcon />}>Copy ID</ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem icon={<TrashIcon />} variant="destructive">
          Delete
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Installation

import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuLinkItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from "@etched/monet/context-menu";

Examples

Table rows

Right-click a row, or press Shift+F10 or the context menu key on its link. The actions button opens the same actions, so they can be found.

Last action: none

LotSiteChips
Lot L-7712TPE-296
Lot L-7709SJC-148
Lot L-7705TPE-196
import { ArrowBendUpRightIcon, CopyIcon, DotsThreeIcon, PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@etched/monet/dropdown";
import { Link } from "@etched/monet/link";
import { Table } from "@etched/monet/table";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from "@etched/monet/context-menu";

interface Lot {
  id: string;
  site: string;
  chips: number;
}

const lots: Lot[] = [
  { id: "L-7712", site: "TPE-2", chips: 96 },
  { id: "L-7709", site: "SJC-1", chips: 48 },
  { id: "L-7705", site: "TPE-1", chips: 96 },
];

const sites = ["SJC-1", "TPE-1", "TPE-2"];

// One list of actions for both menus: the context menu's parts are the dropdown menu's.
function LotActions({ lot, onAction }: { lot: Lot; onAction: (action: string) => void }) {
  return (
    <>
      <ContextMenuItem icon={<PencilSimpleIcon />} onClick={() => onAction("Rename")}>
        Rename
      </ContextMenuItem>
      <ContextMenuItem icon={<CopyIcon />} onClick={() => onAction("Copy ID")}>
        Copy ID
        <ContextMenuShortcut>⌘C</ContextMenuShortcut>
      </ContextMenuItem>
      <ContextMenuSub>
        <ContextMenuSubTrigger icon={<ArrowBendUpRightIcon />}>Move to site</ContextMenuSubTrigger>
        <ContextMenuSubContent>
          {sites.map((site) => (
            <ContextMenuItem key={site} disabled={site === lot.site} onClick={() => onAction(`Move to ${site}`)}>
              {site === lot.site ? `${site} (current)` : site}
            </ContextMenuItem>
          ))}
        </ContextMenuSubContent>
      </ContextMenuSub>
      <ContextMenuSeparator />
      <ContextMenuItem icon={<TrashIcon />} variant="destructive" onClick={() => onAction("Delete")}>
        Delete
      </ContextMenuItem>
    </>
  );
}

export function TableRows() {
  const [last, setLast] = useState("none");

  return (
    <>
      <p>Last action: {last}</p>
      <div className="card">
        <Table aria-label="Lots">
          <thead>
            <tr>
              <th>Lot</th>
              <th>Site</th>
              <th>Chips</th>
              {/* The button and the cell's padding. */}
              <th aria-label="Actions" style={{ inlineSize: "calc(var(--et-control-sm) + 2 * var(--et-space-4))" }} />
            </tr>
          </thead>
          <tbody>
            {lots.map((lot) => {
              const act = (action: string) => setLast(`${action}, lot ${lot.id}`);

              return (
                <ContextMenu key={lot.id}>
                  <ContextMenuTrigger render={<tr />}>
                    <th scope="row">
                      <Link href={`#lot-${lot.id}`}>Lot {lot.id}</Link>
                    </th>
                    <td>{lot.site}</td>
                    <td>{lot.chips}</td>
                    <td>
                      <DropdownMenu>
                        <DropdownMenuTrigger
                          render={<Button size="sm" shape="square" variant="ghost" aria-label={`Actions for lot ${lot.id}`} icon={<DotsThreeIcon />} />}
                        />
                        <DropdownMenuContent align="end">
                          <LotActions lot={lot} onAction={act} />
                        </DropdownMenuContent>
                      </DropdownMenu>
                    </td>
                  </ContextMenuTrigger>
                  <ContextMenuContent>
                    <LotActions lot={lot} onAction={act} />
                  </ContextMenuContent>
                </ContextMenu>
              );
            })}
          </tbody>
        </Table>
      </div>
    </>
  );
}

Checkbox and radio items

The menu stays open while you check items. This area takes focus, so Shift+F10 opens its menu.

Trace view, in ns
import { useState, type CSSProperties } from "react";
import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuTrigger } from "@etched/monet/context-menu";

// A dashed area to right-click, for the demos that are not a table.
const area: CSSProperties = {
  display: "grid",
  placeItems: "center",
  maxInlineSize: "22rem",
  blockSize: "7rem",
  borderRadius: "var(--et-card-radius)",
  boxShadow: "inset 0 0 0 1px var(--et-control-border)",
  color: "var(--et-text-subtle)",
};

export function CheckboxAndRadioItems() {
  const [grid, setGrid] = useState(true);
  const [markers, setMarkers] = useState(false);
  const [unit, setUnit] = useState("ns");

  return (
    <ContextMenu>
      <ContextMenuTrigger style={area} tabIndex={0}>
        Trace view, in {unit}
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuCheckboxItem checked={grid} onCheckedChange={setGrid}>
          Show grid
        </ContextMenuCheckboxItem>
        <ContextMenuCheckboxItem checked={markers} onCheckedChange={setMarkers}>
          Show markers
        </ContextMenuCheckboxItem>
        <ContextMenuSeparator />
        <ContextMenuRadioGroup value={unit} onValueChange={setUnit}>
          <ContextMenuLabel>Time unit</ContextMenuLabel>
          <ContextMenuRadioItem value="ns">Nanoseconds</ContextMenuRadioItem>
          <ContextMenuRadioItem value="µs">Microseconds</ContextMenuRadioItem>
        </ContextMenuRadioGroup>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Accessibility

  • Shift+F10 or the context menu key on a focused element in the trigger opens the menu under it. ArrowUp/ArrowDown and Home/End move; ArrowRight opens a submenu.
  • Enter chooses an item and Escape closes the menu. Focus goes back to the element that opened it.
  • A menu of menuitem, menuitemcheckbox and menuitemradio items, with aria-checked and aria-disabled. While it is open, the page behind is hidden from screen readers.
  • Nothing shows that the menu is there. Put every action somewhere else too, such as a row's actions button.
  • Give an area with nothing focusable in it tabIndex={0}, so the keys can open its menu.

API reference

ContextMenuCheckboxItem

PropTypeDefaultDescription
classNamestring–

ContextMenuContent

PropTypeDefaultDescription
classNamestring–Class on the menu, for layout such as width.
containerBaseTooltip.Portal.Props["container"]document.bodyThe element the popup renders into (a portal), for example a shadow root.
children *ReactNode–
collisionPaddingPositioner["collisionPadding"]5Least space, in pixels, between the menu and the edge of the screen or its scroll area.

ContextMenuGroup

PropTypeDefaultDescription
classNamestring–

ContextMenuItem

PropTypeDefaultDescription
classNamestring–
iconReactNode–Icon element before the text, for example <CopyIcon />. Rows without an icon line up with rows that have one.
variant"default" | "destructive""default"

ContextMenuLabel

PropTypeDefaultDescription
classNamestring–

ContextMenuLinkItem

PropTypeDefaultDescription
classNamestring–
iconReactNode–Icon element before the text, for example <CopyIcon />. Rows without an icon line up with rows that have one.
variant"default" | "destructive""default"

ContextMenuRadioItem

PropTypeDefaultDescription
classNamestring–
iconReactNode–Icon element before the text, for example <CopyIcon />. Rows without an icon line up with rows that have one.

ContextMenuSeparator

PropTypeDefaultDescription
classNamestring–

ContextMenuSubTrigger

PropTypeDefaultDescription
classNamestring–
iconReactNode–Icon element before the text, for example <CopyIcon />. Rows without an icon line up with rows that have one.

ContextMenuTrigger

PropTypeDefaultDescription
classNamestring–Class on the area, for layout.