Skip to main content

teksilo_widgets/
focus_scope.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `FocusScope` — a layout-transparent wrapper that declares a **traversal
5//! boundary** for Tab / Shift+Tab focus cycling.
6//!
7//! Descendants' `tab_index` values are scoped to the nearest enclosing
8//! `FocusScope`: two sibling scopes that both number their children `1, 2, 3`
9//! never interleave — each scope is an independent, ordered unit within its
10//! parent. The [`TraversalScopePolicy`] controls what Tab does at the scope's
11//! ends:
12//!
13//! - [`Continue`](TraversalScopePolicy::Continue) — Tab flows *out* of the
14//!   scope into the enclosing scope's next member (grouping only). Use for
15//!   logical regions in a continuous Tab order, e.g. dock panels.
16//! - [`Cycle`](TraversalScopePolicy::Cycle) — Tab *wraps* within the scope and
17//!   never leaves via keyboard. Use for modal dialogs.
18//!
19//! ```ignore
20//! // A modal dialog whose Tab order is confined to its own content:
21//! FocusScope::new(TraversalScopePolicy::Cycle).child(dialog_body)
22//! ```
23//!
24//! **Do not `Cycle`-wrap a popover, menu or dropdown panel.** Those are
25//! non-modal, and the framework dismisses a non-modal overlay when keyboard
26//! focus leaves it — which is what their ARIA patterns (Disclosure, Menu) ask
27//! for, and what keeps an open panel from sitting over the focus ring that
28//! left it. Trapping focus inside one prevents that dismissal from ever
29//! firing. A centered modal needs no wrapper at all: `cycle_focus` already
30//! roots traversal at the topmost centered overlay's content.
31//!
32//! ## Layout & accessibility
33//!
34//! `FocusScope` imposes no layout — it reports its child's natural size and
35//! places the child at its own bounds (like [`Fade`](crate::Fade)). It is a
36//! structural boundary, not an AT element: the wrapped child owns its own
37//! accessibility semantics. The scope node is never itself a Tab stop
38//! (`BuildContext::set_traversal_scope` forces it non-focusable).
39
40use teksilo_canvas::{Point, Rect, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::focus::TraversalScopePolicy;
44use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
45use teksilo_core::widget_id::WidgetId;
46
47/// Wraps a child subtree and declares it a Tab traversal scope. See the
48/// [module documentation](self) for semantics.
49pub struct FocusScope {
50    policy: TraversalScopePolicy,
51    pending_child: Option<PendingChild>,
52    child_id: Option<WidgetId>,
53}
54
55impl FocusScope {
56    /// Create a traversal scope with the given boundary `policy`.
57    pub fn new(policy: TraversalScopePolicy) -> Self {
58        Self {
59            policy,
60            pending_child: None,
61            child_id: None,
62        }
63    }
64
65    /// Inline child widget (deferred insertion — the form `teksu!` lowers to).
66    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
67        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
68        self
69    }
70
71    /// Pre-registered child by `WidgetId`.
72    pub fn child_id(mut self, id: WidgetId) -> Self {
73        self.pending_child = Some(PendingChild::Id(id));
74        self
75    }
76}
77
78impl std::fmt::Debug for FocusScope {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("FocusScope")
81            .field("policy", &self.policy)
82            .finish()
83    }
84}
85
86impl Widget for FocusScope {
87    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
88        if let Some(pending) = self.pending_child.take() {
89            self.child_id = Some(match pending {
90                PendingChild::Id(id) => id,
91                PendingChild::Deferred(w) => ctx.add_boxed(w),
92            });
93        }
94        // Mark this node as a traversal-scope boundary. This also forces the
95        // node non-focusable: a scope is a boundary, never itself a Tab stop.
96        ctx.set_traversal_scope(self.policy);
97        self.child_id.into_iter().collect()
98    }
99
100    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
101        // Layout-transparent: report the child's natural size unchanged.
102        self.child_id
103            .and_then(|id| ctx.child_size(id, proposal))
104            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
105            .into()
106    }
107
108    fn place_children(
109        &self,
110        bounds: Rect,
111        _proposal: SizeProposal,
112        children: &mut [WidgetPlacement],
113        _ctx: &LayoutContext,
114    ) {
115        for child in children.iter_mut() {
116            child.origin = Point::new(bounds.x, bounds.y);
117            child.size = bounds.size();
118        }
119    }
120
121    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
122        // Structural boundary only — the wrapped subtree owns its semantics.
123    }
124
125    fn children(&self) -> Vec<WidgetId> {
126        self.child_id.into_iter().collect()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::primitives::TextWidget;
134    use teksilo_core::widget_tree::WidgetTree;
135    use teksilo_i18n::lit;
136
137    #[test]
138    fn marks_node_with_its_policy() {
139        let mut tree = WidgetTree::new();
140        let scope = tree
141            .add(FocusScope::new(TraversalScopePolicy::Cycle).child(TextWidget::new(lit!("x"))));
142        tree.layout(SizeProposal::exact(100.0, 50.0));
143        assert_eq!(
144            tree.traversal_scope(scope),
145            Some(TraversalScopePolicy::Cycle),
146            "FocusScope::build must install its policy on its own node"
147        );
148    }
149
150    #[test]
151    fn is_layout_transparent() {
152        // The wrapper's bounds match the bare child's — it imposes no layout.
153        let mut tree = WidgetTree::new();
154        let bare = tree.add(TextWidget::new(lit!("hello")));
155        let scoped = tree.add(
156            FocusScope::new(TraversalScopePolicy::Continue).child(TextWidget::new(lit!("hello"))),
157        );
158        tree.layout(SizeProposal {
159            width: Some(300.0),
160            height: None,
161        });
162        assert_eq!(
163            tree.bounds(bare).size(),
164            tree.bounds(scoped).size(),
165            "FocusScope must report its child's natural size"
166        );
167    }
168}