Context Menu
The dropdown menu, opened by a right click and placed on the pointer.
src/components/rahti_ui/context_menu.rs
The menu
A right click anywhere in the dashed box, and the menu opens on the pointer.
shadcn's own demo, part for part. The trigger carries no class list of its own — the dashed box is `className`, exactly as on the docs page — and the two labels are shadcn's `pointer-fine:` and `pointer-coarse:` pair.
The trigger and the menu are paired by a `menu` name, which becomes `data-menu` on one and `id` on the other. It is not `popovertarget`: that attribute opens on a click and only works on a `<button>`, and this trigger is any element opening on `contextmenu`.
Right click a second time and the menu moves rather than stacking. Click outside it, or press Escape, and it closes — neither of those is script, both are `popover="auto"`.
Sides
Which way the menu goes from the point that was clicked.
shadcn's `side` demo. The anchor is the point the pointer chose, so it is a rect of zero width and height — and the DropdownMenu's placement arithmetic then applies unchanged, `align` included.
Click near an edge and the menu flips rather than running off screen, and `data-side` is rewritten when it does — so the enter animation slides from the side it actually opened on rather than the one it was asked for.
Rows
Ticks, dots, headings, shortcuts, icons and a destructive row.
A tick and a dot, both server-rendered from `checked` — the row's state is `aria-checked`, and the indicator is the whole of what shadcn draws.
The second `People` heading is `inset`, which is `data-[inset]:pl-8` — a heading over a run of ticked rows lines up with their text rather than with their ticks.
`Open in new tab` has an `href`, so it renders as `<a role="menuitem">` rather than shadcn's `<div>` and the browser activates it. The rest are divs, and the script supplies Enter and Space for them.
Submenus
The same rows one level down, opened the way the dropdown opens them.
A submenu is the one half of this component that keeps the DropdownMenu's declarative machinery whole. Its row is a `<button popovertarget="…">`, so it opens on a click with no JavaScript, and CSS anchor positioning places it — the parent menu is a point on the screen, but a submenu has a real element to sit beside.
Nested popovers stay open when their invoker is inside the one above, which is the light-dismiss behaviour the platform already defines. Two levels deep is the same rule twice.
Hover opens it, which is the one thing the platform has no attribute for — `popovertarget` is click-only. Moving to another row closes it after a 150ms grace period, because the diagonal from this row to the menu beside it clips the row underneath on the way.
Keyboard and touch
The two ways in that are not a right click.
This trigger carries `tabindex="0"` through `attrs`, because shadcn's does not and a `<div>` with no focusable content is a `<div>` the keyboard cannot reach. Where the area already holds a link or a field, the key works without it.
The focus is given back when the menu closes — to the parent menu when a submenu closes, and to the trigger otherwise. A `<dialog>` does that for free; a popover does not.
The port
What crossed, what the popover made unnecessary, and the one thing that could not be declarative.
The first menu on this page carries `id="context-menu-demo"`, and its trigger `data-menu="context-menu-demo"`. That name is the whole API surface between the two halves, which is why it has to be unique — and why a component that renders its children twice, as the Sidebar does for its mobile drawer, would otherwise put two menus in one document under one name. The script renames the second at mount.
Two numbers worth naming: the menu sits 0px from the point that opened it, where the Dropdown Menu's sits 4px from its trigger — Radix's `sideOffset` default, and where a native context menu puts its corner. And a long press is 700ms, which is Radix's.
Two class strings differ from the Dropdown Menu's, and both differences are shadcn's rather than the port's: this label carries `text-foreground`, and this sub-trigger has no `gap-2`.