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}