Skip to content

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

SlotPurpose
context-menuRoot element. Controller is bound here.
context-menu-triggerArea that receives right-click / long-press events.
context-menu-contentPopup container that appears at the pointer position.
context-menu-itemSelectable menu action.
context-menu-labelNon-interactive label for grouping items.
context-menu-separatorVisual divider between item groups.
context-menu-checkbox-itemToggleable item with a checked state.
context-menu-radio-itemSelectable item in a radio group.

Data Attributes

AttributeApplies toDescription
data-value*-item, *-checkbox-item, *-radio-itemValue emitted on selection.
data-disabled*-item, *-checkbox-item, *-radio-itemPrevents selection and hover.
data-variant="destructive"*-itemMarks the item as a destructive action.

The controller manages these state attributes on the root:

AttributeValuesDescription
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:

AttributeDescription
data-highlightedPresent on the currently highlighted item.
data-checkedPresent on a checked checkbox/radio item.

Events

All events bubble from the root element (data-slot="context-menu").

Outbound

EventDetailDescription
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";
}
FieldDescription
openNew open state.
previousOpenPrevious open state.
sourceHow the state change was initiated.
reasonWhy the state changed.

context-menu:select

interface ContextMenuSelectDetail {
  value: string;
  item: HTMLElement;
  itemType: "item" | "radio" | "checkbox";
  source: "pointer" | "keyboard";
  checked?: boolean;
}
FieldDescription
valueThe data-value of the selected item.
itemReference to the selected DOM element.
itemTypeThe type of item that was selected.
sourceHow the selection was triggered.
checkedNew checked state (checkbox/radio items only).

API Reference

Options

ContextMenu.createContextMenu(root, options)

OptionTypeDefaultDescription
defaultOpenbooleanfalseInitial open state.
closeOnClickOutsidebooleantrueClose when clicking outside the menu.
closeOnEscapebooleantrueClose when pressing Escape.
closeOnSelectbooleantrueClose when an item is selected.
disabledbooleanfalseDisable user interaction entirely.
longPressDelaynumber500Long-press duration in ms for touch triggers.
sideOffsetnumberDistance in px from the pointer anchor.
collisionPaddingnumberViewport collision padding in px.
onOpenChange(open: boolean) => voidCalled when open state changes.
onSelect(value: string) => voidCalled 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

MemberTypeDescription
isOpenboolean (getter)Current open state.
highlightedValuestring | null (getter)data-value of the currently highlighted item.
open(point?)(point?: { x: number; y: number }) => voidOpen at optional viewport coordinates.
close()() => voidClose the menu.
setOpen(open, point?)(open: boolean, point?: { x: number; y: number }) => voidSet open state programmatically.
destroy()() => voidRemove 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();