Skip to main content

stratum_core/
focus.rs

1use serde::{Deserialize, Serialize};
2
3/// Manages focus behavior for interactive components.
4///
5/// Different component types require different focus management strategies:
6/// - Dialogs trap focus within their boundary
7/// - Popovers restore focus to the trigger on close
8/// - Menus focus the first item on open
9///
10/// `FocusManager` encapsulates these strategies and provides methods
11/// for programmatic focus control.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FocusManager {
14    /// The focus strategy to use.
15    pub strategy: FocusStrategy,
16    /// The ID of the container element to manage focus within.
17    container_id: Option<String>,
18    /// The ID of the element that had focus before trapping.
19    restore_target: Option<String>,
20}
21
22/// Focus management strategies.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub enum FocusStrategy {
25    /// Browser default focus behavior.
26    Auto,
27    /// Trap focus within the component (dialogs, modals).
28    /// Tab and Shift+Tab cycle through focusable children.
29    Trap,
30    /// Restore focus to the previously focused element on close.
31    Restore,
32    /// Focus the first focusable child on mount.
33    Initial,
34    /// Combination: trap focus AND restore on close (dialogs).
35    TrapAndRestore,
36}
37
38impl FocusManager {
39    /// Create a new FocusManager with the given strategy.
40    pub fn new(strategy: FocusStrategy) -> Self {
41        Self {
42            strategy,
43            container_id: None,
44            restore_target: None,
45        }
46    }
47
48    /// Create a FocusManager for a dialog (trap + restore).
49    pub fn dialog() -> Self {
50        Self::new(FocusStrategy::TrapAndRestore)
51    }
52
53    /// Create a FocusManager for a menu (focus first item).
54    pub fn menu() -> Self {
55        Self::new(FocusStrategy::Initial)
56    }
57
58    /// Create a FocusManager for a popover (restore on close).
59    pub fn popover() -> Self {
60        Self::new(FocusStrategy::Restore)
61    }
62
63    /// Set the container element ID.
64    pub fn with_container(mut self, id: impl Into<String>) -> Self {
65        self.container_id = Some(id.into());
66        self
67    }
68
69    /// Get the container ID.
70    pub fn container_id(&self) -> Option<&str> {
71        self.container_id.as_deref()
72    }
73
74    /// Record the currently focused element for later restoration.
75    pub fn save_restore_target(&mut self, element_id: impl Into<String>) {
76        self.restore_target = Some(element_id.into());
77    }
78
79    /// Get the element ID to restore focus to.
80    pub fn restore_target(&self) -> Option<&str> {
81        self.restore_target.as_deref()
82    }
83
84    /// Begin trapping focus within the container.
85    ///
86    /// Returns focus instructions that the framework adapter should execute.
87    pub fn trap(&self, container_id: &str) -> FocusInstruction {
88        FocusInstruction::Trap {
89            container_id: container_id.to_string(),
90        }
91    }
92
93    /// Release the focus trap.
94    pub fn release(&self) -> FocusInstruction {
95        FocusInstruction::Release
96    }
97
98    /// Restore focus to the previously focused element.
99    pub fn restore(&self) -> FocusInstruction {
100        match &self.restore_target {
101            Some(id) => FocusInstruction::FocusElement {
102                element_id: id.clone(),
103            },
104            None => FocusInstruction::Release,
105        }
106    }
107
108    /// Focus the first focusable child of a container.
109    pub fn focus_first(&self, container_id: &str) -> FocusInstruction {
110        FocusInstruction::FocusFirst {
111            container_id: container_id.to_string(),
112        }
113    }
114
115    /// Focus the last focusable child of a container.
116    pub fn focus_last(&self, container_id: &str) -> FocusInstruction {
117        FocusInstruction::FocusLast {
118            container_id: container_id.to_string(),
119        }
120    }
121
122    /// Focus the next focusable element.
123    pub fn focus_next(&self) -> FocusInstruction {
124        FocusInstruction::FocusNext
125    }
126
127    /// Focus the previous focusable element.
128    pub fn focus_prev(&self) -> FocusInstruction {
129        FocusInstruction::FocusPrev
130    }
131
132    /// Whether this manager uses focus trapping.
133    pub fn is_trapping(&self) -> bool {
134        matches!(
135            self.strategy,
136            FocusStrategy::Trap | FocusStrategy::TrapAndRestore
137        )
138    }
139
140    /// Whether this manager restores focus on close.
141    pub fn should_restore(&self) -> bool {
142        matches!(
143            self.strategy,
144            FocusStrategy::Restore | FocusStrategy::TrapAndRestore
145        )
146    }
147}
148
149/// Instructions for framework adapters to execute focus operations.
150///
151/// Since `stratum-core` is framework-agnostic, it cannot directly manipulate
152/// the DOM. Instead, it produces `FocusInstruction`s that the framework
153/// adapter translates to actual DOM focus operations.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum FocusInstruction {
156    /// Trap focus within a container element.
157    Trap { container_id: String },
158    /// Release the focus trap.
159    Release,
160    /// Focus a specific element by ID.
161    FocusElement { element_id: String },
162    /// Focus the first focusable child of a container.
163    FocusFirst { container_id: String },
164    /// Focus the last focusable child of a container.
165    FocusLast { container_id: String },
166    /// Focus the next focusable element in tab order.
167    FocusNext,
168    /// Focus the previous focusable element in tab order.
169    FocusPrev,
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn focus_manager_dialog() {
178        let fm = FocusManager::dialog();
179        assert_eq!(fm.strategy, FocusStrategy::TrapAndRestore);
180        assert!(fm.is_trapping());
181        assert!(fm.should_restore());
182    }
183
184    #[test]
185    fn focus_manager_menu() {
186        let fm = FocusManager::menu();
187        assert_eq!(fm.strategy, FocusStrategy::Initial);
188        assert!(!fm.is_trapping());
189        assert!(!fm.should_restore());
190    }
191
192    #[test]
193    fn focus_manager_popover() {
194        let fm = FocusManager::popover();
195        assert_eq!(fm.strategy, FocusStrategy::Restore);
196        assert!(!fm.is_trapping());
197        assert!(fm.should_restore());
198    }
199
200    #[test]
201    fn focus_manager_save_restore_target() {
202        let mut fm = FocusManager::dialog();
203        assert!(fm.restore_target().is_none());
204        fm.save_restore_target("btn-trigger");
205        assert_eq!(fm.restore_target(), Some("btn-trigger"));
206    }
207
208    #[test]
209    fn focus_manager_with_container() {
210        let fm = FocusManager::dialog().with_container("dialog-1");
211        assert_eq!(fm.container_id(), Some("dialog-1"));
212    }
213
214    #[test]
215    fn focus_instruction_trap() {
216        let fm = FocusManager::dialog();
217        let instruction = fm.trap("container-1");
218        assert_eq!(
219            instruction,
220            FocusInstruction::Trap {
221                container_id: "container-1".to_string()
222            }
223        );
224    }
225
226    #[test]
227    fn focus_instruction_restore() {
228        let mut fm = FocusManager::dialog();
229        fm.save_restore_target("trigger-btn");
230        let instruction = fm.restore();
231        assert_eq!(
232            instruction,
233            FocusInstruction::FocusElement {
234                element_id: "trigger-btn".to_string()
235            }
236        );
237    }
238
239    #[test]
240    fn focus_instruction_restore_no_target() {
241        let fm = FocusManager::dialog();
242        let instruction = fm.restore();
243        assert_eq!(instruction, FocusInstruction::Release);
244    }
245}