Skip to main content

gpui_base/
focus_trap.rs

1use gpui::{
2    AnyElement, App, Bounds, Element, ElementId, FocusHandle, Global, GlobalElementId,
3    InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, Pixels,
4    StatefulInteractiveElement, StyleRefinement, Styled, WeakFocusHandle, Window,
5};
6use std::collections::HashMap;
7
8/// Initialize the focus trap manager as a global
9pub fn init(cx: &mut App) {
10    cx.set_global(FocusTrapManager::new());
11}
12
13/// An extension trait to add `focus_trap` functionality to interactive elements.
14pub trait FocusTrapElement: InteractiveElement + Sized {
15    /// Enable focus trap for this element.
16    ///
17    /// When enabled, focus will automatically cycle within this container
18    /// instead of escaping to parent elements. This is useful for modal dialogs,
19    /// sheets, and other overlay components.
20    ///
21    /// The focus trap works by:
22    /// 1. Registering this element as a focus trap container
23    /// 2. When Tab/Shift-Tab is pressed, Root intercepts the event
24    /// 3. If focus would leave the container, it cycles back to the beginning/end
25    ///
26    /// # Example
27    ///
28    /// ```ignore
29    /// v_flex()
30    ///     .child(Button::new("btn1").label("Button 1"))
31    ///     .child(Button::new("btn2").label("Button 2"))
32    ///     .child(Button::new("btn3").label("Button 3"))
33    ///     .focus_trap("trap1", &self.container_focus_handle)
34    /// // Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1
35    /// // Focus will not escape to elements outside this container
36    /// ```
37    ///
38    /// See also: <https://github.com/focus-trap/focus-trap-react>
39    fn focus_trap(
40        self,
41        id: impl Into<ElementId>,
42        focus_handle: &FocusHandle,
43    ) -> FocusTrapContainer<Self>
44    where
45        Self: ParentElement + Styled + Element + 'static,
46    {
47        FocusTrapContainer::new(id, focus_handle.clone(), self)
48    }
49}
50impl<T: InteractiveElement + Sized> FocusTrapElement for T {}
51
52/// Global state to manage all focus trap containers
53pub(crate) struct FocusTrapManager {
54    /// Map from container element ID to its focus trap info
55    traps: HashMap<GlobalElementId, WeakFocusHandle>,
56}
57
58impl Global for FocusTrapManager {}
59
60impl FocusTrapManager {
61    /// Create a new focus trap manager
62    fn new() -> Self {
63        Self {
64            traps: HashMap::new(),
65        }
66    }
67
68    pub(crate) fn global(cx: &App) -> &Self {
69        cx.global::<FocusTrapManager>()
70    }
71
72    fn global_mut(cx: &mut App) -> &mut Self {
73        cx.global_mut::<FocusTrapManager>()
74    }
75
76    /// Register a focus trap container
77    fn register_trap(id: &GlobalElementId, container_handle: WeakFocusHandle, cx: &mut App) {
78        let this = Self::global_mut(cx);
79        this.traps.insert(id.clone(), container_handle);
80        this.cleanup();
81    }
82
83    /// Find which focus trap contains the currently focused element
84    fn find_active_trap(window: &Window, cx: &App) -> Option<FocusHandle> {
85        let mut active: Option<FocusHandle> = None;
86        for container_handle in Self::global(cx).traps.values() {
87            let Some(container) = container_handle.upgrade() else {
88                continue;
89            };
90
91            if container.contains_focused(window, cx)
92                && active
93                    .as_ref()
94                    .is_none_or(|current| current.contains(&container, window))
95            {
96                // A child scope must confine navigation even if its ancestor
97                // was registered first (or visited first by the HashMap).
98                // Use the rendered focus tree so closing and reopening a
99                // retained scope cannot leave registration order in control.
100                active = Some(container);
101            }
102        }
103        active
104    }
105
106    /// Cleanup any traps with dropped handles
107    fn cleanup(&mut self) {
108        self.traps.retain(|_, handle| handle.upgrade().is_some());
109    }
110}
111
112impl Default for FocusTrapManager {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118/// A wrapper element that implements focus trap behavior.
119///
120/// This element wraps another element and registers it as a focus trap container.
121/// Focus will automatically cycle within the container when Tab/Shift-Tab is pressed.
122pub struct FocusTrapContainer<E: InteractiveElement + ParentElement + Styled + Element> {
123    id: ElementId,
124    focus_handle: FocusHandle,
125    base: E,
126}
127
128impl<E: InteractiveElement + ParentElement + Styled + Element> FocusTrapContainer<E> {
129    pub(crate) fn new(id: impl Into<ElementId>, focus_handle: FocusHandle, child: E) -> Self {
130        Self {
131            id: id.into(),
132            base: child.track_focus(&focus_handle),
133            focus_handle,
134        }
135    }
136}
137
138impl<E: InteractiveElement + ParentElement + Styled + Element> IntoElement
139    for FocusTrapContainer<E>
140{
141    type Element = Self;
142
143    fn into_element(self) -> Self::Element {
144        self
145    }
146}
147impl<E: InteractiveElement + ParentElement + Styled + Element> ParentElement
148    for FocusTrapContainer<E>
149{
150    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
151        self.base.extend(elements);
152    }
153}
154impl<E: InteractiveElement + ParentElement + Styled + Element> InteractiveElement
155    for FocusTrapContainer<E>
156{
157    fn interactivity(&mut self) -> &mut Interactivity {
158        self.base.interactivity()
159    }
160}
161impl<E: InteractiveElement + ParentElement + Styled + Element> StatefulInteractiveElement
162    for FocusTrapContainer<E>
163{
164}
165impl<E: InteractiveElement + ParentElement + Styled + Element> Styled for FocusTrapContainer<E> {
166    fn style(&mut self) -> &mut StyleRefinement {
167        self.base.style()
168    }
169}
170
171impl<E: InteractiveElement + ParentElement + Styled + Element + 'static> Element
172    for FocusTrapContainer<E>
173{
174    type RequestLayoutState = E::RequestLayoutState;
175    type PrepaintState = E::PrepaintState;
176
177    fn id(&self) -> Option<ElementId> {
178        Some(self.id.clone())
179    }
180
181    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
182        None
183    }
184
185    fn a11y_role(&self) -> Option<gpui::Role> {
186        self.base.a11y_role()
187    }
188
189    fn write_a11y_info(&self, node: &mut gpui::accesskit::Node) {
190        self.base.write_a11y_info(node);
191    }
192
193    fn a11y_synthetic_children(
194        &mut self,
195        prepaint: &mut Self::PrepaintState,
196        builder: &mut gpui::A11ySubtreeBuilder,
197    ) {
198        self.base.a11y_synthetic_children(prepaint, builder);
199    }
200
201    fn request_layout(
202        &mut self,
203        global_id: Option<&gpui::GlobalElementId>,
204        _inspector_id: Option<&gpui::InspectorElementId>,
205        window: &mut Window,
206        cx: &mut App,
207    ) -> (LayoutId, Self::RequestLayoutState) {
208        // Register this focus trap with the manager
209        FocusTrapManager::register_trap(global_id.unwrap(), self.focus_handle.downgrade(), cx);
210
211        self.base.request_layout(global_id, None, window, cx)
212    }
213
214    fn prepaint(
215        &mut self,
216        global_id: Option<&gpui::GlobalElementId>,
217        inspector_id: Option<&gpui::InspectorElementId>,
218        bounds: Bounds<Pixels>,
219        request_layout: &mut Self::RequestLayoutState,
220        window: &mut Window,
221        cx: &mut App,
222    ) -> Self::PrepaintState {
223        self.base
224            .prepaint(global_id, inspector_id, bounds, request_layout, window, cx)
225    }
226
227    fn paint(
228        &mut self,
229        global_id: Option<&gpui::GlobalElementId>,
230        inspector_id: Option<&gpui::InspectorElementId>,
231        bounds: Bounds<Pixels>,
232        request_layout: &mut Self::RequestLayoutState,
233        prepaint: &mut Self::PrepaintState,
234        window: &mut Window,
235        cx: &mut App,
236    ) {
237        self.base.paint(
238            global_id,
239            inspector_id,
240            bounds,
241            request_layout,
242            prepaint,
243            window,
244            cx,
245        )
246    }
247}
248
249/// Returns the focus handle for the active focus trap, if any.
250#[doc(hidden)]
251pub fn active_focus_trap(window: &Window, cx: &App) -> Option<FocusHandle> {
252    FocusTrapManager::find_active_trap(window, cx)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::TestSupportExt as _;
259    use gpui::{Role, TestAppContext, accesskit, div};
260
261    #[gpui::test]
262    fn trap_exports_inner_semantics_through_the_outer_native_hooks(cx: &mut TestAppContext) {
263        cx.update(|cx| {
264            for (role, name) in [
265                (Role::Dialog, "Edit server"),
266                (Role::AlertDialog, "Delete server?"),
267            ] {
268                let trap = div()
269                    .id("inner-host")
270                    .test_support()
271                    .role(role)
272                    .aria_label(name)
273                    .aria_description("Server settings")
274                    .focus_trap("outer-trap", &cx.focus_handle());
275                // GPUI calls hooks on the outer Element. An observed inner
276                // div alone can claim these semantics while the real element
277                // silently drops them. Check the exact native boundary.
278                assert_eq!(Element::id(&trap), Some("outer-trap".into()));
279                assert_eq!(trap.a11y_role(), Some(role));
280                let mut node = accesskit::Node::new(role);
281                trap.write_a11y_info(&mut node);
282                assert_eq!(node.label(), Some(name));
283                assert_eq!(node.description(), Some("Server settings"));
284            }
285        });
286    }
287}