ContextMenu
A right-click menu. ContextMenuBoundary marks the region that responds to the
contextmenu event. Opening it suppresses the browser's own menu and shows
ContextMenuPanel instead. The panel closes on Escape and on any
click outside itself. With a Menu inside, it also closes on Tab
and when an item is activated.
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>.