Overlays

Context menu

A menu that opens at the pointer on right click or long press, for actions on the thing under it.

Built on
Popover APIcontextmenu eventPointer events@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.3 kB
Dependencies
React
invoice-table.tsx
Right click this row
Code
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/equip/context-menu";

export default function Demo() {
  return (
    <ContextMenu
      label="File actions"
      style={{
        display: "grid",
        gap: 8,
        width: 216,
        padding: 12,
        boxSizing: "border-box",
        border: "1px dashed var(--eq-line-strong)",
        borderRadius: "var(--eq-radius-m)",
      }}
    >
      <div
        tabIndex={0}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          height: 32,
          padding: "0 8px",
          borderRadius: "var(--eq-radius-s)",
          background: "var(--eq-tint)",
          fontFamily: "var(--eq-font-mono)",
          fontVariationSettings: "var(--eq-font-mono-settings)",
          fontSize: 13,
        }}
      >
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M4 2.5h5l3 3v8H4z M9 2.5v3h3" />
        </svg>
        invoice-table.tsx
      </div>
      <span style={{ fontSize: 12, color: "var(--eq-ink-3)", textAlign: "center" }}>Right click this row</span>
      <ContextMenuContent>
        <ContextMenuItem>Open</ContextMenuItem>
        <ContextMenuItem shortcut="F2">Rename</ContextMenuItem>
        <ContextMenuItem shortcut="⌥⌘C">Copy path</ContextMenuItem>
        <ContextMenuItem shortcut="⌘D">Duplicate</ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem tone="danger" shortcut="⌘⌫">
          Delete
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Examples

From the keyboard

Focus a row and press Shift+F10 or the Menu key: the menu opens under that row and acts on it.

web-7f3a2c
api-19be04
worker-c82d11
No action yet
Code
"use client";

import { useState } from "react";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/equip/context-menu";

const deploys = ["web-7f3a2c", "api-19be04", "worker-c82d11"];

export default function Example() {
  const [target, setTarget] = useState(deploys[0]);
  const [log, setLog] = useState("No action yet");
  return (
    <ContextMenu label={`Actions for ${target}`} style={{ display: "grid", gap: 4, width: "100%", maxWidth: 280 }}>
      {deploys.map((id) => (
        <div
          key={id}
          tabIndex={0}
          onFocus={() => setTarget(id)}
          onContextMenu={() => setTarget(id)}
          style={{
            padding: "6px 8px",
            borderRadius: "var(--eq-radius-s)",
            border: "1px solid var(--eq-line)",
            fontFamily: "var(--eq-font-mono)",
            fontVariationSettings: "var(--eq-font-mono-settings)",
            fontSize: 13,
          }}
        >
          {id}
        </div>
      ))}
      <span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>{log}</span>
      <ContextMenuContent>
        <ContextMenuItem onSelect={() => setLog(`Opened logs for ${target}`)}>View logs</ContextMenuItem>
        <ContextMenuItem onSelect={() => setLog(`Redeploying ${target}`)}>Redeploy</ContextMenuItem>
        <ContextMenuItem onSelect={() => setLog(`Copied ${target}`)} shortcut="⌘C">
          Copy deploy id
        </ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem tone="danger" onSelect={() => setLog(`Rolled back ${target}`)}>
          Roll back
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Disabled and destructive items

On touch, press and hold for half a second. Disabled items stay reachable with the arrows but do nothing.

Dana Whitfield

Pushed the migration to staging. Can someone check the invoice totals?

Code
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/equip/context-menu";

export default function Example() {
  return (
    <ContextMenu
      label="Message actions"
      style={{
        width: "100%",
        maxWidth: 280,
        padding: "10px 12px",
        boxSizing: "border-box",
        borderRadius: "var(--eq-radius-m)",
        border: "1px solid var(--eq-line)",
        fontSize: 14,
      }}
    >
      <strong style={{ fontSize: 13 }}>Dana Whitfield</strong>
      <p style={{ margin: "4px 0 0", color: "var(--eq-ink-2)" }}>
        Pushed the migration to staging. Can someone check the invoice totals?
      </p>
      <ContextMenuContent>
        <ContextMenuItem shortcut="R">Reply in thread</ContextMenuItem>
        <ContextMenuItem>Copy link</ContextMenuItem>
        <ContextMenuItem disabled>Edit message</ContextMenuItem>
        <ContextMenuItem disabled>Pin to channel</ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem tone="danger">Delete message</ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the menu for assistive tech, e.g. "File actions".
childrenReactNodenoneThe target region's content, plus one ContextMenuContent.
...restComponentProps<"div">noneEvery native div attribute, applied to the target region.
ContextMenuContent ...restComponentProps<"div">noneEvery native div attribute, applied to the menu.
ContextMenuItem onSelect() => voidnoneRuns when the item is chosen. The menu then closes.
ContextMenuItem shortcutstringnoneA shortcut hint at the end of the row. Display only; it does not bind the key.
ContextMenuItem disabledbooleannoneSets aria-disabled. The item stays focusable but does nothing.
ContextMenuItem tone"danger"noneColours the row for a destructive action.

Anatomy

root.eq-context-menuThe target region. It has no look of its own.
content.eq-context-menu-contentThe popover with role="menu", placed at the pointer.
item.eq-context-menu-item
item label.eq-context-menu-item-labelType-ahead matches against this text.
shortcut.eq-context-menu-shortcut
separator.eq-context-menu-separator

Keyboard

Shift+F10ContextMenuOn a focused element in the region, opens the menu under it.
ArrowDownArrowUpMoves to the next or previous item, wrapping.
HomeEndMoves to the first or last item.
A-ZMoves to the next item whose label starts with that letter.
EnterSpaceChooses the item and closes the menu.
EscapeTabCloses the menu and returns focus to where it was.

Notes

  • Long press opens it on touch after 500ms; moving more than 10px first cancels, and the tap that follows the long press is swallowed.
  • The keyboard way in needs something focusable inside the region; give the rows a tabindex or use real buttons and links.
  • A context menu is hard to discover, so every action in it should also be reachable another way, such as a row menu or toolbar.
  • It closes on choosing an item, Escape, Tab, a press outside, scrolling, resizing and leaving the window.
  • It opens down and right of the pointer and flips to the other side near an edge, so it always stays inside the viewport.
  • Disabled items stay focusable so they can be discovered, as the menu pattern recommends, but cannot be chosen.
  • The exit fade needs transition of overlay, which only Chromium has; elsewhere the menu fades in but closes at once. iOS Safari fires no contextmenu event, so the long press timer opens it there; text selection may still start on a long press over selectable text.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/context-menu.tsx366 lines
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type RefObject,
} from "react";
import "./context-menu.css";

type Point = { x: number; y: number };

type ContextMenuContext = {
  point: Point | null;
  label: string;
  menuRef: RefObject<HTMLDivElement | null>;
  close: (returnFocus: boolean) => void;
};

const Context = createContext<ContextMenuContext>({
  point: null,
  label: "",
  menuRef: { current: null },
  close: () => {},
});

const LONG_PRESS = 500;
const SLOP = 10;
const EDGE = 8;

export type ContextMenuProps = ComponentProps<"div"> & {
  /** Names the menu for assistive tech, e.g. "File actions". */
  label: string;
};

export function ContextMenu({
  label,
  className,
  children,
  onContextMenu,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onClickCapture,
  onKeyDown,
  ...rest
}: ContextMenuProps) {
  const menuRef = useRef<HTMLDivElement>(null);
  const [point, setPoint] = useState<Point | null>(null);
  const returnTo = useRef<HTMLElement | null>(null);
  const press = useRef<{ timer: number; x: number; y: number } | null>(null);
  // After a long press or a key opens the menu, the browser may send its own contextmenu
  // event (Android, Shift+F10) and a click; both are swallowed until this time.
  const quietUntil = useRef(0);
  const swallowClick = useRef(false);

  function inMenu(target: EventTarget | null) {
    return target instanceof Node && !!menuRef.current?.contains(target);
  }

  function open(at: Point) {
    const active = document.activeElement;
    if (active instanceof HTMLElement && active !== document.body && !inMenu(active)) returnTo.current = active;
    else if (!inMenu(active)) returnTo.current = null;
    setPoint(at);
  }

  function close(returnFocus: boolean) {
    setPoint(null);
    swallowClick.current = false;
    const target = returnTo.current;
    if (returnFocus && target?.isConnected) target.focus({ preventScroll: true });
  }

  function cancelPress() {
    if (press.current) window.clearTimeout(press.current.timer);
    press.current = null;
  }

  useEffect(() => cancelPress, []);

  useEffect(() => {
    if (!point) return;
    function onOutside(event: globalThis.PointerEvent) {
      if (!inMenu(event.target)) close(false);
    }
    function onScroll(event: Event) {
      if (!inMenu(event.target)) close(false);
    }
    function onDismiss() {
      close(false);
    }
    document.addEventListener("pointerdown", onOutside, true);
    window.addEventListener("scroll", onScroll, true);
    window.addEventListener("resize", onDismiss);
    window.addEventListener("blur", onDismiss);
    return () => {
      document.removeEventListener("pointerdown", onOutside, true);
      window.removeEventListener("scroll", onScroll, true);
      window.removeEventListener("resize", onDismiss);
      window.removeEventListener("blur", onDismiss);
    };
  }, [point]);

  function handleContextMenu(event: MouseEvent<HTMLDivElement>) {
    onContextMenu?.(event);
    if (event.defaultPrevented && !inMenu(event.target)) return;
    event.preventDefault();
    if (inMenu(event.target) || event.timeStamp < quietUntil.current) return;
    cancelPress();
    open({ x: event.clientX, y: event.clientY });
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    onPointerDown?.(event);
    swallowClick.current = false;
    if (event.pointerType === "mouse" || inMenu(event.target) || !event.isPrimary) return;
    cancelPress();
    const x = event.clientX;
    const y = event.clientY;
    const timer = window.setTimeout(() => {
      press.current = null;
      quietUntil.current = performance.now() + 800;
      swallowClick.current = true;
      open({ x, y });
    }, LONG_PRESS);
    press.current = { timer, x, y };
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    onPointerMove?.(event);
    const start = press.current;
    if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > SLOP) cancelPress();
  }

  function handleClickCapture(event: MouseEvent<HTMLDivElement>) {
    onClickCapture?.(event);
    if (!swallowClick.current || inMenu(event.target)) return;
    swallowClick.current = false;
    event.preventDefault();
    event.stopPropagation();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || inMenu(event.target)) return;
    if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
    event.preventDefault();
    const target = event.target instanceof Element ? event.target : event.currentTarget;
    const box = target.getBoundingClientRect();
    quietUntil.current = event.timeStamp + 800;
    open({ x: box.left + Math.min(box.width / 2, 24), y: box.bottom });
  }

  return (
    <div
      {...rest}
      className={["eq-context-menu", className].filter(Boolean).join(" ")}
      onContextMenu={handleContextMenu}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={(event) => {
        onPointerUp?.(event);
        cancelPress();
      }}
      onPointerCancel={(event) => {
        onPointerCancel?.(event);
        cancelPress();
      }}
      onClickCapture={handleClickCapture}
      onKeyDown={handleKeyDown}
    >
      <Context.Provider value={{ point, label, menuRef, close }}>{children}</Context.Provider>
    </div>
  );
}

function items(menu: HTMLElement | null) {
  return Array.from(menu?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
}

export function ContextMenuContent({
  className,
  children,
  onKeyDown,
  onClick,
  onPointerDown,
  onContextMenu,
  ...rest
}: ComponentProps<"div">) {
  const { point, label, menuRef, close } = useContext(Context);

  useLayoutEffect(() => {
    const menu = menuRef.current;
    if (!menu) return;
    const showing = menu.matches(":popover-open");
    if (!point) {
      if (showing) menu.hidePopover();
      return;
    }
    if (!showing) menu.showPopover();
    // Opens down and right of the pointer, and flips to the other side of it near an edge.
    const width = menu.offsetWidth;
    const height = menu.offsetHeight;
    const viewWidth = document.documentElement.clientWidth;
    const viewHeight = document.documentElement.clientHeight;
    const flipX = point.x + width > viewWidth - EDGE;
    const flipY = point.y + height > viewHeight - EDGE;
    const left = flipX ? point.x - width : point.x;
    const top = flipY ? point.y - height : point.y;
    menu.style.left = `${Math.max(EDGE, Math.min(left, viewWidth - width - EDGE))}px`;
    menu.style.top = `${Math.max(EDGE, Math.min(top, viewHeight - height - EDGE))}px`;
    menu.dataset.side = flipY ? "top" : "bottom";
    menu.style.transformOrigin = `${flipX ? "right" : "left"} ${flipY ? "bottom" : "top"}`;
    items(menu)[0]?.focus({ preventScroll: true });
  }, [point, menuRef]);

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    const all = items(menuRef.current);
    const current = all.findIndex((item) => item === document.activeElement);
    let next: number | undefined;

    switch (event.key) {
      case "ArrowDown":
        next = current < 0 ? 0 : (current + 1) % all.length;
        break;
      case "ArrowUp":
        next = current < 0 ? all.length - 1 : (current - 1 + all.length) % all.length;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = all.length - 1;
        break;
      case "Escape":
        event.preventDefault();
        close(true);
        return;
      case "Tab":
        event.preventDefault();
        close(true);
        return;
      case "ContextMenu":
      case "F10":
        event.preventDefault();
        return;
      default: {
        if (event.key.length !== 1 || event.key === " ") return;
        if (event.ctrlKey || event.metaKey || event.altKey) return;
        const letter = event.key.toLowerCase();
        for (let step = 1; step <= all.length; step++) {
          const index = (current + step + all.length) % all.length;
          const text = all[index].querySelector(".eq-context-menu-item-label")?.textContent ?? "";
          if (text.trim().toLowerCase().startsWith(letter)) {
            next = index;
            break;
          }
        }
      }
    }

    if (next === undefined || all.length === 0) return;
    event.preventDefault();
    all[next]?.focus();
  }

  // The menu is a React child of the region, so its events would reach the region's handlers.
  return (
    <div
      {...rest}
      ref={menuRef}
      popover="manual"
      role="menu"
      aria-label={label}
      className={["eq-context-menu-content", className].filter(Boolean).join(" ")}
      onKeyDown={(event) => {
        handleKeyDown(event);
        event.stopPropagation();
      }}
      onClick={(event) => {
        onClick?.(event);
        event.stopPropagation();
      }}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        event.stopPropagation();
      }}
      onContextMenu={(event) => {
        onContextMenu?.(event);
        event.preventDefault();
        event.stopPropagation();
      }}
    >
      {children}
    </div>
  );
}

export type ContextMenuItemProps = Omit<ComponentProps<"button">, "onSelect"> & {
  /** Runs when the item is chosen by click, Enter or Space. The menu then closes. */
  onSelect?: () => void;
  /** A shortcut hint shown at the end of the row, e.g. "⌘⌫". Display only. */
  shortcut?: string;
  /** danger colours the row for a destructive action. */
  tone?: "danger";
};

export function ContextMenuItem({
  onSelect,
  shortcut,
  tone,
  disabled,
  className,
  children,
  onClick,
  onPointerMove,
  ...rest
}: ContextMenuItemProps) {
  const { close } = useContext(Context);
  return (
    <button
      type="button"
      {...rest}
      role="menuitem"
      tabIndex={-1}
      aria-disabled={disabled || undefined}
      className={["eq-context-menu-item", className].filter(Boolean).join(" ")}
      data-tone={tone}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
      }}
      onClick={(event) => {
        if (disabled) return;
        onClick?.(event);
        if (event.defaultPrevented) return;
        onSelect?.();
        close(true);
      }}
    >
      <span className="eq-context-menu-item-label">{children}</span>
      {shortcut && <kbd className="eq-context-menu-shortcut">{shortcut}</kbd>}
    </button>
  );
}

export function ContextMenuSeparator({ className, ...rest }: ComponentProps<"div">) {
  return (
    <div
      role="separator"
      {...rest}
      className={["eq-context-menu-separator", className].filter(Boolean).join(" ")}
    />
  );
}
components/equip/context-menu.css152 lines
@layer equip {
  /* The target region. It draws nothing; it only stops the touch callout competing with long press. */
  .eq-context-menu {
    -webkit-touch-callout: none;
  }

  .eq-context-menu-content {
    --_shift: -4px;

    box-sizing: border-box;
    /* Undo the UA's centring: left and top are set at the pointer when it opens. */
    position: fixed;
    inset: auto;
    margin: 0;
    min-width: 200px;
    max-width: calc(100vw - 2 * var(--eq-space-2));
    max-height: calc(100dvh - 2 * var(--eq-space-2));
    overflow: auto;
    /* Rows carry their own padding, so the surface keeps only room for the focus ring. */
    padding: var(--eq-space-1);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-l);
    background: var(--eq-raised);
    color: var(--eq-ink);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    opacity: 0;
    scale: 0.96;
    translate: 0 var(--_shift);
    transform-origin: top left;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease);
  }

  .eq-context-menu-content[data-side="top"] {
    --_shift: 4px;
  }

  .eq-context-menu-content:popover-open {
    display: flex;
    flex-direction: column;
    opacity: 1;
    scale: none;
    translate: none;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-context-menu-content:popover-open {
      opacity: 0;
      scale: 0.96;
      translate: 0 var(--_shift);
    }
  }

  .eq-context-menu-item {
    display: flex;
    align-items: center;
    gap: var(--eq-space-3);
    flex: none;
    width: 100%;
    min-height: var(--eq-h-s);
    padding: 0 var(--eq-space-2);
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
    text-align: start;
    white-space: nowrap;
    cursor: pointer;
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  .eq-context-menu-item:active:not([aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-context-menu-item[data-tone="danger"] {
    color: var(--eq-danger);
  }

  .eq-context-menu-item:focus {
    outline: none;
    background: var(--eq-tint);
  }

  .eq-context-menu-item[data-tone="danger"]:focus {
    background: color-mix(in srgb, var(--eq-danger) 10%, transparent);
  }

  .eq-context-menu-item:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .eq-context-menu-item[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-context-menu-item[aria-disabled="true"]:focus {
    background: transparent;
  }

  .eq-context-menu-item-label {
    flex: 1;
  }

  .eq-context-menu-shortcut {
    margin-inline-start: auto;
    color: var(--eq-ink-2);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-xs);
  }

  .eq-context-menu-separator {
    flex: none;
    height: 1px;
    margin: var(--eq-space-1) calc(-1 * var(--eq-space-1));
    background: var(--eq-line);
  }

  /* Keeping the surface in the top layer while it fades out needs overlay, which only some
     engines can transition. Elsewhere it closes at once. */
  @supports (overlay: auto) {
    .eq-context-menu-content {
      transition:
        opacity var(--eq-dur) var(--eq-ease),
        scale var(--eq-dur) var(--eq-ease),
        translate var(--eq-dur) var(--eq-ease),
        display var(--eq-dur) allow-discrete,
        overlay var(--eq-dur) allow-discrete;
    }

    .eq-context-menu-content:popover-open {
      transition:
        opacity var(--eq-dur) var(--eq-ease),
        scale var(--eq-dur-move) var(--eq-spring),
        translate var(--eq-dur-move) var(--eq-spring),
        display var(--eq-dur) allow-discrete,
        overlay var(--eq-dur) allow-discrete;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index