Terracotta
GitHub

The panel is a dialog-style container, not an ARIA menu, so you can put anything you like in it. That includes a Menu, when you do want menu semantics.

import {
  ContextMenu,
  ContextMenuBoundary,
  ContextMenuPanel,
  ContextMenuOverlay,
} from 'terracotta/context-menu';
import { DisclosureStateChild, useDisclosureState } from 'terracotta/states';

Anatomy

<ContextMenu>           {/* owns the open state */}
  <ContextMenuBoundary/>{/* right-clicking here opens the menu */}
  <ContextMenuOverlay/> {/* optional backdrop; closes on click */}
  <ContextMenuPanel/>   {/* the menu content */}
</ContextMenu>

Examples

Basic

With menu semantics

<ContextMenu class="contextmenu" defaultOpen={false}>
  <ContextMenuBoundary class="contextmenu-boundary">…</ContextMenuBoundary>
  <ContextMenuPanel class="contextmenu-panel">
    <Menu as="ul" class="menu">
      <MenuItem class="menu-item" onClick={cut}>Cut</MenuItem>
      <MenuItem class="menu-item" onClick={copy}>Copy</MenuItem>
      <MenuItem class="menu-item" disabled>Paste</MenuItem>
    </Menu>
  </ContextMenuPanel>
</ContextMenu>

Arrow-key navigation and type-ahead come from Menu. Focus on open and the dismissal come from ContextMenuPanel.

Positioning at the cursor

Terracotta gives you behaviour, not geometry. Record the pointer position on the boundary and feed it to the panel:

const [point, setPoint] = createSignal({ x: 0, y: 0 });

<ContextMenu class="contextmenu" defaultOpen={false}>
  <ContextMenuBoundary
    class="contextmenu-boundary"
    onContextMenu={(event: MouseEvent) => setPoint({ x: event.clientX, y: event.clientY })}
  >
    Right-click anywhere in this box
  </ContextMenuBoundary>

  <ContextMenuPanel
    class="contextmenu-panel contextmenu-panel-fixed"
    style={{ '--x': `${point().x}px`, '--y': `${point().y}px` }}
  >
    …
  </ContextMenuPanel>
</ContextMenu>
.contextmenu-panel-fixed {
  position: fixed;
  inset-block-start: var(--y);
  inset-inline-start: var(--x);
}

Your onContextMenu is forwarded. It runs alongside Terracotta's own listener, which is the one that opens the menu.

Controlled

const [open, setOpen] = createSignal(false);

<ContextMenu class="contextmenu" isOpen={open()} onChange={setOpen}>
  …
</ContextMenu>

Closing from inside

<ContextMenu class="contextmenu" defaultOpen={false}>
  {({ close }) => (
    <>
      <ContextMenuBoundary class="contextmenu-boundary">…</ContextMenuBoundary>
      <ContextMenuPanel class="contextmenu-panel">
        <button type="button" class="contextmenu-item" onClick={() => { copy(); close(); }}>
          Copy
        </button>
      </ContextMenuPanel>
    </>
  )}
</ContextMenu>

Per-row context menus in a list

Each row gets its own ContextMenu, so each has its own state:

<ul class="file-list">
  <For each={files()}>
    {file => (
      <li>
        <ContextMenu class="contextmenu" defaultOpen={false}>
          <ContextMenuBoundary class="file-row">{file.name}</ContextMenuBoundary>
          <ContextMenuPanel class="contextmenu-panel">
            <button type="button" class="contextmenu-item" onClick={() => rename(file)}>
              Rename
            </button>
            <button type="button" class="contextmenu-item" onClick={() => remove(file)}>
              Delete
            </button>
          </ContextMenuPanel>
        </ContextMenu>
      </li>
    )}
  </For>
</ul>
.file-row { padding: 0.5rem 0.75rem; border-radius: 0.375rem; }
.file-row[tc-expanded] { background: #eff6ff; }

Letting the browser menu through

A disabled ContextMenu does not call preventDefault(), so the native menu appears as usual:

<ContextMenu class="contextmenu" defaultOpen={false} disabled={isReadOnly()}>
  …
</ContextMenu>

With a transition

<ContextMenu class="contextmenu" defaultOpen={false}>
  {({ isOpen }) => (
    <>
      <ContextMenuBoundary class="contextmenu-boundary">…</ContextMenuBoundary>
      <Transition
        show={isOpen()}
        enter="pop-enter" enterFrom="pop-from" enterTo="pop-to"
        leave="pop-leave" leaveFrom="pop-to" leaveTo="pop-from"
      >
        <ContextMenuPanel class="contextmenu-panel" unmount={false}>…</ContextMenuPanel>
      </Transition>
    </>
  )}
</ContextMenu>
.pop-enter { transition: opacity 120ms ease-out, scale 120ms ease-out; }
.pop-leave { transition: opacity 80ms ease-in, scale 80ms ease-in; }
.pop-from  { opacity: 0; scale: 0.96; }
.pop-to    { opacity: 1; scale: 1; }

State attributes

Element Attribute Present when
ContextMenu tc-context-menu Always
ContextMenu tc-expanded The menu is open
ContextMenu tc-disabled The menu is disabled
ContextMenuBoundary tc-context-menu-boundary Always
ContextMenuBoundary tc-expanded, tc-disabled Mirrors the menu
ContextMenuPanel tc-context-menu-panel Always (whenever rendered)
ContextMenuPanel tc-expanded, tc-disabled Mirrors the menu
ContextMenuOverlay tc-context-menu-overlay Always
ContextMenuOverlay tc-expanded, tc-disabled Mirrors the menu

The boundary also carries aria-controls pointing at the panel while open. It has no role, so it carries no aria-expanded or aria-disabled. The same goes for the ContextMenu root. Use tc-expanded and tc-disabled to style them.

Styling

/* Highlight the region whose menu is open */
[tc-context-menu-boundary][tc-expanded] {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

/* The overlay is always mounted, so hide it while closed */
[tc-context-menu-overlay]:not([tc-expanded]) { display: none; }

/* With unmount={false} on the panel, you own its visibility */
[tc-context-menu-panel]:not([tc-expanded]) { display: none; }

[tc-context-menu-boundary][tc-disabled] { cursor: default; }

Reading the state in code

Member Type Description
isOpen() boolean Whether the menu is open.
open() / close() () => void Opens / closes it. No-op while disabled.
toggle() () => void Flips the state.
disabled() boolean Whether the menu is disabled.

You can reach this state as the render-prop argument on ContextMenu and each of its parts, through <DisclosureStateChild>, or with useDisclosureState() in any descendant. Full reference in disclosure state.

Behaviour and keyboard

Key / event Action
Right-click inside ContextMenuBoundary Opens the menu and calls preventDefault() on the native event. A disabled menu does nothing and lets the browser menu through.
Escape Closes the menu
Tab / Shift+Tab Closes the menu when the panel holds a Menu. Otherwise cycles focus within the panel.
Enter or click on a MenuItem Activates the item and closes the menu. Disabled items do nothing.
Click outside the panel Closes the menu

When the panel opens, it focuses the first enabled menu item. Without one, it focuses the first focusable child, and failing that the panel itself, which carries tabindex="-1". Focus returns to where it was when the menu closes.

API

<ContextMenu>

Owns the disclosure state and renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
defaultOpen boolean none Initial state, uncontrolled. Mutually exclusive with isOpen.
isOpen boolean none Current state, controlled. Mutually exclusive with defaultOpen.
disabled boolean false Blocks opening and closing, and lets the native menu through.
onChange (state: boolean) => void none Called with the new state on every change.
onOpen () => void none Called when it opens, before onChange.
onClose () => void none Called when it closes, after onChange.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

<ContextMenuBoundary>

The region that listens for contextmenu. Renders a <div> by default.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none The region's content, or a render prop.
…rest props of as none Forwarded to the rendered element, including your own onContextMenu.

<ContextMenuPanel>

The menu content, with focus handling and outside-click handling. Renders a <div> by default.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
unmount boolean | 'offscreen' true How the panel behaves while closed. See unmount.
disabled boolean false Stops the panel's own key handling.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

<ContextMenuOverlay>

An optional backdrop that closes the menu when clicked. Renders a <div> by default. It is not conditionally mounted, so hide it with CSS or wrap it in a <Show>.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

Every descendant throws if rendered outside a <ContextMenu>.