ContextMenu
Use CContextMenu for application commands that belong to one target region. It opens the existing Citry Menu at a trusted pointer point or at a visible point derived from the focused target. It does not create a second Menu model.
Keep the browser's native context menu when copy, spelling, editing, links, images, media, or embedded content are the primary job. The contextmenu event, the browser event guide, and the APG Menu pattern describe the platform contracts that ContextMenu joins. The safest native browser path is to render no ContextMenu for content that mainly needs browser commands.
Start with a contextual action
Bind every value from the required target slot to exactly one direct native Element. Make that target, or a useful descendant, focusable so keyboard users can press the Context Menu key or Shift+F10.
<c-CContextMenu aria_label="Document actions">
<c-fill name="target" data="{ target_attrs }">
<div c-bind="target_attrs" tabindex="0">
Quarterly report.pdf
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="rename">Rename</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate</c-CMenuItem>
</c-fill>
</c-CContextMenu>
The target keeps its native semantics. ContextMenu does not add role=button, aria-expanded, or Menu Button keys. A native focusable descendant is better than adding an extra wrapper Tab stop when the content already has one.
Show code
from typing import Any
import citry_ui
from citry import Component, citry
from citry_ui import CButton, CContextMenu, CMenuItem, CMenuSeparator
citry.register_library(citry_ui)
class BasicContextMenu(Component):
class Kwargs:
pass
class Slots:
pass
def template_data(
self,
kwargs: Kwargs, # noqa: ARG002
slots: Slots, # noqa: ARG002
) -> dict[str, Any]:
return {
"python_menu": CContextMenu(
aria_label="Invoice actions",
slots={
"target": lambda data: CButton(
variant="outline",
attrs=data.target_attrs,
slots={"default": "Invoices"},
),
"menu": (
CMenuItem(value="rename", slots={"default": "Rename"}),
CMenuItem(value="duplicate", slots={"default": "Duplicate"}),
CMenuSeparator(),
CMenuItem(
value="delete",
intent="danger",
slots={"default": "Delete"},
),
),
},
),
}
template = """
<section
class="context-menu-basic"
x-data
>
<article>
<h3>Template file</h3>
<c-CContextMenu
aria_label="Document actions"
$c-props="{onAction: onAction}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-basic__file"
tabindex="0"
c-bind="target_attrs"
>
<strong>Quarterly report.pdf</strong>
<span>2.4 MB ยท Updated today</span>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="rename">Rename</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate</c-CMenuItem>
<c-CMenuSeparator />
<c-CMenuItem value="delete" intent="danger">Delete</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</article>
<article>
<h3>Python composition</h3>
{{ python_menu }}
</article>
<output aria-live="polite" x-text="lastActionLabel">
Last action: No action yet
</output>
</section>
"""
js = """
$component(({ scope }) => {
scope.lastActionLabel = "Last action: No action yet";
scope.onAction = (value) => {
scope.lastActionLabel = `Last action: ${value}`;
};
});
"""
css = """
:where(.context-menu-basic) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
gap: 1rem;
min-block-size: 20rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-basic article) {
display: grid;
gap: 0.75rem;
align-content: start;
}
:where(.context-menu-basic h3) {
margin: 0;
}
:where(.context-menu-basic__file) {
display: grid;
gap: 0.25rem;
padding: 1rem;
border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
border-radius: 0.75rem;
background: Canvas;
}
:where(.context-menu-basic__file:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:where(.context-menu-basic output) {
grid-column: 1 / -1;
}
"""
preview = BasicContextMenu()
preview # noqa: B018
Keep one Menu model
The menu slot accepts the existing CMenuItem, CMenuCheckboxItem, CMenuRadioGroup, CMenuRadioItem, CMenuGroup, CMenuSeparator, and CMenuSubmenu declarations. Their values, choices, action ordering, item callbacks, typeahead, submenu keys, links, disabled state, and validation are the Menu contract.
ContextMenu adds no item-model array or duplicate declaration API. Import Menu declarations and CMenuActionDetail from their existing citry_ui exports.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuChoicesAndSubmenus(Component):
template = """
<section
class="context-menu-choices"
dir="rtl"
x-init="Alpine.store('contextMenuChoices', {showGrid:true, sort:'updated'})"
x-data="{
last:'No Menu action yet',
}"
>
<h3>Canvas card</h3>
<c-CContextMenu
aria_label="Canvas card actions"
c-close_on_select="False"
$c-props="{
onAction:(value,detail)=>
last=`${detail.path.join(' / ') || 'root'}: ${value}`,
}"
>
<c-fill name="target" data="{ target_attrs }">
<article
class="context-menu-choices__card"
dir="ltr"
tabindex="0"
c-bind="target_attrs"
>
<strong>Release canvas</strong>
<span>Four records ยท Updated today</span>
</article>
</c-fill>
<c-fill name="menu">
<c-CMenuCheckboxItem
value="show-grid"
c-checked="True"
$c-props="{
checked:$store.contextMenuChoices.showGrid,
onCheckedChange:(next)=>$store.contextMenuChoices.showGrid=next,
}"
>
Show grid
</c-CMenuCheckboxItem>
<c-CMenuSeparator />
<c-CMenuRadioGroup
value="updated"
$c-props="{
value:$store.contextMenuChoices.sort,
onValueChange:(next)=>$store.contextMenuChoices.sort=next,
}"
>
<c-fill name="label">Sort cards</c-fill>
<c-fill name="default">
<c-CMenuRadioItem value="updated">Recently updated</c-CMenuRadioItem>
<c-CMenuRadioItem value="name">Name</c-CMenuRadioItem>
</c-fill>
</c-CMenuRadioGroup>
<c-CMenuSeparator />
<c-CMenuSubmenu value="export">
<c-fill name="label">Export</c-fill>
<c-fill name="default">
<c-CMenuItem value="export-png">PNG image</c-CMenuItem>
<c-CMenuSubmenu value="document">
<c-fill name="label">Document</c-fill>
<c-fill name="default">
<c-CMenuItem value="export-pdf">PDF</c-CMenuItem>
<c-CMenuItem value="export-svg">SVG</c-CMenuItem>
</c-fill>
</c-CMenuSubmenu>
</c-fill>
</c-CMenuSubmenu>
</c-fill>
</c-CContextMenu>
<div class="context-menu-choices__peer" dir="ltr">
<c-CContextMenu
aria_label="Canvas peer actions"
$c-props="{
onAction:(value,detail)=>
last=`LTR ${detail.path.join(' / ') || 'root'}: ${value}`,
}"
>
<c-fill name="target" data="{ target_attrs }">
<button type="button" c-bind="target_attrs">LTR peer card</button>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect-peer">Inspect peer</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</div>
<output
aria-live="polite"
x-text="`${last}; grid ${$store.contextMenuChoices.showGrid}; sort ${$store.contextMenuChoices.sort}`"
>No Menu action yet; grid true; sort updated</output>
</section>
"""
css = """
:where(.context-menu-choices) {
display: grid;
gap: 0.875rem;
justify-items: start;
min-block-size: 24rem;
max-inline-size: 22rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-choices h3) {
margin: 0;
}
:where(.context-menu-choices__card) {
display: grid;
gap: 0.25rem;
inline-size: min(18rem, 100%);
padding: 1rem;
border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, Highlight 8%, Canvas);
}
:where(.context-menu-choices__card:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:where(.context-menu-choices__peer) {
padding: 0.75rem;
border: 1px dashed color-mix(in srgb, CanvasText 24%, transparent);
}
"""
preview = ContextMenuChoicesAndSubmenus()
preview # noqa: B018
Own visibility without stealing native fallback
Supply client open and onOpenChange together when application state owns visibility. A trusted closed-to-open request has to decide whether to suppress the browser menu before its event listener returns. The callback therefore uses a narrow claim protocol:
- Set the owner's
openstate totruesynchronously. - Return the literal Boolean
truein the same callback turn.
Every other return, including a Promise or another truthy value, refuses that opening. The candidate coordinates remain available in the detail, but the component does not commit them or prevent the native default. Returning true without supplying open=true on the next settled props turn is a broken claim: the component stays closed and reports one diagnostic.
Return values are ignored for uncontrolled opening and for every close. open=null or prop removal releases control from the currently committed visibility, using the same handoff as CMenu.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ControlledContextMenu(Component):
template = """
<section
class="context-menu-controlled"
x-data="{
open:false,
controlled:true,
accept:true,
breakClaim:false,
lastReason:'none',
candidate:'none',
}"
>
<c-CContextMenu
aria_label="Diagram actions"
$c-props="{
open:controlled ? open : null,
onOpenChange:(nextOpen,detail)=>{
lastReason=detail.reason;
candidate=`${Math.round(detail.clientX)}, ${Math.round(detail.clientY)}`;
if (!controlled) return;
if (!nextOpen) {
open=false;
return;
}
if (!accept) return false;
if (!breakClaim) open=true;
return true;
},
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-controlled__target"
tabindex="0"
c-bind="target_attrs"
>
<strong>Controlled diagram</strong>
<span>Right click or press Shift+F10</span>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect layers</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate diagram</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<div role="group" aria-label="Controlled visibility settings">
<label><input type="checkbox" x-model="accept" /> Claim requests</label>
<label><input type="checkbox" x-model="breakClaim" /> Break the claim</label>
<button type="button" @click="controlled=true;open=true">
Open from owner
</button>
<button type="button" @click="controlled=true;open=false">
Close from owner
</button>
<button type="button" @click="controlled=false">
Release control
</button>
</div>
<output>
State:
<span x-text="controlled ? (open ? 'controlled open' : 'controlled closed') : 'uncontrolled'">
controlled closed
</span>;
request: <span x-text="lastReason">none</span>;
candidate: <span x-text="candidate">none</span>
</output>
</section>
"""
css = """
:where(.context-menu-controlled) {
display: grid;
gap: 1rem;
min-block-size: 22rem;
padding: 1rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-controlled__target) {
display: grid;
gap: 0.25rem;
padding: 1.25rem;
border-radius: 1rem;
background: light-dark(#eef4ff, #182230);
}
:where(.context-menu-controlled__target:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:where(.context-menu-controlled [role="group"]) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
align-items: center;
}
:where(.context-menu-controlled label) {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
"""
preview = ControlledContextMenu()
preview # noqa: B018
Keep browser commands
ContextMenu preserves native behavior for:
- input, textarea, select, option, and editable content;
- links with
href, images, audio, video, object, embed, and iframe Elements; - custom Elements and detectable open-shadow hosts;
- a noncollapsed Selection that intersects the target; and
- any composed event path containing
data-citry-context-menu-native.
Put data-citry-context-menu-native on a standard host when outside code cannot inspect a closed shadow. Shift plus secondary click is always a native escape. Firefox may not dispatch contextmenu for that gesture, so ContextMenu also recognizes the pointer sequence and never suppresses it.
If a custom Menu is already open, a protected native request closes it once and leaves the platform default untouched. Citry never clears selected text.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuNativeContent(Component):
template = """
<section
class="context-menu-native"
x-data="{last:'No custom request yet'}"
>
<p>
Select text or use the editing, link, image, media, embedded, and
marked regions below. Their browser context menus stay available.
</p>
<c-CContextMenu
aria_label="Document region actions"
$c-props="{
onOpenChange:(next,detail)=>
last=`${next ? 'Open' : 'Close'}: ${detail.reason}`,
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-native__target"
tabindex="0"
c-bind="target_attrs"
>
<p class="context-menu-native__selection">
Select part of this paragraph before opening its browser menu.
</p>
<label>
Editable title
<input value="Quarterly report" />
</label>
<div contenteditable="true">Editable note</div>
<a href="#native-content-destination">Open linked record</a>
<img
alt="Blue document thumbnail"
width="72"
height="48"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
/>
<video controls aria-label="Media preview"></video>
<context-menu-native-card>Custom element host</context-menu-native-card>
<div
data-citry-context-menu-native
x-init="const root=$el.attachShadow({mode:'closed'});root.textContent='Closed shadow fixture'"
>
Marked closed-shadow host
</div>
<div
x-init="const root=$el.attachShadow({mode:'open'});root.textContent='Select open-shadow text'"
>
Open-shadow selection fixture
</div>
<iframe
title="Embedded document boundary"
srcdoc="<p>Child document keeps its own browser menu.</p>"
></iframe>
<div class="context-menu-native__eligible" tabindex="0">
Plain file row ยท Custom commands available here
</div>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="rename">Rename file row</c-CMenuItem>
<c-CMenuItem value="archive">Archive file row</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<output aria-live="polite" x-text="last">No custom request yet</output>
<span id="native-content-destination">Linked record destination</span>
</section>
"""
css = """
:where(.context-menu-native) {
display: grid;
gap: 1rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-native > p) {
max-inline-size: 62ch;
margin: 0;
}
:where(.context-menu-native__target) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: 0.75rem;
padding: 1rem;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
border-radius: 1rem;
}
:where(.context-menu-native__target > *) {
min-inline-size: 0;
padding: 0.625rem;
border-radius: 0.5rem;
background: color-mix(in srgb, Highlight 7%, Canvas);
}
:where(.context-menu-native__selection) {
user-select: text;
}
:where(.context-menu-native__eligible:focus-visible,
.context-menu-native__target:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:where(.context-menu-native iframe) {
inline-size: 100%;
min-block-size: 5rem;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
}
"""
preview = ContextMenuNativeContent()
preview # noqa: B018
Bound touch and pen fallback
An eligible primary touch or pen press can request the Menu after 700 ms. The hold cancels for movement beyond 10 CSS pixels, scrolling, selection, another pointer, pointer end or cancellation, blur, visibility loss, disabledness, or structural change. Citry does not cancel pointerdown, capture the pointer, change touch-action, disable selection, or apply callout-suppression CSS.
An accepted synthetic hold suppresses only its matching trusted derived click, through pointerup plus 1,500 ms and never beyond the 10-second absolute deadline. This prevents one hold from also navigating, submitting, resetting, or running a target click. Other clicks keep their native behavior.
Desktop emulation cannot prove an operating system's callout timing. If a platform shows a native callout without a cancelable contextmenu, Citry does not claim to suppress it. Test real touch and pen devices before release.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuTouchAndPen(Component):
template = """
<section
class="context-menu-touch"
x-init="Alpine.store('contextMenuTouchDemo', {
phase:'idle',
log:[],
targetClicks:0,
})"
x-data="{
simulate(kind) {
const state=Alpine.store('contextMenuTouchDemo');
state.phase='armed: synthetic probe';
state.log.unshift(`armed synthetic ${kind}`);
const target=document.querySelector(
kind === 'scroll'
? '[data-context-menu-scroll-target]'
: '[data-context-menu-touch-target]'
);
const fire=(type,id,x,y,buttons=1)=>target.dispatchEvent(
new PointerEvent(type, {
bubbles:true,
pointerType:'touch',
pointerId:id,
clientX:x,
clientY:y,
buttons,
})
);
fire('pointerdown',81,12,12);
if (kind === 'hold') {
state.log.unshift('synthetic hold: pointerup after 700 ms');
setTimeout(()=>{
fire('pointerup',81,12,12,0);
state.phase='synthetic hold complete: use a trusted device';
},725);
} else if (kind === 'move') {
fire('pointermove',81,28,12);
fire('pointerup',81,28,12,0);
state.phase='canceled: movement';
state.log.unshift('canceled movement');
} else if (kind === 'scroll') {
const scroller=document.querySelector('.context-menu-touch__scroller');
scroller.scrollTop += 24;
scroller.dispatchEvent(new Event('scroll', {bubbles:true}));
fire('pointerup',81,12,12,0);
} else if (kind === 'lost-up') {
state.phase='armed: lost-up deadline pending';
state.log.unshift('synthetic lost-up: absolute 10 s guard');
} else {
fire('pointerdown',82,14,14);
fire('pointercancel',81,12,12,0);
fire('pointercancel',82,14,14,0);
state.phase='canceled: second pointer';
state.log.unshift('canceled second pointer');
}
},
}"
>
<div class="context-menu-touch__policy">
<strong>Bound fallback</strong>
<span>Hold 700 ms ยท move at most 10 CSS px</span>
<span>Matching click guard: pointerup + 1,500 ms</span>
<span>Absolute guard deadline: 10 seconds</span>
</div>
<c-CContextMenu
aria_label="Touch card actions"
$c-props="{
onOpenChange:(next,detail)=>{
const state=$store.contextMenuTouchDemo;
state.phase=next ? 'accepted' : 'idle';
state.log.unshift(`${next ? 'accepted' : 'closed'} ${detail.reason}`);
},
}"
>
<c-fill name="target" data="{ target_attrs }">
<button
class="context-menu-touch__card"
type="button"
data-context-menu-touch-target
@click="$store.contextMenuTouchDemo.targetClicks += 1"
c-bind="target_attrs"
>
<strong>Touch card</strong>
<span>Press and hold for contextual commands</span>
</button>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="pin">Pin card</c-CMenuItem>
<c-CMenuItem value="share">Share card</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<div
class="context-menu-touch__scroller"
@scroll="
$store.contextMenuTouchDemo.phase='canceled: scroll';
$store.contextMenuTouchDemo.log.unshift('canceled scroll')
"
>
<c-CContextMenu
aria_label="Scrollable card actions"
$c-props="{
open:false,
onOpenChange:(next,detail)=>{
$store.contextMenuTouchDemo.phase='controlled refused';
$store.contextMenuTouchDemo.log.unshift(`refused ${detail.reason}`);
return false;
},
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-touch__card"
tabindex="0"
data-context-menu-scroll-target
c-bind="target_attrs"
>
<strong>Scrollable card</strong>
<span>Scrolling, movement, and another pointer cancel the hold.</span>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect card</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<div class="context-menu-touch__spacer">Scroll boundary</div>
</div>
<div class="context-menu-touch__controls">
<button type="button" @click="simulate('hold')">Test 700 ms hold</button>
<button type="button" @click="simulate('move')">Test movement cancel</button>
<button type="button" @click="simulate('scroll')">Test scroll cancel</button>
<button type="button" @click="simulate('lost-up')">Test lost pointerup</button>
<button type="button" @click="simulate('second')">Test second pointer</button>
<button
type="button"
@click="$store.contextMenuTouchDemo.log=[];$store.contextMenuTouchDemo.phase='idle'"
>Clear ledger</button>
<output
aria-live="polite"
x-text="`State: ${$store.contextMenuTouchDemo.phase};
primary clicks: ${$store.contextMenuTouchDemo.targetClicks}`"
>State: idle; primary clicks: 0</output>
</div>
<ol aria-live="polite">
<template x-for="entry in $store.contextMenuTouchDemo.log.slice(0,5)">
<li x-text="entry"></li>
</template>
</ol>
<p>
Desktop emulation cannot prove an operating system's callout timing.
Citry does not disable selection, touch scrolling, or platform callouts.
</p>
</section>
"""
css = """
:where(.context-menu-touch) {
display: grid;
gap: 1rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-touch__policy) {
display: grid;
gap: 0.25rem;
padding: 0.875rem;
border-inline-start: 0.25rem solid Highlight;
background: color-mix(in srgb, Highlight 8%, Canvas);
}
:where(.context-menu-touch__card) {
display: grid;
gap: 0.25rem;
inline-size: min(100%, 26rem);
padding: 1rem;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
border-radius: 0.75rem;
background: Canvas;
color: CanvasText;
text-align: start;
}
:where(.context-menu-touch__scroller) {
max-block-size: 8rem;
overflow: auto;
border: 1px solid color-mix(in srgb, CanvasText 16%, transparent);
}
:where(.context-menu-touch__spacer) {
min-block-size: 12rem;
padding: 1rem;
}
:where(.context-menu-touch__controls) {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
:where(.context-menu-touch p,
.context-menu-touch ol) {
margin: 0;
}
"""
preview = ContextMenuTouchAndPen()
preview # noqa: B018
Return focus deliberately
After an accepted request, focus moves to the first enabled Menu item. Escape and a non-link command try the original deep focus snapshot, then the invoking Element, then the focusable target. If those are unavailable, focus moves to the nearest open modal Dialog or to the document body. A link action keeps native navigation and skips focus return.
Outside pointer, focus outside, Tab, Shift+Tab, ancestor closure, and owner focus movement do not restore focus. Owner-moved focus always wins.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuFocusAndKeyboard(Component):
template = """
<section
class="context-menu-focus"
x-data="{disableInvoker:false,last:'No close yet'}"
>
<p>
Focus the row or nested Button, then press the Context Menu key or
Shift+F10. A linked path keeps the browser's native context menu.
</p>
<c-CContextMenu
aria_label="Focusable row actions"
$c-props="{
onOpenChange:(next,detail)=>last=
`${next ? 'opened' : 'closed'} by ${detail.reason}`,
onAction:(value)=>{
if (value === 'disable-invoker') disableInvoker=true;
if (value === 'remove-invoker') {
document.querySelector('[data-context-menu-return-target]')?.remove();
}
},
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-focus__row"
tabindex="0"
c-bind="target_attrs"
>
<span>
<strong>Focusable report row</strong>
<small>The row is the stable fallback target.</small>
</span>
<c-CButton
size="sm"
variant="outline"
c-attrs="{'data-context-menu-return-target':''}"
$c-props="{disabled:disableInvoker}"
>Nested action</c-CButton>
<a href="#focus-linked-record">Linked record</a>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="rename">Rename</c-CMenuItem>
<c-CMenuItem value="disable-invoker">
Disable nested return target
</c-CMenuItem>
<c-CMenuItem value="remove-invoker">
Remove nested return target
</c-CMenuItem>
<c-CMenuItem href="#focus-linked-record">Open linked record</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<div class="context-menu-focus__fallbacks">
<button type="button" @click="location.reload()">Reload nested Button</button>
<button type="button" disabled>Disabled fallback</button>
<span tabindex="-1">Programmatic fallback</span>
</div>
<c-CDialog>
<c-fill name="activator" data="{ activator_attrs }">
<c-CButton variant="outline" c-attrs="activator_attrs">
Open composed modal fixture
</c-CButton>
</c-fill>
<c-fill name="title">Modal focus ancestry</c-fill>
<c-fill name="default">
<p>
This target and its private point stay inside the current modal.
</p>
<c-CContextMenu
aria_label="Modal row actions"
$c-props="{
onOpenChange:(next,detail)=>last=
`modal ${next ? 'opened' : 'closed'} by ${detail.reason}`,
}"
>
<c-fill name="target" data="{ target_attrs }">
<button
class="context-menu-focus__modal-target"
type="button"
c-bind="target_attrs"
>Modal report row</button>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="review-modal-row">Review modal row</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</c-fill>
</c-CDialog>
<output aria-live="polite" x-text="last">No close yet</output>
<span id="focus-linked-record">Linked destination</span>
</section>
"""
css = """
:where(.context-menu-focus) {
display: grid;
gap: 1rem;
min-block-size: 22rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-focus > p) {
max-inline-size: 62ch;
margin: 0;
}
:where(.context-menu-focus__row) {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
padding: 1rem;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
border-radius: 0.75rem;
}
:where(.context-menu-focus__row > span:first-child) {
display: grid;
gap: 0.25rem;
}
:where(.context-menu-focus__row:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:where(.context-menu-focus__fallbacks) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
:where(.context-menu-focus__modal-target) {
padding: 0.75rem;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
border-radius: 0.5rem;
background: Canvas;
color: CanvasText;
}
"""
preview = ContextMenuFocusAndKeyboard()
preview # noqa: B018
Share the layer coordinator
Nested ContextMenus use the deepest bound target. A ContextMenu inside a Popover, Tooltip, or Dialog keeps that logical ancestry. A coexisting ordinary Menu shares the same coordinator; right-clicking inside its open surface stays native and does not reinvoke the ContextMenu. A later modal outside the ancestry force-closes it. Point and Menu surfaces remain inline in the same Document or open ShadowRoot, then use native Popover for the top layer.
Events do not cross iframe Document boundaries. A child document needs its own Citry installation and coordinator. An iframe Element inside the parent target keeps its native context menu.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuLayersAndRoots(Component):
template = """
<section
class="context-menu-layers"
x-data="{
last:'No layer request yet',
counterTick:0,
}"
>
<article>
<h3>Deepest target wins</h3>
<c-CContextMenu
aria_label="Outer card actions"
$c-props="{
onOpenChange:(next,detail)=>last=
`outer ${next ? 'open' : 'close'} ${detail.reason}`,
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-layers__outer"
tabindex="0"
c-bind="target_attrs"
>
Outer card
<c-CContextMenu
aria_label="Inner badge actions"
$c-props="{
onOpenChange:(next,detail)=>last=
`inner ${next ? 'open' : 'close'} ${detail.reason}`,
}"
>
<c-fill name="target" data="{ target_attrs as inner_target_attrs }">
<span
class="context-menu-layers__inner"
tabindex="0"
c-bind="inner_target_attrs"
>Inner badge</span>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect-badge">Inspect badge</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect-card">Inspect card</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</article>
<article>
<h3>Inside another anchored layer</h3>
<c-CPopover>
<c-fill name="activator" data="{ activator_attrs }">
<c-CButton c-attrs="activator_attrs">Open inspector</c-CButton>
</c-fill>
<c-fill name="title">Record inspector</c-fill>
<c-fill name="default">
<c-CContextMenu
aria_label="Inspector row actions"
c-attrs="{'data-context-menu-removable':''}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-layers__popover-target"
tabindex="0"
c-bind="target_attrs"
>Row inside Popover</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="open-row">Open row</c-CMenuItem>
<c-CMenuItem value="archive-row">Archive row</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</c-fill>
</c-CPopover>
<button
type="button"
@click="
document.querySelector('[data-context-menu-removable]')?.remove();
last='nested ContextMenu removed'
"
>Remove nested ContextMenu</button>
<button type="button" @click="location.reload()">
Restore the fixture, then repeat the cycle
</button>
<c-CContextMenu aria_label="Tooltip target actions">
<c-fill name="target" data="{ target_attrs }">
<div class="context-menu-layers__popover-target" c-bind="target_attrs">
<c-CTooltip text="This descendant shares Tooltip layer ancestry">
<c-fill name="activator" data="{ activator_attrs }">
<c-CButton
size="sm"
variant="outline"
c-attrs="activator_attrs"
>Tooltip-bound target</c-CButton>
</c-fill>
</c-CTooltip>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect-tooltip-target">
Inspect Tooltip target
</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<c-CMenu>
<c-fill name="activator" data="{ activator_attrs }">
<c-CButton size="sm" variant="outline" c-attrs="activator_attrs">
Open sibling Menu
</c-CButton>
</c-fill>
<c-fill name="default">
<c-CMenuItem value="ordinary-menu-command">
Ordinary Menu command
</c-CMenuItem>
<c-CMenuItem href="#context-menu-menu-native-link">
Native link in Menu
</c-CMenuItem>
</c-fill>
</c-CMenu>
<p id="context-menu-menu-native-link">
Right click inside the open Menu to keep the browser path rather
than reinvoking ContextMenu.
</p>
</article>
<article
x-data
x-init="$nextTick(()=>{
const shadow=$refs.shadowHost.attachShadow({mode:'open'});
document.querySelectorAll('style').forEach(
(style)=>shadow.append(style.cloneNode(true))
);
shadow.append($refs.shadowFixture);
})"
>
<h3>Open ShadowRoot scope</h3>
<div x-ref="shadowFixture">
<c-CContextMenu aria_label="Shadow record actions">
<c-fill name="target" data="{ target_attrs }">
<button
class="context-menu-layers__shadow-target"
type="button"
c-bind="target_attrs"
>ShadowRoot target</button>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect-shadow">Inspect shadow record</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</div>
<div x-ref="shadowHost" data-context-menu-shadow-host></div>
</article>
<article>
<h3>Later modal owns the top layer</h3>
<c-CDialog>
<c-fill name="activator" data="{ activator_attrs }">
<c-CButton variant="outline" c-attrs="activator_attrs">
Open sibling Dialog
</c-CButton>
</c-fill>
<c-fill name="title">Layer review</c-fill>
<c-fill name="default">
A later modal outside a ContextMenu ancestry force-closes it.
</c-fill>
<c-fill name="actions" data="{ close_attrs }">
<c-CButton c-attrs="close_attrs">Close review</c-CButton>
</c-fill>
</c-CDialog>
<iframe
title="Separate document context boundary"
srcdoc="<p>A child document needs its own Citry instance.</p>"
></iframe>
</article>
<div class="context-menu-layers__diagnostics">
<button type="button" @click="counterTick += 1">
Refresh layer counters
</button>
<output
aria-live="polite"
x-text="`${last}; layers ${counterTick >= 0
? (globalThis[Symbol.for('citry-ui:anchored-layer-runtime')]?.layers.length ?? 0)
: 0}; registrations ${globalThis[Symbol.for('citry-ui:anchored-layer-runtime')]
?.stats?.activeCoordinators ?? 0}`"
>No layer request yet; layers 0; registrations 0</output>
</div>
</section>
"""
css = """
:where(.context-menu-layers) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
min-block-size: 28rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-layers article) {
display: grid;
gap: 0.75rem;
align-content: start;
min-inline-size: 0;
}
:where(.context-menu-layers h3) {
margin: 0;
}
:where(.context-menu-layers__outer,
.context-menu-layers__popover-target) {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
border-radius: 0.75rem;
}
:where(.context-menu-layers__inner) {
display: inline-block;
inline-size: fit-content;
padding: 0.375rem 0.625rem;
border-radius: 999px;
background: color-mix(in srgb, Highlight 14%, Canvas);
}
:where(.context-menu-layers__shadow-target) {
padding: 0.75rem;
border: 1px solid currentColor;
border-radius: 0.5rem;
background: Canvas;
color: CanvasText;
}
:where(.context-menu-layers iframe) {
inline-size: 100%;
min-block-size: 6rem;
}
:where(.context-menu-layers__diagnostics) {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
"""
preview = ContextMenuLayersAndRoots()
preview # noqa: B018
Anchor to the accepted point
Pointer requests use the trusted event's viewport point. Keyboard and external owner-open requests derive a visible logical start and block-end point from the focused descendant or target. The Menu reads its own computed direction and uses logical bottom-start, native CSS Anchor Positioning, and collision fallbacks.
There is no public coordinate, target selector, Element reference, placement, offset, or positioning-strategy input. A request whose target-derived rect is fully outside the visual viewport is rejected rather than anchored to a stale or arbitrary point.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuPositioningAndRtl(Component):
template = """
<section
class="context-menu-positioning"
style="--cui-menu-inline-size:18rem"
x-data="{
surfaceDir:'ltr',
targetDir:'rtl',
externalOpen:false,
lastPoint:'none',
lastInvocation:'none',
}"
:dir="surfaceDir"
>
<div class="context-menu-positioning__controls">
<button
type="button"
@click="surfaceDir=surfaceDir === 'ltr' ? 'rtl' : 'ltr'"
>Toggle surface direction</button>
<button
type="button"
@click="targetDir=targetDir === 'ltr' ? 'rtl' : 'ltr'"
>Toggle target-only direction</button>
<button
type="button"
@click="
externalOpen=true;
lastInvocation='external';
$nextTick(()=>setTimeout(()=>{
const point=document.querySelector('#context-position-external-point');
const box=point?.getBoundingClientRect();
if (box) lastPoint=`${Math.round(box.x)}, ${Math.round(box.y)}`;
}))
"
>Open from owner state</button>
<button type="button" @click="$refs.repairScroller.scrollTop += 32">
Scroll repair fixture
</button>
<button type="button" @click="window.dispatchEvent(new Event('resize'))">
Resize repair fixture
</button>
<button
type="button"
@click="
const target=document.querySelector('[data-context-menu-offscreen-target]');
target.focus();
target.dispatchEvent(new KeyboardEvent('keydown', {
bubbles:true,
key:'F10',
shiftKey:true,
}))
"
>Test fully offscreen rejection</button>
<output x-text="`Accepted point: ${lastPoint}; invocation: ${lastInvocation}`">
Accepted point: none; invocation: none
</output>
<output
x-text="`Visual viewport: ${Math.round(visualViewport?.width ?? innerWidth)} x
${Math.round(visualViewport?.height ?? innerHeight)} CSS px`"
>Visual viewport diagnostic</output>
</div>
<div class="context-menu-positioning__board">
<c-CContextMenu
aria_label="Top start actions"
$c-props="{
onOpenChange:(next,detail)=>{
if (next) {
lastPoint=`${Math.round(detail.clientX)}, ${Math.round(detail.clientY)}`;
lastInvocation=detail.reason === 'contextmenu' ? 'pointer' : detail.reason;
}
},
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-positioning__target is-top-start"
tabindex="0"
:dir="targetDir"
c-bind="target_attrs"
>Top start</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect corner</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate record</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<c-CContextMenu aria_label="Top end actions">
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-positioning__target is-top-end"
tabindex="0"
c-bind="target_attrs"
>Top end</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect corner</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate record</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<c-CContextMenu aria_label="Bottom start actions">
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-positioning__target is-bottom-start"
tabindex="0"
c-bind="target_attrs"
>Bottom start</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect corner</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate record</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<c-CContextMenu
id="context-position-external"
aria_label="Bottom end actions"
$c-props="{
open:externalOpen,
onOpenChange:(next,detail)=>{
externalOpen=next;
if (next) {
lastPoint=`${Math.round(detail.clientX)}, ${Math.round(detail.clientY)}`;
lastInvocation=detail.reason === 'contextmenu' ? 'pointer' : detail.reason;
return true;
}
},
}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-positioning__target is-bottom-end"
tabindex="0"
c-bind="target_attrs"
>Bottom end</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect corner</c-CMenuItem>
<c-CMenuItem value="duplicate">Duplicate record</c-CMenuItem>
<c-CMenuItem value="history">Open a deliberately longer command label</c-CMenuItem>
</c-fill>
</c-CContextMenu>
<c-CContextMenu aria_label="Fully offscreen target actions">
<c-fill name="target" data="{ target_attrs }">
<button
class="context-menu-positioning__target is-offscreen"
type="button"
data-context-menu-offscreen-target
c-bind="target_attrs"
>Fully offscreen target</button>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="unreachable">Rejected while fully offscreen</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</div>
<div
class="context-menu-positioning__repair-scroller"
x-ref="repairScroller"
>
<div class="context-menu-positioning__repair-spacer">Scrollable repair boundary</div>
<div class="context-menu-positioning__transformed">
<c-CContextMenu aria_label="Transformed card actions">
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-positioning__target"
tabindex="0"
c-bind="target_attrs"
>Target inside transform, filter, and containment</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="inspect">Inspect transformed card</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</div>
</div>
<p>
Pointer requests use the accepted browser event point. Keyboard and
owner-open requests derive a visible point from the focused target.
The component has no coordinate or placement input. Zoom to 400% to
verify the same collision-safe 18 rem surface.
</p>
</section>
"""
css = """
:where(.context-menu-positioning) {
display: grid;
gap: 1rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-positioning__controls) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
align-items: center;
}
:where(.context-menu-positioning__board) {
position: relative;
min-block-size: 20rem;
overflow: hidden;
border: 1px solid color-mix(in srgb, CanvasText 22%, transparent);
border-radius: 1rem;
background: linear-gradient(
135deg,
color-mix(in srgb, Highlight 10%, Canvas),
Canvas
);
}
:where(.context-menu-positioning__target) {
padding: 0.625rem;
border: 1px solid color-mix(in srgb, CanvasText 28%, transparent);
border-radius: 0.5rem;
background: Canvas;
}
:where(.context-menu-positioning__board .context-menu-positioning__target) {
position: absolute;
}
:where(.context-menu-positioning__target:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:where(.context-menu-positioning__target.is-top-start) {
inset-block-start: -0.25rem;
inset-inline-start: -0.25rem;
}
:where(.context-menu-positioning__target.is-top-end) {
inset-block-start: 0.5rem;
inset-inline-end: 0.5rem;
}
:where(.context-menu-positioning__target.is-bottom-start) {
inset-block-end: 0.5rem;
inset-inline-start: 0.5rem;
}
:where(.context-menu-positioning__target.is-bottom-end) {
inset-block-end: -0.25rem;
inset-inline-end: -0.25rem;
}
:where(.context-menu-positioning__target.is-offscreen) {
inset-block-start: -20rem;
inset-inline-start: -20rem;
}
:where(.context-menu-positioning__repair-scroller) {
max-block-size: 9rem;
overflow: auto;
border: 1px dashed color-mix(in srgb, CanvasText 24%, transparent);
}
:where(.context-menu-positioning__repair-spacer) {
min-block-size: 8rem;
padding: 0.5rem;
}
:where(.context-menu-positioning__transformed) {
inline-size: fit-content;
padding: 1rem;
filter: saturate(0.9);
transform: translateX(1rem);
contain: paint;
}
:where(.context-menu-positioning p) {
max-inline-size: 68ch;
margin: 0;
}
"""
preview = ContextMenuPositioningAndRtl()
preview # noqa: B018
Use Menu styling and native fallback
class_, style, and attrs target the ContextMenu host. The host renders no visual box, so set target presentation on the Element that binds target_attrs. Existing --cui-menu-* variables inherit from the host or an ancestor to the inline Menu surface. Existing Menu part selectors customize its surface and items. ContextMenu adds no theme or coordinate variables.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ContextMenuCustomizationAndFallback(Component):
template = """
<section
class="context-menu-customization"
x-data="{orchardEnhanced:false,harborEnhanced:true}"
>
<div class="context-menu-customization__controls">
<button type="button" @click="orchardEnhanced=!orchardEnhanced">
Toggle server-disabled Orchard enhancement
</button>
<button type="button" @click="harborEnhanced=!harborEnhanced">
Disable or restore ready Harbor enhancement
</button>
<output
aria-live="polite"
x-text="`Orchard ${orchardEnhanced ? 'enhanced' : 'native'};
Harbor ${harborEnhanced ? 'enhanced' : 'native'}`"
>Orchard native; Harbor enhanced</output>
</div>
<div class="context-menu-customization__brands">
<article class="context-menu-customization__orchard">
<h3>Orchard</h3>
<c-CContextMenu
class_="brand-context-menu"
aria_label="Orchard file actions"
c-open="True"
c-disabled="True"
c-style="{
'--cui-menu-radius':'1rem',
'--cui-menu-focus-background':'#315f37',
}"
c-attrs="{'data-quality-brand':'orchard'}"
$c-props="{disabled:!orchardEnhanced}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-customization__file"
tabindex="0"
c-bind="target_attrs"
>
<strong>Harvest plan.pdf</strong>
<span>Server-open fallback</span>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="open">Open file</c-CMenuItem>
<c-CMenuItem value="archive">Archive file</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</article>
<article
class="context-menu-customization__harbor"
style="color-scheme:dark"
>
<h3>Harbor</h3>
<c-CContextMenu
class_="brand-context-menu"
aria_label="Harbor file actions"
size="lg"
c-style="{
'--cui-menu-background':'#173c4c',
'--cui-menu-foreground':'#eefaff',
'--cui-menu-border-color':'#72b5ce',
}"
c-attrs="{'data-quality-brand':'harbor'}"
$c-props="{disabled:!harborEnhanced}"
>
<c-fill name="target" data="{ target_attrs }">
<div
class="context-menu-customization__file"
tabindex="0"
c-bind="target_attrs"
>
<strong>Dock schedule.csv</strong>
<span>Server-closed fallback</span>
</div>
</c-fill>
<c-fill name="menu">
<c-CMenuItem value="open">Open file</c-CMenuItem>
<c-CMenuItem value="remove" intent="danger">Remove file</c-CMenuItem>
</c-fill>
</c-CContextMenu>
</article>
</div>
<p>
Without JavaScript, targets remain ordinary native content. A
server-closed Menu stays hidden and a server-open Menu remains readable
in document flow. The browser context menu remains available until a
valid enhanced request is accepted.
</p>
</section>
"""
css = """
:where(.context-menu-customization) {
display: grid;
gap: 1rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.context-menu-customization__brands) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
}
:where(.context-menu-customization__controls) {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
:where(.context-menu-customization article) {
display: grid;
gap: 0.75rem;
padding: 1rem;
border-radius: 1rem;
}
:where(.context-menu-customization h3,
.context-menu-customization p) {
margin: 0;
}
:where(.context-menu-customization__orchard) {
background: #f5f0df;
color: #203422;
--cui-menu-background: #fffdf5;
--cui-menu-foreground: #203422;
--cui-menu-border-color: #78916d;
}
:where(.context-menu-customization__harbor) {
background: #102b38;
color: #eefaff;
}
:where(.context-menu-customization__file) {
display: grid;
gap: 0.25rem;
padding: 1rem;
border: 1px solid currentColor;
border-radius: 0.75rem;
}
:where(.context-menu-customization__file:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.context-menu-customization
.brand-context-menu[data-citry-ui-part="context-menu"]
[data-citry-ui-part="menu"] {
border-width: 2px;
}
@media (forced-colors: active) {
:where(.context-menu-customization article) {
border: 1px solid CanvasText;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.context-menu-customization) {
scroll-behavior: auto;
}
}
@media print {
:where(.context-menu-customization article) {
background: transparent;
color: black;
}
}
"""
preview = ContextMenuCustomizationAndFallback()
preview # noqa: B018
Without JavaScript, the target stays ordinary native content. A server-closed Menu remains hidden through native Popover presence, while an initially open Menu remains readable in document flow. Before successful initialization, ContextMenu does not suppress native requests. Capability loss after initialization closes the enhanced Menu before removing its point.
Distinguish callbacks from native events
onOpenChange and onAction are component callbacks supplied through $c-props. onOpenChange describes requests and forced closes; onAction uses the existing CMenuActionDetail. ContextMenu dispatches no custom DOM event.
Native events remain Alpine listeners in allowed attrs or target content. The ContextMenu root has Citry's isolated expression scope, so an attrs listener cannot read ancestor-local x-data identifiers directly. Use $event, $dispatch, $store, or an explicit global bridge. Use component callbacks for owner-local state.
The component owns contextmenu, ContextMenu/Shift+F10 keydown, and its touch/pen pointer sequence. It does not stop propagation on those paths. Only the exact trusted click derived from an accepted synthetic long press is prevented and stopped immediately so the target's primary action cannot also run.
Keep target and host attributes separate
target_attrs is copied, validated, and included in the target slot data. It may carry ordinary classes, styles, safe ARIA, semantic native attributes, nonreserved data, and unrelated native listeners. It cannot author the target ID, ContextMenu marker, owned invocation events, role, native disabled, Popover/anchor state, or Menu Button ARIA.
Host attrs accepts ordinary descriptive attributes, dir, lang, nonreserved data, and unrelated native listeners. It cannot replace owned identity, roles, ARIA, parts, reflections, lifecycle, Popover/anchor state, or Citry runtime namespaces. Mappings are copied once. ContextMenu trusts ordinary slot content as application content; it is not an HTML or URL sanitizer.
CContextMenu is not Form-associated. It emits no name/value pair and does not participate in reset or constraint validation. Target descendants keep their native Form behavior. A target Button may submit or reset on primary activation; only the exact derived click from an accepted synthetic long press is suppressed.
API reference
Inputs
CContextMenu server inputs
Server inputs are passed in a template through <c-CContextMenu ... /> or in Python through CContextMenu(...).
| Input | Type | Default | Effect |
|---|---|---|---|
id | str | None | generated | Sets the correlated host, target, point, Menu surface, declaration, and submenu ID family. |
aria_label | non-whitespace str | required | Supplies the root Menu surface accessible name without relabelling the arbitrary target. |
open | bool | False | Sets server and uncontrolled Menu visibility. |
disabled | bool | False | Blocks custom invocation and force-closes an open Menu while preserving native context behavior. |
loop | bool | True | Selects existing CMenu arrow and typeahead wrapping. |
close_on_select | bool | True | Selects the existing root CMenu action-close policy. |
size | "sm" | "md" | "lg" (CMenuSize) | "md" | Selects existing CMenu item geometry. |
class_ | CClassValue | None (CClassValue) | None | Adds host classes and merges them with attrs. |
style | CStyleValue | None (CStyleValue) | None | Adds host styles; inherited Menu variables reach the inline surface. |
attrs | Mapping[str, object] | None | None | Adds copied allowed host attributes and isolated-scope unrelated native listeners. |
target_attrs | Mapping[str, object] | None | None | Adds copied allowed attributes to the exact bound target mapping without replacing owned identity or invocation behavior. |
CContextMenu client inputs
Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CContextMenu />.
| Input | Type | Omitted behavior | Effect |
|---|---|---|---|
open | boolean | null | Releases control from the current committed visibility; null has the same effect. | Controls Menu visibility while supplied as a Boolean. |
disabled | boolean | Uses the server input. | Controls custom invocation disabledness; native target and fieldset disabledness remain authoritative. |
loop | boolean | Uses the server input. | Controls existing CMenu navigation wrapping. |
closeOnSelect | boolean | Uses the server input. | Controls the existing root CMenu action-close policy. |
size | "sm" | "md" | "lg" (CMenuSize) | Uses the server input. | Controls existing CMenu item geometry. |
onOpenChange | function | Omission or null selects no visibility callback and refuses a controlled native-default claim. | Receives visibility requests and forced closes; only a synchronous literal-true return can claim a controlled closed-to-open native request. |
onAction | function | Omission or null selects no root action callback. | Receives existing valued CMenu command and choice activations. |
Slots
Slots are passed as nested content or <c-fill> tags in a template, or through the slots={...} argument in Python.
CContextMenu slots
| Slot | Required | Data | Fallback |
|---|---|---|---|
target | yes | {target_attrs} (CContextMenuTargetSlotData) | None. Must settle to exactly one direct standard native Element with the complete target_attrs mapping bound. |
menu | yes | {} (CContextMenuMenuSlotData) | None. Requires one or more direct existing CMenu declarations under the existing collection rules. |
Events
Component events are callback inputs supplied through $c-props. Native browser events remain available through Alpine @... attributes.
CContextMenu events
| Event | Signature | Trigger and timing | Detail | Controlled and cancellation behavior |
|---|---|---|---|---|
onOpenChange | (requestedOpen: boolean, detail: CContextMenuOpenChangeDetail) => boolean | void (CContextMenuOpenChangeDetail) | A trusted contextual request, Menu dismissal, or forced native, disabled, or ancestor close occurs. | {reason, controlled, forced, source, clientX, clientY} (CContextMenuOpenChangeDetail) | Candidate coordinates accompany refused controlled requests. A controlled closed-to-open request is claimed only when the callback synchronously sets owner open state and returns literal true; all other returns refuse without preventing the native default. |
onAction | (value: string, detail: CMenuActionDetail) => void (CMenuActionDetail) | An enabled valued CMenu command, checkbox, or radio activates. | {kind, item, event, path} (CMenuActionDetail) | Uses exact CMenu callback order and action detail. ContextMenu adds no target or point fields to action detail. |
Methods
-
CSS
CSS variables to theme the components. Set them on an ancestor or the component itself.
CContextMenu CSS variables
Apply these variables to CContextMenu or one of its ancestors.
| Variable | Type | Purpose | Default |
|---|---|---|---|
--cui-menu-background | color | Root and submenu Menu surfaces. | Canvas |
--cui-menu-foreground | color | Menu item text. | CanvasText |
--cui-menu-muted-color | color | Descriptions, labels, and shortcuts. | color-mix(in srgb, current foreground 72%, transparent) |
--cui-menu-border-color | color | Menu surface and separator boundaries. | color-mix(in srgb, CanvasText 18%, transparent) |
--cui-menu-border-width | length | Menu surface boundary width. | 1px |
--cui-menu-radius | length | Menu surface corners. | 0.75rem |
--cui-menu-shadow | shadow | Root Menu elevation. | 0 0.75rem 2rem rgb(15 23 42 / 18%) |
--cui-menu-submenu-shadow | shadow | Nested Menu elevation. | 0 1rem 2.5rem rgb(15 23 42 / 22%) |
--cui-menu-inline-size | length | Preferred Menu width. | 14rem |
--cui-menu-min-inline-size | length | Minimum useful submenu corridor. | 10rem |
--cui-menu-max-inline-size | length | Viewport-safe Menu width. | calc(100dvi - 1rem) |
--cui-menu-max-block-size | length | Menu scroll limit. | min(24rem, calc(100dvb - 1rem)) |
--cui-menu-padding | length | Menu surface edge spacing. | 0.375rem |
--cui-menu-item-block-size | length | Menu item minimum height. | Size-derived. |
--cui-menu-item-padding-inline | length | Menu item inline spacing. | Size-derived. |
--cui-menu-item-gap | length | Menu item-region gap. | 0.625rem |
--cui-menu-item-radius | length | Menu item corners. | 0.5rem |
--cui-menu-hover-background | color | Enabled pointer-hover fill. | color-mix(in srgb, CanvasText 8%, transparent) |
--cui-menu-focus-background | color | Focused Menu item fill. | light-dark(#175cd3, #84adff) |
--cui-menu-focus-foreground | color | Focused Menu item content. | light-dark(#ffffff, #101828) |
--cui-menu-focus-outline-color | color | Menu item focus-visible outline. | light-dark(#175cd3, #84adff) |
--cui-menu-danger-color | color | Destructive Menu item content. | light-dark(#b42318, #fda29b) |
--cui-menu-disabled-opacity | number | Disabled Menu content opacity. | 0.5 |
--cui-menu-offset | length | Context point to root Menu gap. | 0.375rem |
--cui-menu-submenu-offset | length | Nested Menu anchor gap. | 0.25rem |
--cui-menu-duration | time | Menu entry and exit duration. | 120ms |
--cui-menu-easing | easing | Menu entry and exit curve. | cubic-bezier(0.2, 0.8, 0.2, 1) |
Attributes
HTML attributes defined on the components that you can refer to for CSS, inspection, and testing. Read-only.
CContextMenu attributes
| Attribute | Element | Type | Meaning |
|---|---|---|---|
id | ContextMenu host | supplied or generated string | Identifies the host and bases the correlated target, point, surface, and declaration IDs. |
data-open | ContextMenu host | present | absent | Mirrors committed root Menu visibility. |
data-disabled | ContextMenu host | present | absent | Mirrors effective component, native target, and fieldset disabledness. |
data-size | ContextMenu host | "sm" | "md" | "lg" (CMenuSize) | Mirrors effective CMenu item geometry. |
data-invocation | ContextMenu host | "pointer" | "keyboard" | "long-press" | "external" | absent | Identifies the latest accepted invocation only while the Menu is open. |
CContextMenu attributes
| Attribute | Element | Type | Meaning |
|---|---|---|---|
id | Bound target Element | root ID plus -target | Uses the exact owned target identity from target_attrs. |
data-citry-context-menu-native | Target or descendant | present | absent | Preserves the browser context menu for a consumer-declared native path. |
CContextMenu attributes
| Attribute | Element | Type | Meaning |
|---|---|---|---|
id | Root Menu surface | root ID plus -menu | Uses the exact owned root Menu identity. |
popover | Root and submenu Menu surfaces | "manual" | Uses native top-layer presence with Citry dismissal. |
role | Root and submenu Menu surfaces | "menu" | Exposes application Menu semantics. |
aria-label | Root Menu surface | non-whitespace string | Uses required aria_label without naming the arbitrary target as a Menu Button. |
data-open | Root and submenu Menu surfaces | present | absent | Mirrors logical Menu visibility. |
data-placement | Root Menu surface | "bottom-start" | Mirrors the fixed requested logical point placement rather than collision result. |
data-size | Root Menu surface | "sm" | "md" | "lg" (CMenuSize) | Mirrors effective Menu item geometry. |
CContextMenu attributes
| Attribute | Element | Type | Meaning |
|---|---|---|---|
role | Menu item, group, separator, submenu trigger, or submenu surface | CMenu-owned role | Uses existing CMenu command, choice, group, separator, and submenu semantics. |
aria-labelledby | Menu item or labelled group | owned label IDREF | absent | Uses existing CMenu exact visible labels. |
aria-describedby | Menu item root | description IDREF | absent | Uses an optional separate CMenu item description. |
aria-disabled | Menu item root | "true" | absent | Represents a focusable inactive CMenu item. |
data-disabled | Menu item root | present | absent | Mirrors effective CMenu item disabledness. |
data-intent | Menu item root | "default" | "danger" | Mirrors existing CMenu item emphasis. |
aria-checked | Checkbox or radio item | "false" | "true" | "mixed" | Uses effective CMenu choice state; radio items never use mixed. |
data-checked | Checkbox or radio item | "false" | "true" | "mixed" | Mirrors effective CMenu choice state. |
aria-haspopup | Submenu trigger | "menu" | Announces an existing CMenu child surface. |
aria-controls | Submenu trigger | child Menu IDREF | References the existing child Menu surface. |
aria-expanded | Submenu trigger | "true" | "false" | Mirrors existing child Menu visibility. |
Selectors
Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.
CContextMenu selectors
| Selector | Element | Purpose |
|---|---|---|
[data-citry-ui-part="context-menu"] | Host div | Lifecycle owner and class_, style, and attrs destination; it has no visual box. |
[data-citry-ui-part="menu"] | Root or submenu Menu surface | Existing CMenu Popover presence and collection focus. |
[data-citry-ui-part="menu-item"] | Command, link, checkbox, or radio root | Existing CMenu item styling. |
[data-citry-ui-part="menu-item-start"] | Decorative item wrapper | Existing logical-start content. |
[data-citry-ui-part="menu-item-label"] | Visible item label | Existing layout and exact accessible-name target. |
[data-citry-ui-part="menu-item-description"] | Optional item description | Existing supporting text and accessible description. |
[data-citry-ui-part="menu-item-end"] | Decorative item wrapper | Existing shortcut or logical-end content. |
[data-citry-ui-part="menu-choice-indicator"] | Decorative choice marker | Existing checkbox and radio state. |
[data-citry-ui-part="menu-group"] | Labelled group root | Existing grouped-command layout. |
[data-citry-ui-part="menu-group-label"] | Visible group label | Existing exact group name and layout. |
[data-citry-ui-part="menu-radio-group"] | Radio-group root | Existing exclusive choice grouping. |
[data-citry-ui-part="menu-separator"] | Horizontal separator | Existing collection division. |
[data-citry-ui-part="menu-submenu"] | Neutral submenu wrapper | Existing child trigger and surface ownership. |
[data-citry-ui-part="menu-submenu-trigger"] | Submenu Button | Existing child Menu activation and placement anchor. |
Interfaces
Aliases and data shapes referenced above.
Input type aliases
| Interface | Definition |
|---|---|
CClassValue | str | Mapping[str, bool] | Sequence[CClassValue] |
CStyleValue | str | Mapping[str, str | int | float | bool | None] | Sequence[CStyleValue] |
CMenuSize | Literal["sm", "md", "lg"] |
CContextMenuTargetSlotData
| Field | Type | Default | Meaning |
|---|---|---|---|
target_attrs | dict[str, object] | - | Generated target ID, private ownership marker, and the validated copied target_attrs mapping. |
CContextMenuMenuSlotData
Empty dataclass: {}.
CContextMenuOpenChangeDetail
| Field | Type | Default | Meaning |
|---|---|---|---|
reason | "contextmenu" | "keyboard" | "long-press" | "escape" | "outside" | "focus-outside" | "tab" | "action" | "native" | "disabled" | "ancestor" | - | Cause of the requested or forced visibility change. |
controlled | boolean | - | Whether a valid client Boolean owns desired Menu visibility. |
forced | boolean | - | Whether native or structural safety overrides owner refusal. |
source | Element | EventTarget | null | - | Responsible composed-path target, focused Element, item, surface, target, or ancestor when still connected. |
clientX | float | - | Candidate or latest committed visual-viewport-clamped x coordinate. |
clientY | float | - | Candidate or latest committed visual-viewport-clamped y coordinate. |
CMenuActionDetail
| Field | Type | Default | Meaning |
|---|---|---|---|
kind | "command" | "checkbox" | "radio" | - | Activated semantic CMenu item kind. |
item | Element | - | Activated CMenu item root. |
event | Event | - | Native CMenu activation event. |
path | list[str] | - | Canonical ancestor-submenu path from the ContextMenu root Menu. |
Translation keys
-