Context Menu
A primitive for building context menus that open at the pointer position on right-click (or long-press on touch devices). Use ContextMenu when you need a menu bound to a region rather than a visible trigger button.
import { ContextMenu } from "@areia/slots";
document.querySelector("#root")!.innerHTML = `
<div data-slot="context-menu">
<div data-slot="context-menu-trigger" class="win95-inset p-3 w-48 select-none text-center">
Right click here
</div>
<div data-slot="context-menu-content" class="win95-menu">
<div data-slot="context-menu-label" class="win95-label">Actions</div>
<button data-slot="context-menu-item" data-value="copy" class="win95-menu-item">
Copy
</button>
<button data-slot="context-menu-item" data-value="paste" class="win95-menu-item">
Paste
</button>
<div data-slot="context-menu-separator" class="win95-separator"></div>
<button
data-slot="context-menu-item"
data-value="delete"
data-variant="destructive"
class="win95-menu-item">
Delete
</button>
</div>
</div>
`;
const root = document.querySelector<HTMLElement>(
'[data-slot="context-menu"]',
)!;
if (root) {
const controller = ContextMenu.createContextMenu(root, {
onSelect: (value) => console.log("selected", value),
});
}
Import
import { ContextMenu } from "@areia/slots";
Usage
Call createContextMenu with the root element and optional configuration. The primitive reads slot attributes from the DOM and manages open/close, item highlight, keyboard navigation, and selection.
import { ContextMenu } from "@areia/slots";
const el = document.querySelector<HTMLElement>(
'[data-slot="context-menu"]',
)!;
if (!el) throw new Error("No root element found");
const controller = ContextMenu.createContextMenu(el, {
onOpenChange: (open) => console.log("open changed", open),
onSelect: (value) => console.log("selected", value),
});
// Programmatic control
controller.open({ x: 200, y: 100 });
// controller.close();
// controller.destroy();
Auto-bind
Use the create helper to discover and bind every unattached root in a scope.
import { ContextMenu } from "@areia/slots";
const controllers = ContextMenu.create(document);
// controllers.forEach((c) => { ... });
Expected Markup
The primitive expects the following slot structure. Every slot attribute must be present for the controller to wire up correctly.
<div data-slot="context-menu">
<div data-slot="context-menu-trigger">
<!-- Region that accepts right-click / long-press -->
</div>
<div data-slot="context-menu-content">
<div data-slot="context-menu-label">Actions</div>
<button data-slot="context-menu-item" data-value="copy">
Copy
</button>
<button data-slot="context-menu-item" data-value="paste">
Paste
</button>
<div data-slot="context-menu-separator"></div>
<button
data-slot="context-menu-item"
data-value="delete"
data-variant="destructive"
>
Delete
</button>
<button
data-slot="context-menu-item"
data-value="disabled"
data-disabled
>
Disabled
</button>
<button
data-slot="context-menu-checkbox-item"
data-value="show-hidden"
>
Show hidden
</button>
<button
data-slot="context-menu-radio-item"
data-value="option-a"
>
Option A
</button>
</div>
</div>
Slot Reference
| Slot | Purpose |
|---|---|
context-menu | Root element. Controller is bound here. |
context-menu-trigger | Area that receives right-click / long-press events. |
context-menu-content | Popup container that appears at the pointer position. |
context-menu-item | Selectable menu action. |
context-menu-label | Non-interactive label for grouping items. |
context-menu-separator | Visual divider between item groups. |
context-menu-checkbox-item | Toggleable item with a checked state. |
context-menu-radio-item | Selectable item in a radio group. |
Data Attributes
| Attribute | Applies to | Description |
|---|---|---|
data-value | *-item, *-checkbox-item, *-radio-item | Value emitted on selection. |
data-disabled | *-item, *-checkbox-item, *-radio-item | Prevents selection and hover. |
data-variant="destructive" | *-item | Marks the item as a destructive action. |
The controller manages these state attributes on the root:
| Attribute | Values | Description |
|---|---|---|
data-state | "open" / "closed" | Current open state. |
data-open | (present) | Present when the menu is open. |
data-closed | (present) | Present when the menu is closed. |
Items receive the following state attributes:
| Attribute | Description |
|---|---|
data-highlighted | Present on the currently highlighted item. |
data-checked | Present on a checked checkbox/radio item. |
Events
All events bubble from the root element (data-slot="context-menu").
Outbound
| Event | Detail | Description |
|---|---|---|
context-menu:open-change | { open, previousOpen, source, reason } | Fires when the menu opens or closes. |
context-menu:select | { value, item, itemType, source, checked? } | Fires when an item is selected. |
context-menu:open-change
interface ContextMenuOpenChangeDetail {
open: boolean;
previousOpen: boolean;
source: "pointer" | "keyboard" | "programmatic";
reason:
| "trigger"
| "select"
| "outside"
| "escape"
| "tab"
| "programmatic";
}
| Field | Description |
|---|---|
open | New open state. |
previousOpen | Previous open state. |
source | How the state change was initiated. |
reason | Why the state changed. |
context-menu:select
interface ContextMenuSelectDetail {
value: string;
item: HTMLElement;
itemType: "item" | "radio" | "checkbox";
source: "pointer" | "keyboard";
checked?: boolean;
}
| Field | Description |
|---|---|
value | The data-value of the selected item. |
item | Reference to the selected DOM element. |
itemType | The type of item that was selected. |
source | How the selection was triggered. |
checked | New checked state (checkbox/radio items only). |
API Reference
Options
ContextMenu.createContextMenu(root, options)
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Initial open state. |
closeOnClickOutside | boolean | true | Close when clicking outside the menu. |
closeOnEscape | boolean | true | Close when pressing Escape. |
closeOnSelect | boolean | true | Close when an item is selected. |
disabled | boolean | false | Disable user interaction entirely. |
longPressDelay | number | 500 | Long-press duration in ms for touch triggers. |
sideOffset | number | — | Distance in px from the pointer anchor. |
collisionPadding | number | — | Viewport collision padding in px. |
onOpenChange | (open: boolean) => void | — | Called when open state changes. |
onSelect | (value: string) => void | — | Called when an item is selected. |
Options can also be set via data-* attributes on the root element, using kebab-case names (e.g. data-close-on-select="false"). JS options take precedence over data attributes.
Controller
| Member | Type | Description |
|---|---|---|
isOpen | boolean (getter) | Current open state. |
highlightedValue | string | null (getter) | data-value of the currently highlighted item. |
open(point?) | (point?: { x: number; y: number }) => void | Open at optional viewport coordinates. |
close() | () => void | Close the menu. |
setOpen(open, point?) | (open: boolean, point?: { x: number; y: number }) => void | Set open state programmatically. |
destroy() | () => void | Remove all event listeners and clean up. |
Controller
The controller is the imperative handle returned by createContextMenu. Use it for programmatic control in response to application state changes, or when you need to open the menu outside of a right-click interaction.
import { ContextMenu } from "@areia/slots";
const el = document.querySelector<HTMLElement>(
'[data-slot="context-menu"]',
)!;
if (!el) throw new Error("No root element");
const ctrl = ContextMenu.createContextMenu(el, {
onSelect: (v) => console.log(v),
});
// Read state
console.log(ctrl.isOpen); // boolean
console.log(ctrl.highlightedValue); // string | null
// Open at a specific position
ctrl.open({ x: 300, y: 150 });
// Open at the last known pointer position
ctrl.open();
// Close
ctrl.close();
// Set open state with coordinates
ctrl.setOpen(true, { x: 500, y: 200 });
ctrl.setOpen(false);
// Tear down all listeners
ctrl.destroy();