Skip to content

cv-context-menu

Contextual menu triggered by right-click, keyboard invocation, or imperative openAt(x, y), supporting direct cv-menu-item action children.

Headless: createContextMenu

Usage

View source
html
<div class="context-menu-demo-shell" data-demo="context-menu" data-live-demo-height="780">
  <section class="context-menu-demo-hero" aria-labelledby="context-menu-demo-title">
    <div class="context-menu-demo-copy">
      <span class="context-menu-demo-kicker">Contextual command surface</span>
      <h3 id="context-menu-demo-title">
        Right-click, keyboard, and programmatic opening all resolve through the same menu state.
      </h3>
      <p>
        The component keeps coordinates, active item, selection, Escape handling, and focus restoration in the
        headless context-menu model while the target can remain any product surface.
      </p>
      <div class="context-menu-demo-actions" role="group" aria-label="Context menu demo controls">
        <cv-button variant="primary" data-context-menu-open>Open at file row</cv-button>
        <cv-button variant="secondary" data-context-menu-focus>Focus target</cv-button>
      </div>
    </div>

    <dl class="context-menu-demo-metrics" aria-label="Context menu behavior summary">
      <div>
        <dt>Trigger</dt>
        <dd>right-click / Shift+F10</dd>
      </div>
      <div>
        <dt>State</dt>
        <dd>value + anchor</dd>
      </div>
      <div>
        <dt>Focus</dt>
        <dd>restores to target</dd>
      </div>
    </dl>
  </section>

  <section class="context-menu-demo-workbench" aria-labelledby="context-menu-demo-workbench-title">
    <div class="context-menu-demo-section-header">
      <span class="context-menu-demo-kicker">Vault file workspace</span>
      <h4 id="context-menu-demo-workbench-title">
        Use it where commands depend on the exact object under the pointer
      </h4>
    </div>

    <div class="context-menu-demo-surface">
      <header class="context-menu-demo-toolbar">
        <div>
          <span class="context-menu-demo-label">Selected route</span>
          <strong>Travel profile / field-notes</strong>
        </div>
        <output class="context-menu-demo-state" data-context-menu-state aria-live="polite">
          Menu closed. Right-click the highlighted row.
        </output>
      </header>

      <cv-context-menu class="context-menu-demo-menu" aria-label="Vault file actions" close-on-scroll>
        <div class="context-menu-demo-target" slot="target" tabindex="-1">
          <div class="context-menu-demo-table" role="table" aria-label="Vault file list">
            <div class="context-menu-demo-row context-menu-demo-row--header" role="row">
              <span role="columnheader">Name</span>
              <span role="columnheader">Layer</span>
              <span role="columnheader">State</span>
            </div>
            <div class="context-menu-demo-row" role="row">
              <span role="cell">border-route.md</span>
              <span role="cell">visible</span>
              <span role="cell">synced</span>
            </div>
            <div class="context-menu-demo-row context-menu-demo-row--active" role="row" aria-selected="true">
              <span role="cell">source-contact.enc</span>
              <span role="cell">hidden</span>
              <span role="cell">local only</span>
            </div>
            <div class="context-menu-demo-row" role="row">
              <span role="cell">camera-import.zip</span>
              <span role="cell">quarantine</span>
              <span role="cell">needs review</span>
            </div>
          </div>
        </div>

        <cv-menu-item value="preview">
          <cv-icon slot="prefix" name="eye" size="s"></cv-icon>
          Preview safely
          <cv-shortcut slot="suffix" label="Enter"></cv-shortcut>
        </cv-menu-item>
        <cv-menu-item value="copy-path">
          <cv-icon slot="prefix" name="copy" size="s"></cv-icon>
          Copy sealed path
          <cv-shortcut slot="suffix" label="Cmd+C"></cv-shortcut>
        </cv-menu-item>
        <cv-menu-item value="move">
          <cv-icon slot="prefix" name="folder-open" size="s"></cv-icon>
          Move to route
          <cv-shortcut slot="suffix" label="M"></cv-shortcut>
        </cv-menu-item>
        <div role="separator" aria-hidden="true"></div>
        <cv-menu-item value="audit">
          <cv-icon slot="prefix" name="history" size="s"></cv-icon>
          Inspect audit trail
        </cv-menu-item>
        <cv-menu-item value="expose" disabled>
          <cv-icon slot="prefix" name="globe" size="s"></cv-icon>
          Publish outside vault
        </cv-menu-item>
        <div role="separator" aria-hidden="true"></div>
        <cv-menu-item value="delete">
          <cv-icon slot="prefix" name="trash" size="s"></cv-icon>
          Delete local copy
          <cv-shortcut slot="suffix" label="Del"></cv-shortcut>
        </cv-menu-item>
      </cv-context-menu>

      <div class="context-menu-demo-contract" aria-label="Context menu contract">
        <div>
          <span class="context-menu-demo-label">Pointer</span>
          <strong>Context menu event anchors the popup to the click point.</strong>
        </div>
        <div>
          <span class="context-menu-demo-label">Keyboard</span>
          <strong>Shift+F10 and ContextMenu open without adding custom DOM state.</strong>
        </div>
        <div>
          <span class="context-menu-demo-label">Selection</span>
          <strong><code>cv-change</code> reports the chosen value.</strong>
        </div>
      </div>
    </div>
  </section>
</div>

<script>
  ;(() => {
    const shell = document.querySelector('.context-menu-demo-shell')
    const menu = shell?.querySelector('.context-menu-demo-menu')
    const target = shell?.querySelector('.context-menu-demo-target')
    const state = shell?.querySelector('[data-context-menu-state]')
    const openButton = shell?.querySelector('[data-context-menu-open]')
    const focusButton = shell?.querySelector('[data-context-menu-focus]')

    if (!shell || !menu || !target || !state) return

    const commandLabels = {
      'item-1': 'Preview safely',
      'item-2': 'Copy sealed path',
      'item-3': 'Move to route',
      'item-4': 'Inspect audit trail',
      'item-5': 'Publish outside vault',
      'item-6': 'Delete local copy',
    }

    const setState = (detail) => {
      const selected = detail?.value
        ? `Selected: ${commandLabels[detail.value] ?? detail.value}`
        : 'No command selected'
      const status = detail?.open ? 'open' : 'closed'
      const anchor =
        typeof detail?.anchorX === 'number' && typeof detail?.anchorY === 'number'
          ? ` at ${Math.round(detail.anchorX)}, ${Math.round(detail.anchorY)}`
          : ''
      state.textContent = `${selected}. Menu ${status}${anchor}.`
    }

    openButton?.addEventListener('click', () => {
      const rect = target.getBoundingClientRect()
      menu.openAt(rect.left + rect.width * 0.48, rect.top + rect.height * 0.58)
    })

    focusButton?.addEventListener('click', () => {
      const focusTarget = menu.shadowRoot?.querySelector('[part="target"]')
      focusTarget?.focus()
    })

    menu.addEventListener('cv-input', (event) => setState(event.detail))
    menu.addEventListener('cv-change', (event) => setState(event.detail))
  })()
</script>
html
<!-- Basic context menu -->
<cv-context-menu aria-label="File actions">
  <div slot="target">Right-click here</div>
  <cv-menu-item value="copy">Copy</cv-menu-item>
  <cv-menu-item value="paste">Paste</cv-menu-item>
  <cv-menu-item value="delete" disabled>Delete</cv-menu-item>
</cv-context-menu>
html
<!-- With inert separators -->
<cv-context-menu aria-label="Edit actions">
  <div slot="target">Right-click here</div>
  <cv-menu-item value="cut">Cut</cv-menu-item>
  <cv-menu-item value="copy">Copy</cv-menu-item>
  <cv-menu-item value="paste">Paste</cv-menu-item>
  <div role="separator" aria-hidden="true"></div>
  <cv-menu-item value="select-all">Select All</cv-menu-item>
</cv-context-menu>
html
<!-- With prefix and suffix affordances from cv-menu-item -->
<cv-context-menu aria-label="File actions">
  <div slot="target">Content area</div>
  <cv-menu-item value="rename">
    <cv-icon slot="prefix" name="pencil"></cv-icon>
    Rename
    <span slot="suffix">F2</span>
  </cv-menu-item>
</cv-context-menu>
html
<!-- Imperative positioning -->
<cv-context-menu id="my-menu" aria-label="Custom menu" close-on-scroll>
  <div slot="target">Content area</div>
  <cv-menu-item value="action1">Action 1</cv-menu-item>
</cv-context-menu>
<script>
  document.getElementById('my-menu').openAt(200, 150)
</script>

Anatomy

<cv-context-menu> (host)
├── <div part="target" tabindex="0">
│   └── <slot name="target">
└── <div part="menu" role="menu" tabindex="-1">
    └── <slot>   ← direct cv-menu-item children and inert role="separator" markup

Attributes

AttributeTypeDefaultDescription
valueString""Last selected item value
openBooleanfalseWhether the menu is currently visible
anchor-xNumber0X coordinate of the menu anchor point
anchor-yNumber0Y coordinate of the menu anchor point
aria-labelString""Accessible label for the menu
close-on-selectBooleantrueClose the menu after an item is selected
close-on-outside-pointerBooleantrueClose the menu on pointer interaction outside
close-on-scrollBooleanfalseClose the menu when the document scrolls

Slots

SlotDescription
targetContent that acts as the right-click or keyboard target zone
(default)Direct cv-menu-item children; inert elements with role="separator" may separate groups

cv-context-menu only indexes direct cv-menu-item children for selection and keyboard navigation. Separator markup is not a child component contract; use an inert element such as <div role="separator" aria-hidden="true"></div>. Non-item children are ignored by item navigation.

CSS Parts

PartElementDescription
target<div>Wrapper for the trigger/target zone
menu<div>Menu popup container positioned at anchor coordinates

CSS Custom Properties

PropertyDefaultDescription
--cv-context-menu-x0pxInline-start position of the menu popup, set by the component from anchor
--cv-context-menu-y0pxBlock-start position of the menu popup, set by the component from anchor
--cv-context-menu-min-inline-size180pxMinimum inline size of the menu popup
--cv-context-menu-paddingvar(--cv-space-1, 4px)Padding inside the menu popup
--cv-context-menu-gapvar(--cv-space-1, 4px)Gap between menu items
--cv-context-menu-border-radiusvar(--cv-radius-md, 10px)Border radius of the menu popup
--cv-context-menu-z-index80Z-index of the menu popup

Visual States

Host selectorDescription
:host([open])Menu popup is visible
:host(:not([open]))Menu popup is hidden

Events

EventDetailDescription
cv-input{value, activeId, open, anchorX, anchorY, openedBy}Fires on any state change (selection, active, open, anchor)
cv-change{value, activeId, open, anchorX, anchorY, openedBy}Fires only when the selected value changes

Event detail type:

ts
interface CVContextMenuEventDetail {
  value: string | null
  activeId: string | null
  open: boolean
  anchorX: number
  anchorY: number
  openedBy: string | null // 'pointer' | 'keyboard' | 'programmatic' | null
}

Imperative API

MethodSignatureDescription
openAt(x: number, y: number) => voidOpens the menu at the given coordinates
close() => voidCloses the menu

Keyboard Interaction

Target element

KeyAction
ContextMenuOpen menu at current anchor coordinates
Shift+F10Open menu at current anchor coordinates
KeyAction
EscapeClose menu, restore focus to target
TabClose menu, restore focus to target
ArrowDownMove active to next enabled item, wrapping
ArrowUpMove active to previous enabled item
HomeMove active to first enabled item
EndMove active to last enabled item
Enter / SpaceSelect active item

ARIA Contract

ElementAttributeValue
menurolemenu
menutabindex-1
menuaria-labeloptional label text
menuhiddenreflects !open
menudata-anchor-xstring of anchorX
menudata-anchor-ystring of anchorY
targetid{idBase}-target
itemrolefrom cv-menu-item headless item props
itemtabindex-1
itemaria-disabledpresent when disabled

Reactive State Mapping

cv-context-menu is a visual adapter over headless createContextMenu.

UIKit PropertyDirectionHeadless Binding
valueattr -> actionactions.select(value) when value changes
openattr -> actionactions.openAt(anchorX, anchorY) when true; actions.close() when false
anchor-x / anchor-yattr -> actionpassed to actions.openAt(x, y)
aria-labelattr -> optionpassed as ariaLabel in createContextMenu(options)
close-on-selectattr -> optionpassed as closeOnSelect in createContextMenu(options)
close-on-outside-pointerattr -> optionpassed as closeOnOutsidePointer in createContextMenu(options)
close-on-scrollattr -> DOMattaches a document scroll close listener only while open && closeOnScroll
Headless StateDirectionDOM Reflection
state.isOpen()state -> attr[open] host attribute, menu [hidden]
state.activeId()state -> DOM[data-active] on item elements, focus management
state.anchorX() / state.anchorY()state -> attr[anchor-x] / [anchor-y] host attributes and host-owned CSS custom properties
state.openedBy()state -> eventincluded in cv-input/cv-change event detail
state.restoreTargetId()state -> DOMfocus restored to target element on close

Contracts applied to DOM elements:

  • contracts.getTargetProps() -> target wrapper ([part="target"]): provides id, onContextMenu, onKeyDown
  • contracts.getMenuProps() -> menu container ([part="menu"]): provides id, role, tabindex, hidden, aria-label, data-anchor-x, data-anchor-y, onKeyDown
  • contracts.getItemProps(id) -> each direct cv-menu-item: provides id, role, tabindex, aria-disabled, data-active, onClick

UIKit does not own activation or navigation logic; headless state is the source of truth.

Child Elements

cv-menu-item

Actionable item within the context menu. cv-context-menu owns selection, active state, visibility, and ARIA props for direct cv-menu-item children. cv-menu-item owns its visual structure, including prefix, default label, and suffix slots.

See packages/uikit/specs/components/menu.md for the shared cv-menu-item visual contract.

Inert separators

Use plain inert markup between items when a divider is needed:

html
<div role="separator" aria-hidden="true"></div>

Separators are ignored by item navigation and do not receive cv-context-menu item props.

ChromVoid UIKit documentation