Dropdown Menu
A menu in the top layer — and it opens without a line of JavaScript.
src/components/rahti_ui/dropdown_menu.rs
Basic
A trigger, a menu, and two attributes doing what Radix needs a portal and a dismiss layer for.
shadcn's own demo. The trigger is a `<button popovertarget="…">` and the menu is a `popover` — that pair is the whole of the open and close, and none of it is JavaScript.
`popovertarget` takes an id, and the trigger and the menu are separate components that cannot see each other — so the pairing is a `menu` name written on both. It becomes `id="dropdown-menu-account-demo"` on one and `popovertarget` on the other, and it has to be unique in the document and a valid CSS identifier, because it is also the anchor name.
`Account`, `Billing` and `Notifications` have an `href`, so they are `<a role="menuitem">` — links the browser can already focus and activate. `Upgrade to Pro` and `Log out` do not, so they are shadcn's `<div>` and the script supplies `Enter` and `Space`.
Sides
Where the menu opens, said twice.
Radix's `side` and `align`, and they place the menu twice over. Where the browser has CSS anchor positioning it is an inline `position-anchor` and `position-area` and no script runs at all; elsewhere the script measures the trigger and writes `left`/`top`. Both flip when the menu would run off screen.
They are inline styles rather than classes because the anchor name varies per menu, and Tailwind can only emit classes it finds spelled out in the source — the same wall the Tabs hit when a CSS-only tab set turned out to need a rule per tab value.
Checkboxes and radios
Rows with something on their left.
A ticked row and a chosen row. Both are `aria-checked` on a `role="menuitemcheckbox"` or `menuitemradio`, and the tick or the dot is drawn by the server rather than by an indicator that mounts — which is why `checked` is a prop and not a state.
The `Position` label is `inset`, so it lines up with the rows' text rather than with their ticks. That is shadcn's `data-[inset]:pl-8`, and it works on a label as well as on an item.
Submenus
The same two attributes, one level down — and the two behaviours the platform does not give away.
A submenu is the same mechanism again: its own `menu` name, its own `popovertarget`, its own `popover`. Nested popovers stay open when their invoker is inside the one above, which is the light-dismiss behaviour the platform already defines.
The row is a `<button>`, and that is not a matter of taste: `popovertarget` is only an attribute of `<button>` and the button-ish `<input>`s, so on the `<div role="menuitem"` shadcn renders it is inert — the property reads `null` and nothing happens. It costs `w-full` and `text-left`, because a button is shrink-to-fit even at `display: flex`.
Hover it and the submenu opens with no click, which is the one thing here the platform has no attribute for. `popovertarget` is click-only, so the script opens it on `pointermove` — and the trigger carries `popovertargetaction="show"` so the click that follows a hover cannot toggle shut what the pointer just opened.
Moving to another row closes it, but not immediately: the diagonal from this row to the menu beside it clips the row underneath on the way, and closing on that would make the submenu unreachable. There is a 150ms grace period, and entering any menu cancels it.
What the platform does
Most of Radix's DropdownMenu, in two attributes.
The top-layer row is not a nicety. This component exists to be used inside a Sidebar, whose content pane clips — a menu that is not in the top layer is a menu with its bottom half cut off. Radix needs a portal for the same reason.
So the script is left with two jobs: the arrow keys, and placement in the browsers without anchor positioning. It also moves focus into the menu when it opens and back to the trigger when it closes, which a `<dialog>` does for free and a popover does not.
The port
What crossed, and what the popover made unnecessary.
The menu on this page carries `id="dropdown-menu-account-demo"`. That id is the whole API surface between the two halves, which is why the name 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.