Context MenuATM
A menu triggered by right-click. Uses the Popover API with contextmenu event.
On this page (5)
§Default
Right-click inside the box. Near the right or bottom edge of the window the menu opens toward the other side of the pointer - as a native menu does - so it never runs off screen.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
Named states via the shared State API, driven per instance through the bound api:
default- closed (the authored state; right-click opens it)open- menu shown;setState('open', { x, y })positions it (viewport top-left by default, there is no pointer to anchor to)
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/context-menu-{state}.png.
Machine contract - verified against context-menu.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Shown - driven through the component's own State API. The runtime positions it at x/y (default 8/8) first. |
§CSS view file
Styles for the context-menu component. Uses design tokens for colors, spacing, and radius.
@layer components { .context-menu-trigger { display: flex; align-items: center; justify-content: center; border: 2px dashed var(--border); border-radius: var(--radius-lg); padding: 3rem; font-size: 0.875rem; color: var(--muted-foreground); cursor: default; } .context-menu { margin: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); background-color: var(--popover); color: var(--popover-foreground); padding: 0.25rem; box-shadow: var(--shadow-md); min-width: 10rem; opacity: 0; transform: scale(0.95); transition: opacity 100ms ease, transform 100ms ease, display 100ms allow-discrete; &:popover-open { opacity: 1; transform: scale(1); } } @starting-style { .context-menu:popover-open { opacity: 0; transform: scale(0.95); } } .context-menu-item { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.375rem 0.5rem; border: none; border-radius: var(--radius-md); background: transparent; color: var(--popover-foreground); font-size: 0.8125rem; font-family: inherit; text-align: left; cursor: pointer; transition: background-color 100ms ease; &:hover, &[data-highlighted] { background-color: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } .context-menu-separator { height: 1px; background-color: var(--border); margin: 0.25rem -0.25rem; } /* -- Density ---------------------------------------------------- data-density on the .context-menu root scales the container padding and the item rows. comfortable == the unsized default. */ .context-menu:where([data-density="compact"]) { padding: 0.125rem; & .context-menu-item { padding: 0.25rem 0.5rem; } } .context-menu:where([data-density="comfortable"]) { padding: 0.25rem; & .context-menu-item { padding: 0.375rem 0.5rem; } } .context-menu:where([data-density="spacious"]) { padding: 0.375rem; & .context-menu-item { padding: 0.5rem 0.625rem; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .context-menu-trigger, .context-menu-trigger *, .context-menu-trigger::before, .context-menu-trigger::after, .context-menu-trigger *::before, .context-menu-trigger *::after, .context-menu-trigger::backdrop, .context-menu, .context-menu *, .context-menu::before, .context-menu::after, .context-menu *::before, .context-menu *::after, .context-menu::backdrop, .context-menu-item, .context-menu-item *, .context-menu-item::before, .context-menu-item::after, .context-menu-item *::before, .context-menu-item *::after, .context-menu-item::backdrop, .context-menu-separator, .context-menu-separator *, .context-menu-separator::before, .context-menu-separator::after, .context-menu-separator *::before, .context-menu-separator *::after, .context-menu-separator::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}§JavaScript view file
Interaction logic for the context-menu component. Uses data attributes for wiring.
// -- Context Menu ---------------------------------------------// Right-click context menu using the Popover API, plus the named-state// API bound per menu popover, so agents/tests can open it without a real// right-click (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, safeShowPopover } from '../../shared/state-api.js';const df$ = defussGlobals();const contextMenuStates = ['default', 'open'];/** Like a native menu: where there is no room right of / below the point, * open toward its other side, and never past the viewport. Measured right * after the (usually synchronous) show - else once the deferred show lands. */function keepInView(menu, x, y) { const fit = () => { const w = menu.offsetWidth, h = menu.offsetHeight; const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; if (x + w > vw - 4) menu.style.left = `${Math.max(4, Math.min(x - w, vw - w - 4))}px`; if (y + h > vh - 4) menu.style.top = `${Math.max(4, Math.min(y - h, vh - h - 4))}px`; }; if (menu.matches(':popover-open')) fit(); else menu.addEventListener('toggle', (e) => { if (e.newState === 'open') fit(); }, { once: true });}/** * UI side of setState (per menu popover): 'open' shows the menu at { x, y } * (falling back to the top-left of the viewport - there is no pointer event * to anchor to); 'default' hides it. */function triggerStateChange(menu, stateName, config) { switch (stateName) { case 'default': menu.hidePopover(); break; case 'open': { const x = Number(config?.x ?? 8); const y = Number(config?.y ?? 8); menu.style.position = 'fixed'; menu.style.top = `${y}px`; menu.style.left = `${x}px`; // deferred show: showPopover() while a previous exit transition is // still running crashes the headless renderer (setState after Escape) safeShowPopover(menu); keepInView(menu, x, y); break; } }}/** Registry-level API; pass the menu popover explicitly. Unknown names throw. */export const contextMenuApi = { setState(menu, stateName, config = {}) { if (!contextMenuStates.includes(stateName)) { throw new Error(`context-menu: unknown state "${stateName}" (supported: ${contextMenuStates.join(', ')})`); } triggerStateChange(menu, stateName, config); // state lives on the ELEMENT, not the module (many menus per page) menu.dataset.stateName = stateName; menu._stateConfig = config; }, getState(menu) { // reflect reality: right-clicks and item clicks change the UI too return { name: menu.matches(':popover-open') ? 'open' : 'default', config: menu._stateConfig ?? {}, }; },};df$.contextMenuApi = contextMenuApi;df$.contextMenuStates = contextMenuStates;/* One pending open across all triggers: { menu, x, y } captured on the contextmenu event, consumed on the right-button pointerup. */let pendingOpen = null;/* Timestamp of the last right-button release (0 = never, i.e. page start) — see the contextmenu handler: the gesture normally fires contextmenu at button-DOWN (open must wait for the release), but some engines dispatch it AFTER the pointerup - then the gesture is already over and opening is safe. */let lastRightUp = 0;/* Document-level open-on-release - registered once (AGENTS.md delegation pattern). WHY release and not the contextmenu event itself: macOS fires contextmenu at mouse-DOWN, and an auto popover shown while the right button is still held is light-dismissed by the platform the moment it goes up — the menu flashed open and vanished on mouse-up (verified in Chromium). */if (!document.__ctxMenuReleaseInit) { document.__ctxMenuReleaseInit = true; document.addEventListener('pointerup', (e) => { if (e.button !== 2) return; lastRightUp = performance.now(); if (!pendingOpen) return; const { menu, x, y } = pendingOpen; pendingOpen = null; openMenuAt(menu, x, y); }); document.addEventListener('pointercancel', () => { pendingOpen = null; });}/** Show a context menu fixed at the pointer coords. */function openMenuAt(menu, x, y) { menu.style.position = 'fixed'; menu.style.top = `${y}px`; menu.style.left = `${x}px`; safeShowPopover(menu); keepInView(menu, x, y); menu.dataset.stateName = 'open';}function init() { document.querySelectorAll('[data-context-menu]:not([data-init])').forEach((trigger) => { trigger.dataset.init = ''; const menu = document.getElementById(trigger.dataset.contextMenu); if (!menu) return; // bind-scope the api per menu popover: `$('#my-ctx').api.setState('open', { x: 40, y: 40 })` menu.api = { setState: (stateName, config) => contextMenuApi.setState(menu, stateName, config), getState: () => contextMenuApi.getState(menu), }; trigger.addEventListener('contextmenu', (e) => { e.preventDefault(); // no pointer press (keyboard-synthesized, e.g. a11y tooling) → open next frame if (e.pointerId === undefined || e.pointerId < 0) { requestAnimationFrame(() => openMenuAt(menu, e.clientX, e.clientY)); return; } // right-button already released (gesture order: pointerup → contextmenu) // → opening now can't be light-dismissed. lastRightUp===0 (page never saw a // right release) must NOT qualify - otherwise early page loads take this // branch for a still-held button (0 - now is meaningless). if (lastRightUp > 0 && performance.now() - lastRightUp < 100) { openMenuAt(menu, e.clientX, e.clientY); return; } pendingOpen = { menu, x: e.clientX, y: e.clientY }; }); menu.addEventListener('click', (e) => { if (e.target.closest('.context-menu-item')) { menu.hidePopover(); menu.dataset.stateName = 'default'; } }); // right out of the top layer via Escape: keep the named state honest menu.addEventListener('toggle', (e) => { if (e.newState === 'closed') menu.dataset.stateName = 'default'; });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub