Overlays

Floating UI — dropdowns, dialogs, tooltips, context menus — needs to escape its parent’s clipping and layout, and sit above everything else for hit testing. Portal is the one core primitive that provides this; Modal and Tooltip (in yui::widgets) are built entirely on top of it, with no special core support of their own.

Portal

VNode Portal(std::vector<Child> children);
VNode Portal(Cs&&... children);   // variadic, same as Box
Portal(backdrop)
    .trapFocus();   // scope Tab to the portal's content; save/restore focus

A Portal’s content reconciles in its logical parent — where it’s declared in the tree — so component state, hooks, and element refs behave exactly as if it weren’t in a portal at all. But it is laid out against the viewport rather than its parent’s box, and painted and hit-tested at root z-order, escaping any ancestor’s clip and sitting above the rest of the tree. Position its content with .positionType(PositionType::Absolute) plus .positionLeft/positionTop(...) in root-space (viewport) coordinates — the same coordinates NodeRef::getBoundingRect() and the placement helpers already work in.

The Portal node itself occupies zero space in its parent’s layout (no gap slot, no size) — it’s pure plumbing. It has no visual setters; all visuals belong to its content.

.trapFocus(bool = true) scopes Tab traversal to the portal’s content while it’s mounted. Mounting saves whatever was focused beforehand; unmounting (or clearing the trap) restores it. This is exactly what Modal uses to give a dialog focus-trap behavior with zero extra wiring — see Focus.

When multiple portals are open at once (e.g. a Select popup opened from inside a Modal), they layer in document order: a portal declared later in a pre-order scan of the main tree paints above one declared earlier, and a portal nested inside another portal’s content layers above its spawner. Paint order and hit-test order are guaranteed to agree.

ModalBuilder Modal(std::vector<Child> children);
ModalBuilder Modal(Cs&&... children);   // variadic
When(showDialog, Modal(
    Text("Delete this item?"),
    Row(
        Button("Cancel", [&] { setShowDialog(false); }),
        Button("Delete", [&] { deleteItem(); setShowDialog(false); })
    ).gap(8)
)
    .onDismiss([&] { setShowDialog(false); })
    .dismissOnBackdropClick(true)      // default true
    .dismissKeyCode(SDLK_ESCAPE)       // unset = no key dismiss
    .backdropColor(0x00000088u));      // default: half-transparent black

Modal(...) is Portal(backdrop-wrapped-panel).trapFocus() — nothing more. Open/close is entirely app state: render the Modal while it should be open, stop rendering it to close. Mounting arms the focus trap (and saves the previously-focused node); unmounting restores that focus automatically.

The scrim is a full-viewport backdrop; your children are wrapped in a panel that’s a child of the backdrop (so the backdrop centers it) with a consuming onClick — a click inside the dialog bubbles toward the backdrop and is swallowed there, while a click beside the dialog hits the backdrop directly and calls onDismiss (when dismissOnBackdropClick is true; when false, the backdrop still consumes the click, so the modal stays modal but the click does nothing).

.dismissKeyCode(...) is app-supplied for the same reason every widget keycode is: core doesn’t know whether you’re running under SDL or GLFW. The widget never closes itself — onDismiss is a request, not a command; your app decides what “closed” means (usually flipping a bool).

Tooltip

TooltipBuilder Tooltip(std::vector<Child> target);
TooltipBuilder Tooltip(Cs&&... target);   // variadic
Tooltip(
    Box(Text("?")).padding(4)
)
    .tip("Enables verbose logging")   // convenience: default-styled one-line text
    .delayMs(400)                     // unset = render_defaults::kHoverDelayMs
    .tipSize(160, 24);                // helps placement clamp fully on-screen

Or supply arbitrary tip content instead of a string:

Tooltip(icon).tip(Column(Text("Shortcut"), Text("Ctrl+K")).gap(2));

Unlike Modal, Tooltip is a stateful Component — visibility and position are internal state, set by an onHoverDelay callback and cleared on hover leave — so it converts to Child only, not VNode.

The tip’s Portal is declared inside the target wrapper deliberately: portal content hover-walks through its logical parent, so the wrapper is the lowest common ancestor of any pointer travel between the target and the tip. That means moving from the target onto the tip itself doesn’t fire onHover(false) mid-travel and flicker the tip away — only leaving both dismisses it. Placement is computed in the hover-delay callback (not during render, since element refs read null then — see useElementRef) by reading the wrapper’s drawn rect and calling the same layout::placePanel helper Select’s popup uses, clamped against the render root as the viewport. The tip sits flush against the target’s bottom edge (zero gap) — a nonzero gap would open a dead zone the pointer could pass through mid-drag, causing the tip to unmount before it’s reached.

See Integration Notes — floating-panel placement for placePanel/placeSubmenu directly, if you’re building a custom overlay that isn’t quite Modal or Tooltip (e.g. a cascading context menu).