Skip to content

Commit 55bfb1c

Browse files
author
Roy Lin
committed
Merge branch 'codex/overlay-runtime'
2 parents 7522ea8 + c3033ec commit 55bfb1c

21 files changed

Lines changed: 1672 additions & 26 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ object graph at the host boundary.
2323
stable-key selection with native aggregate snapshots, independent
2424
`onAction(key)` item activation, select-all/clear keyboard commands,
2525
touch/pen long-press selection entry, collection keyboard navigation,
26-
hierarchical tree expansion, inherited locale/direction, versioned capability
27-
reporting, shared native event-source state machines, and accessibility
28-
conformance checks.
26+
hierarchical tree expansion, activation-ordered overlay dismissal, modal
27+
background inertness, nested focus containment/restoration, inherited
28+
locale/direction, versioned capability reporting, shared native event-source
29+
state machines, and accessibility conformance checks.
2930
Role-edge input parity, layout-aware collection page movement, IME/dead-key
30-
conformance, remaining native focus conformance, and locale formatting are
31-
still in progress.
31+
conformance, native overlay positioning/scroll locking, remaining native
32+
focus conformance, and locale formatting are still in progress.
3233
- `.rsx` component source modules with imports, local Rust types, hook
3334
registrations, Rust selector/reducer expressions, and a final `rsx!(...)`
3435
view.

docs/architecture.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,42 @@ escape an active contained scope, and records the pre-mount focus owner for
487487
each restore-enabled scope. When nested scopes unmount, valid restoration
488488
targets unwind from the innermost scope outward through the same imperative
489489
host capability.
490+
491+
## Mounted Overlay Runtime
492+
493+
`MountedOverlayRegistry` is the platform-independent source of truth for open
494+
managed overlays. After keyed reconciliation, `GuiRuntime` synchronizes the
495+
registry from `data-overlay` contracts, preserves already-open overlays in
496+
activation order, and appends newly opened overlays. Persistent sibling nodes
497+
therefore stack by when they became visible rather than by source order.
498+
499+
While any overlay is active, the runtime projects an internal capture marker
500+
to mounted native nodes. The shared native interaction profile translates that
501+
marker into pointer-lifecycle and key-down subscriptions on AppKit, GTK4, and
502+
WinUI. Escape is claimed only by the topmost overlay unless
503+
`isKeyboardDismissDisabled` is set. Outside dismissal records the topmost
504+
overlay on press start and closes it only when release is also outside that
505+
same overlay; intercepted background events do not reach application actions.
506+
Close-on-blur requires a known `relatedTarget` outside the overlay subtree.
507+
All successful dismissal paths synthesize a target-scoped native `Close`
508+
event, which routes to the overlay's `onClose` action without closing an
509+
ancestor layer.
510+
511+
For the topmost active modal, branches outside the modal and any overlays
512+
opened after it are projected inert. Inert projection also removes those
513+
branches from portable accessibility output. Ancestors needed to retain the
514+
native hierarchy stay enabled, but the registry still suppresses interactions
515+
targeted outside the modal foreground. Later portaled overlays are treated as
516+
foreground and may receive focus through an earlier contained scope.
517+
518+
Overlay focus uses the existing `FocusManager` contracts. A newly opened
519+
overlay with `autoFocus` focuses its first tabbable or focusable descendant;
520+
contained focus remains inside the active scope; and unmounting a
521+
restore-enabled overlay returns focus to its valid trigger. `UiDialog`,
522+
`UiModal`, and the default `UiPopover` opt into these behaviors. A nonmodal
523+
popover keeps background branches interactive and does not contain focus,
524+
while still supporting autofocus, restoration, close-on-blur, and Escape.
525+
490526
Non-focus interaction state is
491527
revision-scoped: after a successful rerender, controlled values from the new
492528
blueprint supersede stale local event state while focus remains preserved until

docs/react-aria-native.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ The first shared interaction milestone is available in the portable runtime:
124124
descendant instead of the scope wrapper. Restore-enabled scopes retain the
125125
focus owner that preceded their mount and unwind nested restoration targets
126126
when they unmount.
127+
- `MountedOverlayRegistry` discovers open managed overlays from the reconciled
128+
native tree and retains activation order independently of document order.
129+
Only the topmost overlay handles Escape or outside-press dismissal. Escape
130+
remains available to the focused control when keyboard dismissal is
131+
disabled, and outside dismissal requires both press start and release to
132+
occur outside the same topmost overlay.
133+
- Modal overlays project `inert` onto background branches, which also removes
134+
them from the portable accessibility tree. Structural ancestors stay
135+
available for event routing, and overlays opened later through a separate
136+
portal branch remain interactive foreground layers.
137+
- Overlay autofocus, contained focus, and restoration reuse `FocusManager`.
138+
Dismissal emits a target-scoped native `Close` event, so it invokes only the
139+
topmost overlay's `onClose`. `UiDialog` and `UiModal` expose
140+
`isDismissable` and `isKeyboardDismissDisabled`; `UiPopover` additionally
141+
exposes `isNonModal`, closes on focus leaving its subtree, and defaults to a
142+
modal dismissable popover.
127143
- `GuiRuntime` exposes `request_focus`, `focus_first`, `focus_last`,
128144
`focus_next`, and `focus_previous`. These methods validate mounted
129145
focusability, apply active-scope containment, and send a typed platform
@@ -274,6 +290,7 @@ existence of a platform object:
274290
| Focus within | Portable runtime routing on AppKit, GTK4, WinUI, and headless hosts. Native blur/focus batches are linked with `relatedTarget`; direct focus callbacks remain target-only while focus-within callbacks run only when a subtree boundary is crossed. |
275291
| Interaction style projection | Runtime-resolved hover, press, long-press, move, focus, focus-visible, focus-within, selected, checked, expanded, disabled, validation, read-only, direction, and matching `data-*`/`aria-*` variants use the same transactional `SetPortableStyle` path on all three planning adapters. |
276292
| Focus events, scopes, and `autoFocus` | Native focusable control roles listed in the capability manifest. Runtime navigation, restoration, and post-mount `autoFocus` all emit typed `requestFocus` commands; contained scopes redirect escaping native focus. AppKit uses `makeFirstResponder`, GTK4 uses `grab_focus`, and WinUI calls the fixed `IUIElement::Focus(Programmatic)` ABI through an isolated adapter because the generated binding leaves that method unwrapped. |
293+
| Overlay stack | Activation ordering, topmost Escape and outside-press dismissal, modal background inertness/accessibility suppression, close-on-blur, portaled child overlays, containment, autofocus, and restoration run in the shared mounted runtime. AppKit, GTK4, and WinUI planning adapters receive the same projected props and event subscriptions. |
277294
| Selection and item action | Select/combo box, list box/tree, and tabs/tab list. GTK4 and WinUI ListBox callbacks provide complete native selection snapshots; AppKit modifier-aware row activation and all stable-key aggregation remain in the portable keyed-runtime layer. ListBox/Tree item `onAction(key)` separation and collection keyboard navigation are shared across adapters. |
278295

279296
`NativeCapabilities` is the executable source of truth. Global entries are
@@ -294,7 +311,7 @@ props:
294311
| P1 | Collections and selection | Add layout-aware page navigation and complete IME/dead-key typeahead conformance. |
295312
| P1 | Internationalization | Add message formatting, number/date formatting, locale-aware collation, and localized interaction behavior on top of inherited locale/direction. |
296313
| P1 | Accessibility conformance | Complete OS accessibility API projection, relationships, live regions, value announcements, and role-specific native adapter coverage. |
297-
| P2 | Overlays | Add dismissal, focus restoration, modal containment, outside interaction, and nested overlay ordering. |
314+
| P2 | Overlays | Add anchored positioning/flipping, native scroll locking, configurable outside-interaction filters, multi-window layer coordination, and real-platform conformance fixtures. |
298315
| P2 | Capability enforcement | Turn reported capability gaps into adapter policy and conformance gates where portable fallback is not sufficient. |
299316
| P2 | Environment style variants | Add native environment and ancestry evaluators for responsive/container, theme, group, peer, and structural selector variants. These remain preserved in the style IR but inactive at runtime today. |
300317

docs/roadmap.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ The following foundations are implemented and are not roadmap placeholders:
7575
- a component playground that checks registry coverage
7676
- normalized portable style, Tailwind-compatible variant metadata, and
7777
transactional runtime projection for interaction and typed state variants
78+
- an activation-ordered mounted overlay stack with topmost Escape/outside
79+
dismissal, modal background inertness, portaled foreground layers, and
80+
shared focus containment, autofocus, and restoration
7881
- AppKit, GTK4, and WinUI build/test matrices with real native dogfood surfaces
7982

8083
## Delivery Dependency

docs/rsx.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,36 @@ card parts are under `src/rsx_ui/components/card/`, checkbox parts are under
16331633
- `UiCalendarMonthPicker`
16341634
- `UiCalendarYearPicker`
16351635

1636+
Open `UiDialog`, `UiModal`, and `UiPopover` nodes participate in the mounted
1637+
overlay stack. Only the most recently opened overlay handles Escape and
1638+
outside interaction. `UiDialog` and `UiModal` accept `isDismissable` (default
1639+
`false`) and `isKeyboardDismissDisabled` (default `false`). Escape invokes
1640+
`onClose` independently of `isDismissable`; set
1641+
`isKeyboardDismissDisabled={true}` to leave Escape for the focused control.
1642+
Outside dismissal requires `isDismissable={true}` and suppresses the
1643+
corresponding background press lifecycle.
1644+
1645+
`UiPopover` accepts `onClose`, `isNonModal`, and
1646+
`isKeyboardDismissDisabled`. A normal popover is modal and dismissable,
1647+
contains focus, closes when focus moves outside, and makes background branches
1648+
inert. `isNonModal={true}` keeps the background interactive and disables
1649+
outside-press dismissal and focus containment; close-on-blur, Escape,
1650+
autofocus, and focus restoration remain enabled. `UiModalOverlay` is a
1651+
structural underlay and `UiTooltip` is informational, so neither independently
1652+
joins the managed stack. Dismissal is controlled: the `onClose` action should
1653+
rerender the overlay with `isOpen={false}`.
1654+
1655+
```rsx
1656+
<UiModal
1657+
key="settings-modal"
1658+
isOpen={settings_open}
1659+
isDismissable={true}
1660+
onClose={close_settings}
1661+
>
1662+
<UiButton key="save" onPress={save_settings}>Save</UiButton>
1663+
</UiModal>
1664+
```
1665+
16361666
Collection selection components accept React Aria-style `selectedKeys`,
16371667
`defaultSelectedKeys`, `selectionMode`, `selectionBehavior`, `disabledKeys`,
16381668
`disabledBehavior`, `disallowEmptySelection`, and `shouldFocusWrap` props.

src/compiler.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2862,6 +2862,28 @@ fn i18n_scope_value(props: &CompiledProps) -> GuiResult<JsonValue> {
28622862
}
28632863

28642864
fn overlay_scope_value(props: &CompiledProps) -> GuiResult<JsonValue> {
2865+
let overlay_type = non_empty_attribute(props, &["overlayType", "data-overlay-type"]);
2866+
let is_non_modal =
2867+
bool_attribute_value(props, &["isNonModal", "data-overlay-non-modal"]).unwrap_or(false);
2868+
let managed_by_default = !matches!(overlay_type.as_deref(), Some("tooltip" | "underlay"));
2869+
let modal_by_default = match overlay_type.as_deref() {
2870+
Some("modal" | "dialog") => true,
2871+
Some("popover") => !is_non_modal,
2872+
_ => false,
2873+
};
2874+
let underlay_by_default = matches!(overlay_type.as_deref(), Some("modal"));
2875+
let dismissable_by_default =
2876+
matches!(overlay_type.as_deref(), Some("popover")) && !is_non_modal;
2877+
let close_on_blur_by_default = matches!(overlay_type.as_deref(), Some("popover"));
2878+
let contain_by_default = match overlay_type.as_deref() {
2879+
Some("modal" | "dialog") => true,
2880+
Some("popover") => !is_non_modal,
2881+
_ => false,
2882+
};
2883+
let manages_focus_by_default = matches!(
2884+
overlay_type.as_deref(),
2885+
Some("modal" | "dialog" | "popover")
2886+
);
28652887
use_overlay_value(
28662888
UseOverlayProps::new()
28672889
.open(bool_attribute_value(props, &["isOpen", "open", "data-open"]).unwrap_or(false))
@@ -2874,7 +2896,49 @@ fn overlay_scope_value(props: &CompiledProps) -> GuiResult<JsonValue> {
28742896
.trigger_kind(non_empty_attribute(
28752897
props,
28762898
&["overlayTriggerKind", "triggerKind", "aria-haspopup"],
2877-
)),
2899+
))
2900+
.managed(
2901+
bool_attribute_value(props, &["isManagedOverlay", "data-overlay-managed"])
2902+
.unwrap_or(managed_by_default),
2903+
)
2904+
.modal(
2905+
bool_attribute_value(props, &["isModal", "aria-modal", "data-overlay-modal"])
2906+
.unwrap_or(modal_by_default),
2907+
)
2908+
.underlay(
2909+
bool_attribute_value(props, &["isUnderlay", "data-overlay-underlay"])
2910+
.unwrap_or(underlay_by_default),
2911+
)
2912+
.dismissable(
2913+
bool_attribute_value(props, &["isDismissable", "data-overlay-dismissable"])
2914+
.unwrap_or(dismissable_by_default),
2915+
)
2916+
.keyboard_dismiss_disabled(
2917+
bool_attribute_value(
2918+
props,
2919+
&[
2920+
"isKeyboardDismissDisabled",
2921+
"data-overlay-keyboard-dismiss-disabled",
2922+
],
2923+
)
2924+
.unwrap_or(false),
2925+
)
2926+
.close_on_blur(
2927+
bool_attribute_value(props, &["shouldCloseOnBlur", "data-overlay-close-on-blur"])
2928+
.unwrap_or(close_on_blur_by_default),
2929+
)
2930+
.contain_focus(
2931+
bool_attribute_value(props, &["shouldContainFocus", "contain", "data-contain"])
2932+
.unwrap_or(contain_by_default),
2933+
)
2934+
.restore_focus(
2935+
bool_attribute_value(props, &["restoreFocus", "data-restore-focus"])
2936+
.unwrap_or(manages_focus_by_default),
2937+
)
2938+
.auto_focus(
2939+
bool_attribute_value(props, &["autoFocus", "data-auto-focus"])
2940+
.unwrap_or(manages_focus_by_default),
2941+
),
28782942
)
28792943
}
28802944

src/event/press.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ impl NativeInteractionSubscriptions {
3232
.is_some_and(|action| !action.is_empty()),
3333
);
3434
subscriptions.merge(Self::from_style(&blueprint.portable_style));
35+
subscriptions.merge(Self::from_metadata(&blueprint.metadata));
3536
subscriptions.terminal_press |= has_collection_action(blueprint);
3637
subscriptions.long_press |= has_collection_action(blueprint);
3738
subscriptions
@@ -50,6 +51,17 @@ impl NativeInteractionSubscriptions {
5051
}
5152
}
5253

54+
fn from_metadata(metadata: &BTreeMap<String, String>) -> Self {
55+
let captures_overlay_events = metadata
56+
.get(crate::overlay::OVERLAY_CAPTURE_METADATA_KEY)
57+
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
58+
Self {
59+
press_lifecycle: captures_overlay_events,
60+
key_down: captures_overlay_events,
61+
..Self::default()
62+
}
63+
}
64+
5365
fn from_events(events: &BTreeMap<String, String>, has_action: bool) -> Self {
5466
Self {
5567
terminal_press: has_action || has_event(events, &["onPress", "onClick"]),
@@ -210,6 +222,7 @@ pub(crate) struct NativeInteractionProfile {
210222
pub(crate) subscriptions: NativeInteractionSubscriptions,
211223
event_subscriptions: NativeInteractionSubscriptions,
212224
style_subscriptions: NativeInteractionSubscriptions,
225+
overlay_subscriptions: NativeInteractionSubscriptions,
213226
has_action: bool,
214227
has_terminal_event: bool,
215228
has_long_press_event: bool,
@@ -236,11 +249,14 @@ impl NativeInteractionProfile {
236249
);
237250
let style_subscriptions =
238251
NativeInteractionSubscriptions::from_style(&blueprint.portable_style);
252+
let overlay_subscriptions =
253+
NativeInteractionSubscriptions::from_metadata(&blueprint.metadata);
239254
let mut profile = Self {
240255
role: blueprint.role,
241256
subscriptions: NativeInteractionSubscriptions::default(),
242257
event_subscriptions,
243258
style_subscriptions,
259+
overlay_subscriptions,
244260
has_action,
245261
has_terminal_event,
246262
has_long_press_event,
@@ -277,6 +293,8 @@ impl NativeInteractionProfile {
277293
.get(crate::selection::COLLECTION_ACTION_METADATA_KEY)
278294
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
279295
self.long_press_threshold = long_press_threshold(metadata);
296+
self.overlay_subscriptions =
297+
NativeInteractionSubscriptions::from_metadata(metadata);
280298
self.refresh_subscriptions();
281299
}
282300
NativeWidgetSetter::SetPortableStyle(style) => {
@@ -329,6 +347,7 @@ impl NativeInteractionProfile {
329347
self.has_long_press_event || self.has_collection_action;
330348
self.subscriptions = self.event_subscriptions;
331349
self.subscriptions.merge(self.style_subscriptions);
350+
self.subscriptions.merge(self.overlay_subscriptions);
332351
}
333352
}
334353

@@ -1171,6 +1190,23 @@ mod tests {
11711190
assert!(!profile.subscriptions.terminal_press);
11721191
}
11731192

1193+
#[test]
1194+
fn overlay_capture_marker_subscribes_pointer_lifecycle_and_escape_delivery() {
1195+
let element = NativeElement::new("overlay-capture", NativeRole::View).with_props(
1196+
NativeProps::new().metadata(crate::overlay::OVERLAY_CAPTURE_METADATA_KEY, "true"),
1197+
);
1198+
let blueprint = AppKitAdapter.blueprint(&element);
1199+
let mut profile = NativeInteractionProfile::from_blueprint(&blueprint);
1200+
1201+
assert!(profile.subscriptions.press_lifecycle);
1202+
assert!(profile.subscriptions.key_down);
1203+
assert!(!profile.subscriptions.terminal_press);
1204+
1205+
profile.apply_setter(&NativeWidgetSetter::SetMetadata(BTreeMap::new()));
1206+
assert!(!profile.subscriptions.press_lifecycle);
1207+
assert!(!profile.subscriptions.key_down);
1208+
}
1209+
11741210
#[test]
11751211
fn explicit_long_press_profile_honors_a_bounded_threshold() {
11761212
let element = NativeElement::new("target", NativeRole::Button).with_props(

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub mod native;
4141
all(feature = "winui-native", target_os = "windows")
4242
))]
4343
mod native_backends;
44+
pub mod overlay;
4445
pub mod platform;
4546
pub mod protocol;
4647
pub mod renderer;
@@ -137,6 +138,7 @@ pub use interaction::{
137138
DEFAULT_INTERACTION_CHANGE_HISTORY_LIMIT,
138139
};
139140
pub use native::{ElementKey, NativeElement, NativeProps, NativeRole, ValueSensitivity};
141+
pub use overlay::{MountedOverlayRegistry, NativeOverlay};
140142
pub use platform::{
141143
native_widget_kind, native_widget_name, widget_blueprint, AppKitAdapter, BlueprintHost,
142144
Gtk4Adapter, NativeBackendKind, NativeConfigValueChange, NativeContainerKind,

0 commit comments

Comments
 (0)