Theme
Version
GitHub PyPI Discord
On this page

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.

Start with a contextual action
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.

Keep one Menu model
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:

  1. Set the owner's open state to true synchronously.
  2. Return the literal Boolean true in 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.

Own visibility without stealing native fallback
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.

Keep browser commands
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.

Bound long press
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.

Return focus deliberately
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.

Share the layer coordinator
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.

Anchor to the accepted 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.

Use Menu styling and native fallback
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(...).

InputTypeDefaultEffect
idstr | NonegeneratedSets the correlated host, target, point, Menu surface, declaration, and submenu ID family.
aria_labelnon-whitespace strrequiredSupplies the root Menu surface accessible name without relabelling the arbitrary target.
openboolFalseSets server and uncontrolled Menu visibility.
disabledboolFalseBlocks custom invocation and force-closes an open Menu while preserving native context behavior.
loopboolTrueSelects existing CMenu arrow and typeahead wrapping.
close_on_selectboolTrueSelects the existing root CMenu action-close policy.
size"sm" | "md" | "lg" (CMenuSize)"md"Selects existing CMenu item geometry.
class_CClassValue | None (CClassValue)NoneAdds host classes and merges them with attrs.
styleCStyleValue | None (CStyleValue)NoneAdds host styles; inherited Menu variables reach the inline surface.
attrsMapping[str, object] | NoneNoneAdds copied allowed host attributes and isolated-scope unrelated native listeners.
target_attrsMapping[str, object] | NoneNoneAdds 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 />.

InputTypeOmitted behaviorEffect
openboolean | nullReleases control from the current committed visibility; null has the same effect.Controls Menu visibility while supplied as a Boolean.
disabledbooleanUses the server input.Controls custom invocation disabledness; native target and fieldset disabledness remain authoritative.
loopbooleanUses the server input.Controls existing CMenu navigation wrapping.
closeOnSelectbooleanUses 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.
onOpenChangefunctionOmission 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.
onActionfunctionOmission 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

SlotRequiredDataFallback
targetyes{target_attrs} (CContextMenuTargetSlotData)None. Must settle to exactly one direct standard native Element with the complete target_attrs mapping bound.
menuyes{} (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

EventSignatureTrigger and timingDetailControlled 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.

VariableTypePurposeDefault
--cui-menu-backgroundcolorRoot and submenu Menu surfaces.Canvas
--cui-menu-foregroundcolorMenu item text.CanvasText
--cui-menu-muted-colorcolorDescriptions, labels, and shortcuts.color-mix(in srgb, current foreground 72%, transparent)
--cui-menu-border-colorcolorMenu surface and separator boundaries.color-mix(in srgb, CanvasText 18%, transparent)
--cui-menu-border-widthlengthMenu surface boundary width.1px
--cui-menu-radiuslengthMenu surface corners.0.75rem
--cui-menu-shadowshadowRoot Menu elevation.0 0.75rem 2rem rgb(15 23 42 / 18%)
--cui-menu-submenu-shadowshadowNested Menu elevation.0 1rem 2.5rem rgb(15 23 42 / 22%)
--cui-menu-inline-sizelengthPreferred Menu width.14rem
--cui-menu-min-inline-sizelengthMinimum useful submenu corridor.10rem
--cui-menu-max-inline-sizelengthViewport-safe Menu width.calc(100dvi - 1rem)
--cui-menu-max-block-sizelengthMenu scroll limit.min(24rem, calc(100dvb - 1rem))
--cui-menu-paddinglengthMenu surface edge spacing.0.375rem
--cui-menu-item-block-sizelengthMenu item minimum height.Size-derived.
--cui-menu-item-padding-inlinelengthMenu item inline spacing.Size-derived.
--cui-menu-item-gaplengthMenu item-region gap.0.625rem
--cui-menu-item-radiuslengthMenu item corners.0.5rem
--cui-menu-hover-backgroundcolorEnabled pointer-hover fill.color-mix(in srgb, CanvasText 8%, transparent)
--cui-menu-focus-backgroundcolorFocused Menu item fill.light-dark(#175cd3, #84adff)
--cui-menu-focus-foregroundcolorFocused Menu item content.light-dark(#ffffff, #101828)
--cui-menu-focus-outline-colorcolorMenu item focus-visible outline.light-dark(#175cd3, #84adff)
--cui-menu-danger-colorcolorDestructive Menu item content.light-dark(#b42318, #fda29b)
--cui-menu-disabled-opacitynumberDisabled Menu content opacity.0.5
--cui-menu-offsetlengthContext point to root Menu gap.0.375rem
--cui-menu-submenu-offsetlengthNested Menu anchor gap.0.25rem
--cui-menu-durationtimeMenu entry and exit duration.120ms
--cui-menu-easingeasingMenu 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

AttributeElementTypeMeaning
idContextMenu hostsupplied or generated stringIdentifies the host and bases the correlated target, point, surface, and declaration IDs.
data-openContextMenu hostpresent | absentMirrors committed root Menu visibility.
data-disabledContextMenu hostpresent | absentMirrors effective component, native target, and fieldset disabledness.
data-sizeContextMenu host"sm" | "md" | "lg" (CMenuSize)Mirrors effective CMenu item geometry.
data-invocationContextMenu host"pointer" | "keyboard" | "long-press" | "external" | absentIdentifies the latest accepted invocation only while the Menu is open.

CContextMenu attributes

AttributeElementTypeMeaning
idBound target Elementroot ID plus -targetUses the exact owned target identity from target_attrs.
data-citry-context-menu-nativeTarget or descendantpresent | absentPreserves the browser context menu for a consumer-declared native path.

CContextMenu attributes

AttributeElementTypeMeaning
idRoot Menu surfaceroot ID plus -menuUses the exact owned root Menu identity.
popoverRoot and submenu Menu surfaces"manual"Uses native top-layer presence with Citry dismissal.
roleRoot and submenu Menu surfaces"menu"Exposes application Menu semantics.
aria-labelRoot Menu surfacenon-whitespace stringUses required aria_label without naming the arbitrary target as a Menu Button.
data-openRoot and submenu Menu surfacespresent | absentMirrors logical Menu visibility.
data-placementRoot Menu surface"bottom-start"Mirrors the fixed requested logical point placement rather than collision result.
data-sizeRoot Menu surface"sm" | "md" | "lg" (CMenuSize)Mirrors effective Menu item geometry.

CContextMenu attributes

AttributeElementTypeMeaning
roleMenu item, group, separator, submenu trigger, or submenu surfaceCMenu-owned roleUses existing CMenu command, choice, group, separator, and submenu semantics.
aria-labelledbyMenu item or labelled groupowned label IDREF | absentUses existing CMenu exact visible labels.
aria-describedbyMenu item rootdescription IDREF | absentUses an optional separate CMenu item description.
aria-disabledMenu item root"true" | absentRepresents a focusable inactive CMenu item.
data-disabledMenu item rootpresent | absentMirrors effective CMenu item disabledness.
data-intentMenu item root"default" | "danger"Mirrors existing CMenu item emphasis.
aria-checkedCheckbox or radio item"false" | "true" | "mixed"Uses effective CMenu choice state; radio items never use mixed.
data-checkedCheckbox or radio item"false" | "true" | "mixed"Mirrors effective CMenu choice state.
aria-haspopupSubmenu trigger"menu"Announces an existing CMenu child surface.
aria-controlsSubmenu triggerchild Menu IDREFReferences the existing child Menu surface.
aria-expandedSubmenu 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

SelectorElementPurpose
[data-citry-ui-part="context-menu"]Host divLifecycle owner and class_, style, and attrs destination; it has no visual box.
[data-citry-ui-part="menu"]Root or submenu Menu surfaceExisting CMenu Popover presence and collection focus.
[data-citry-ui-part="menu-item"]Command, link, checkbox, or radio rootExisting CMenu item styling.
[data-citry-ui-part="menu-item-start"]Decorative item wrapperExisting logical-start content.
[data-citry-ui-part="menu-item-label"]Visible item labelExisting layout and exact accessible-name target.
[data-citry-ui-part="menu-item-description"]Optional item descriptionExisting supporting text and accessible description.
[data-citry-ui-part="menu-item-end"]Decorative item wrapperExisting shortcut or logical-end content.
[data-citry-ui-part="menu-choice-indicator"]Decorative choice markerExisting checkbox and radio state.
[data-citry-ui-part="menu-group"]Labelled group rootExisting grouped-command layout.
[data-citry-ui-part="menu-group-label"]Visible group labelExisting exact group name and layout.
[data-citry-ui-part="menu-radio-group"]Radio-group rootExisting exclusive choice grouping.
[data-citry-ui-part="menu-separator"]Horizontal separatorExisting collection division.
[data-citry-ui-part="menu-submenu"]Neutral submenu wrapperExisting child trigger and surface ownership.
[data-citry-ui-part="menu-submenu-trigger"]Submenu ButtonExisting child Menu activation and placement anchor.

Interfaces

Aliases and data shapes referenced above.

Input type aliases

InterfaceDefinition
CClassValuestr | Mapping[str, bool] | Sequence[CClassValue]
CStyleValuestr | Mapping[str, str | int | float | bool | None] | Sequence[CStyleValue]
CMenuSizeLiteral["sm", "md", "lg"]

CContextMenuTargetSlotData

FieldTypeDefaultMeaning
target_attrsdict[str, object]-Generated target ID, private ownership marker, and the validated copied target_attrs mapping.

CContextMenuMenuSlotData

Empty dataclass: {}.

CContextMenuOpenChangeDetail

FieldTypeDefaultMeaning
reason"contextmenu" | "keyboard" | "long-press" | "escape" | "outside" | "focus-outside" | "tab" | "action" | "native" | "disabled" | "ancestor"-Cause of the requested or forced visibility change.
controlledboolean-Whether a valid client Boolean owns desired Menu visibility.
forcedboolean-Whether native or structural safety overrides owner refusal.
sourceElement | EventTarget | null-Responsible composed-path target, focused Element, item, surface, target, or ancestor when still connected.
clientXfloat-Candidate or latest committed visual-viewport-clamped x coordinate.
clientYfloat-Candidate or latest committed visual-viewport-clamped y coordinate.

CMenuActionDetail

FieldTypeDefaultMeaning
kind"command" | "checkbox" | "radio"-Activated semantic CMenu item kind.
itemElement-Activated CMenu item root.
eventEvent-Native CMenu activation event.
pathlist[str]-Canonical ancestor-submenu path from the ContextMenu root Menu.

Translation keys

-