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 teksilo_core::IntoTeksiChild) -> Self {
67 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
68 self
69 }
70 /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
71 ///
72 /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
73 /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
74 /// adds no arena node, so nothing is laid out, painted, or published to the
75 /// accessibility tree, and a stack applies no spacing around it.
76 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
77 match widget {
78 Some(w) => self.child(w),
79 None => self,
80 }
81 }
82}
83
84impl std::fmt::Debug for FocusScope {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("FocusScope")
87 .field("policy", &self.policy)
88 .finish()
89 }
90}
91
92impl Widget for FocusScope {
93 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
94 if let Some(pending) = self.pending_child.take() {
95 self.child_id = Some(match pending {
96 PendingChild::Id(id) => id,
97 PendingChild::Deferred(w) => ctx.add_boxed(w),
98 });
99 }
100 // Mark this node as a traversal-scope boundary. This also forces the
101 // node non-focusable: a scope is a boundary, never itself a Tab stop.
102 ctx.set_traversal_scope(self.policy);
103 self.child_id.into_iter().collect()
104 }
105
106 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
107 // Layout-transparent: report the child's natural size unchanged.
108 self.child_id
109 .and_then(|id| ctx.child_size(id, proposal))
110 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
111 .into()
112 }
113
114 fn place_children(
115 &self,
116 bounds: Rect,
117 _proposal: SizeProposal,
118 children: &mut [WidgetPlacement],
119 _ctx: &LayoutContext,
120 ) {
121 for child in children.iter_mut() {
122 child.origin = Point::new(bounds.x, bounds.y);
123 child.size = bounds.size();
124 }
125 }
126
127 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
128 // Structural boundary only — the wrapped subtree owns its semantics.
129 }
130
131 fn children(&self) -> Vec<WidgetId> {
132 self.child_id.into_iter().collect()
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::primitives::TextWidget;
140 use teksilo_core::widget_tree::WidgetTree;
141 use teksilo_i18n::lit;
142
143 #[test]
144 fn marks_node_with_its_policy() {
145 let mut tree = WidgetTree::new();
146 let scope = tree
147 .add(FocusScope::new(TraversalScopePolicy::Cycle).child(TextWidget::new(lit!("x"))));
148 tree.layout(SizeProposal::exact(100.0, 50.0));
149 assert_eq!(
150 tree.traversal_scope(scope),
151 Some(TraversalScopePolicy::Cycle),
152 "FocusScope::build must install its policy on its own node"
153 );
154 }
155
156 #[test]
157 fn is_layout_transparent() {
158 // The wrapper's bounds match the bare child's — it imposes no layout.
159 let mut tree = WidgetTree::new();
160 let bare = tree.add(TextWidget::new(lit!("hello")));
161 let scoped = tree.add(
162 FocusScope::new(TraversalScopePolicy::Continue).child(TextWidget::new(lit!("hello"))),
163 );
164 tree.layout(SizeProposal {
165 width: Some(300.0),
166 height: None,
167 });
168 assert_eq!(
169 tree.bounds(bare).size(),
170 tree.bounds(scoped).size(),
171 "FocusScope must report its child's natural size"
172 );
173 }
174}