Skip to main content

seigi_focus/
lib.rs

1//! Focus management with accessibility
2
3mod candidates;
4
5use std::{
6    rc::{Rc, Weak},
7    sync::{Mutex, MutexGuard},
8};
9
10use gloo::{
11    timers::callback::Timeout,
12    utils::{body, document},
13};
14use js_sys::Function;
15use wasm_bindgen::{JsCast, prelude::Closure};
16use web_sys::{AddEventListenerOptions, Event, FocusEvent, HtmlElement, KeyboardEvent, MouseEvent};
17
18macro_rules! callback {
19    ($state: ident, $closure: expr) => {{
20        let $state = $state.clone();
21        let _closure: Closure<dyn FnMut(&Event)> = Closure::new($closure);
22        Callback(_closure)
23    }};
24}
25
26/// Runs given closure with acquired guard of Weak<Mutex<T>> and return the return of closure
27/// Caller should ensure that weak always upgrade to rc
28///
29/// # Panics
30/// This function assumes that Weak::upgrade and Mutex::lock does not fail, so it will panic if one
31/// of these fail.
32fn acquired<T, R>(weak: &Weak<Mutex<T>>, mut f: impl FnMut(MutexGuard<'_, T>) -> R) -> R {
33    let rc = weak.upgrade().unwrap();
34    let state = rc.lock().unwrap();
35    f(state)
36}
37
38/// Gets document.activeElement
39fn active_element() -> Option<HtmlElement> {
40    document()
41        .active_element()
42        .and_then(|v| v.dyn_into::<HtmlElement>().ok())
43}
44
45/// Sets immediate timeout(0ms) for focusing given element
46fn schedule_focus(target: HtmlElement) {
47    Timeout::new(0, move || {
48        let _ = target.focus();
49    })
50    .forget();
51}
52
53/// Gets the target of Event as HtmlElement
54fn target(event: &Event) -> Option<HtmlElement> {
55    event
56        .target()
57        .and_then(|v| v.dyn_into::<HtmlElement>().ok())
58}
59
60struct Callback(Closure<dyn FnMut(&Event)>);
61
62impl Callback {
63    fn as_function(&self) -> &Function {
64        self.0.as_ref().unchecked_ref()
65    }
66}
67
68struct Callbacks {
69    focus_in: Callback,
70    pointer_down: Callback,
71    click: Callback,
72    key_down: Callback,
73}
74
75/// The method trap will use to decide the initial focus element
76#[derive(Default)]
77pub enum InitialFocus {
78    /// The trap doesn't do initial focus
79    None,
80    /// The trap focuses the first focusable element inside the trap
81    #[default]
82    Auto,
83    /// The trap focuses the first element that matches given selector inside the trap
84    Selector(String),
85    /// The trap focuses given element
86    Element(HtmlElement),
87    /// The trap focuses returned element by the function
88    Function(Box<dyn Fn() -> HtmlElement>),
89}
90
91/// Hooks to [FocusTrap]
92#[derive(Default)]
93pub struct FocusTrapHooks {
94    /// Called when the trap is activated
95    pub activate: Option<Box<dyn Fn()>>,
96    /// Called when the trap is deactivated
97    pub deactivate: Option<Box<dyn Fn()>>,
98}
99
100/// Options of [FocusTrap]
101pub struct FocusTrapOptions {
102    /// Whether trap should return focus to the last focused element before trap activation
103    pub return_focus: bool,
104    pub initial_focus: InitialFocus,
105    /// Whether trap should deactivate when user press esc
106    pub deactivate_on_escape: bool,
107    /// The hooks
108    pub hooks: FocusTrapHooks,
109    /// The scope trap is affected.
110    ///
111    /// Elements outside the scope are not affected by the trap
112    pub scope: HtmlElement,
113    /// The element focus trap is attached to
114    pub target: HtmlElement,
115}
116
117impl FocusTrapOptions {
118    pub fn builder() -> FocusTrapOptionsBuilder {
119        FocusTrapOptionsBuilder::new()
120    }
121}
122
123/// A builder struct of [FocusTrapOptions]
124pub struct FocusTrapOptionsBuilder {
125    return_focus: bool,
126    initial_focus: InitialFocus,
127    deactivate_on_escape: bool,
128    hooks: FocusTrapHooks,
129    scope: HtmlElement,
130    target: Option<HtmlElement>,
131}
132
133impl Default for FocusTrapOptionsBuilder {
134    fn default() -> Self {
135        Self {
136            return_focus: true,
137            initial_focus: InitialFocus::default(),
138            deactivate_on_escape: false,
139            hooks: FocusTrapHooks::default(),
140            scope: body(),
141            target: None,
142        }
143    }
144}
145
146impl FocusTrapOptionsBuilder {
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    pub fn return_focus(mut self, return_focus: bool) -> Self {
152        self.return_focus = return_focus;
153        self
154    }
155
156    pub fn initial_focus(mut self, initial_focus: InitialFocus) -> Self {
157        self.initial_focus = initial_focus;
158        self
159    }
160
161    pub fn deactivate_on_escape(mut self, deactivate_on_escape: bool) -> Self {
162        self.deactivate_on_escape = deactivate_on_escape;
163        self
164    }
165
166    pub fn hooks(mut self, hooks: FocusTrapHooks) -> Self {
167        self.hooks = hooks;
168        self
169    }
170
171    pub fn scope(mut self, scope: HtmlElement) -> Self {
172        self.scope = scope;
173        self
174    }
175
176    pub fn target(mut self, target: HtmlElement) -> Self {
177        self.target = Some(target);
178        self
179    }
180
181    /// Builds into [FocusTrapOptions]
182    ///
183    /// # Panics
184    /// This method panics if target field is not set
185    pub fn build(self) -> FocusTrapOptions {
186        FocusTrapOptions {
187            return_focus: self.return_focus,
188            initial_focus: self.initial_focus,
189            deactivate_on_escape: self.deactivate_on_escape,
190            hooks: self.hooks,
191            scope: self.scope,
192            target: self
193                .target
194                .expect("target must be set to build FocusTrapOptions"),
195        }
196    }
197}
198
199struct State {
200    options: Rc<FocusTrapOptions>,
201    is_activated: bool,
202    last_focus: Option<HtmlElement>,
203    return_element: Option<HtmlElement>,
204    callbacks: Callbacks,
205}
206
207impl State {
208    fn add_listeners(&mut self) {
209        let option_captures = {
210            let options = AddEventListenerOptions::new();
211            options.set_capture(true);
212            options
213        };
214
215        let scope = &self.options.scope;
216
217        let _ = scope.add_event_listener_with_callback_and_add_event_listener_options(
218            "focusin",
219            self.callbacks.focus_in.as_function(),
220            &option_captures,
221        );
222        let _ = scope.add_event_listener_with_callback_and_add_event_listener_options(
223            "mousedown",
224            self.callbacks.pointer_down.as_function(),
225            &option_captures,
226        );
227        let _ = scope.add_event_listener_with_callback_and_add_event_listener_options(
228            "touchstart",
229            self.callbacks.pointer_down.as_function(),
230            &option_captures,
231        );
232        let _ = scope.add_event_listener_with_callback_and_add_event_listener_options(
233            "click",
234            self.callbacks.click.as_function(),
235            &option_captures,
236        );
237        let _ = scope.add_event_listener_with_callback_and_add_event_listener_options(
238            "keydown",
239            self.callbacks.key_down.as_function(),
240            &option_captures,
241        );
242        let _ = scope
243            .add_event_listener_with_callback("keydown", self.callbacks.key_down.as_function());
244    }
245
246    fn remove_listeners(&mut self) {
247        let scope = &self.options.scope;
248
249        let _ = scope.remove_event_listener_with_callback_and_bool(
250            "focusin",
251            self.callbacks.focus_in.as_function(),
252            true,
253        );
254        let _ = scope.remove_event_listener_with_callback_and_bool(
255            "mousedown",
256            self.callbacks.pointer_down.as_function(),
257            true,
258        );
259        let _ = scope.remove_event_listener_with_callback_and_bool(
260            "touchstart",
261            self.callbacks.pointer_down.as_function(),
262            true,
263        );
264        let _ = scope.remove_event_listener_with_callback_and_bool(
265            "click",
266            self.callbacks.click.as_function(),
267            true,
268        );
269        let _ = scope.remove_event_listener_with_callback_and_bool(
270            "keydown",
271            self.callbacks.key_down.as_function(),
272            true,
273        );
274        let _ = scope
275            .remove_event_listener_with_callback("keydown", self.callbacks.key_down.as_function());
276    }
277
278    fn activate(&mut self) {
279        if self.is_activated {
280            return;
281        }
282        self.is_activated = true;
283
284        self.return_element = active_element();
285        self.add_listeners();
286        self.initial_focus();
287
288        if let Some(hook) = &self.options.hooks.activate {
289            hook();
290        }
291    }
292
293    fn deactivate(&mut self) {
294        if !self.is_activated {
295            return;
296        }
297        self.is_activated = false;
298
299        self.remove_listeners();
300        self.return_focus();
301
302        if let Some(hook) = &self.options.hooks.deactivate {
303            hook();
304        }
305    }
306
307    fn initial_focus(&self) {
308        let element = match &self.options.initial_focus {
309            InitialFocus::None => return,
310            InitialFocus::Auto => {
311                match candidates::first_focus_candidate(self.options.target.unchecked_ref()) {
312                    Some(element) => element,
313                    None => return,
314                }
315            }
316            InitialFocus::Selector(selector) => {
317                match document().query_selector(selector).ok().flatten() {
318                    Some(element) => match element.dyn_into::<HtmlElement>() {
319                        Ok(element) => element,
320                        Err(_) => return,
321                    },
322                    None => return,
323                }
324            }
325            InitialFocus::Element(element) => element.clone(),
326            InitialFocus::Function(function) => function(),
327        };
328
329        schedule_focus(element);
330    }
331
332    fn return_focus(&self) {
333        if let Some(element) = &self.return_element {
334            schedule_focus(element.clone());
335        }
336    }
337
338    fn handle_focus_in(&mut self, event: &FocusEvent) {
339        let Some(target) = target(event.unchecked_ref()) else {
340            return;
341        };
342
343        if self.options.target.contains(Some(&target)) {
344            self.last_focus = Some(target)
345        } else {
346            // the focus has escaped out of focus trap
347            event.stop_immediate_propagation();
348
349            if let Some(last_focus) = &self.last_focus {
350                schedule_focus(last_focus.clone());
351            }
352        }
353    }
354
355    fn handle_pointer_down(&mut self, event: &Event) {
356        let Some(target) = target(event) else {
357            return;
358        };
359
360        if !self.options.target.contains(Some(&target)) {
361            event.prevent_default();
362        }
363    }
364
365    fn handle_click(&mut self, event: &MouseEvent) {
366        let Some(target) = target(event.unchecked_ref()) else {
367            return;
368        };
369
370        if !self.options.target.contains(Some(&target)) {
371            event.prevent_default();
372            event.stop_immediate_propagation();
373        }
374    }
375
376    fn handle_key_down(&mut self, event: &KeyboardEvent) {
377        if event.key() == "Tab" {
378            let Some(target) = event.target() else {
379                return;
380            };
381            let target = target.unchecked_ref::<HtmlElement>();
382            let is_backward = event.shift_key();
383
384            let body_tab_candidates = {
385                let container = &self.options.target;
386                let scope = &self.options.scope;
387                candidates::candidates(body().unchecked_ref(), move |v| {
388                    candidates::is_tabbable(v)
389                        && (!scope.contains(Some(v.unchecked_ref()))
390                            || container.contains(Some(v.unchecked_ref())))
391                })
392            };
393            let container_tab_candidates =
394                candidates::tab_candidates(self.options.target.unchecked_ref());
395
396            if is_backward {
397                let Some(first) = container_tab_candidates.first() else {
398                    event.prevent_default();
399                    return;
400                };
401
402                if target == first {
403                    let position = body_tab_candidates
404                        .iter()
405                        .position(|v| v == target)
406                        .unwrap();
407
408                    if position == 0 {
409                        // If there was a first element in vec, then there must be last one too
410                        let last = body_tab_candidates.last().unwrap();
411                        schedule_focus(last.clone());
412                    } else {
413                        let target = body_tab_candidates
414                            .get(position - 1)
415                            .unwrap_or_else(|| body_tab_candidates.last().unwrap());
416                        schedule_focus(target.clone());
417                    }
418                    event.prevent_default();
419                }
420            } else {
421                let Some(last) = container_tab_candidates.last() else {
422                    event.prevent_default();
423                    return;
424                };
425
426                if target == last {
427                    let position = body_tab_candidates
428                        .iter()
429                        .position(|v| v == target)
430                        .unwrap();
431
432                    if position == body_tab_candidates.len() {
433                        let first = container_tab_candidates.first().unwrap();
434                        schedule_focus(first.clone());
435                    } else {
436                        let target = body_tab_candidates
437                            .get(position + 1)
438                            .unwrap_or_else(|| body_tab_candidates.first().unwrap());
439                        schedule_focus(target.clone());
440                    }
441                    event.prevent_default();
442                }
443            }
444        } else if event.key() == "Escape" && self.options.deactivate_on_escape {
445            event.prevent_default();
446            self.deactivate();
447        }
448    }
449}
450
451impl Drop for State {
452    fn drop(&mut self) {
453        // Clean up listeners so there are no dangling listeners pointing to dropped rust closures
454        self.remove_listeners();
455    }
456}
457
458/// An instance of focus trap.
459///
460/// This struct contains a handle(Rc) to actual state.
461///
462/// Dropping this struct would also unregister all event listeners the trap has attached
463#[derive(Clone)]
464pub struct FocusTrap {
465    state: Rc<Mutex<State>>,
466}
467
468impl FocusTrap {
469    /// Returns the handle to options that were used to construct the trap
470    ///
471    /// This function locks the state
472    pub fn options(&self) -> Rc<FocusTrapOptions> {
473        self.state.lock().unwrap().options.clone()
474    }
475
476    /// Return true if the trap is activated
477    ///
478    /// This function locks the state
479    pub fn is_activated(&self) -> bool {
480        self.state.lock().unwrap().is_activated
481    }
482
483    /// Activates the trap
484    ///
485    /// Does nothing if the trap is already activated
486    pub fn activate(&self) {
487        self.state.lock().unwrap().activate();
488    }
489
490    /// Deactivates the trap
491    ///
492    /// Does nothing if the trap is already deactivated
493    pub fn deactivate(&self) {
494        self.state.lock().unwrap().deactivate();
495    }
496}
497
498pub fn create(options: FocusTrapOptions) -> FocusTrap {
499    let options = Rc::new(options);
500    let state = Rc::new_cyclic(|weak: &Weak<Mutex<State>>| {
501        let weak = weak.clone();
502        let focus_in = callback!(weak, move |event: &Event| acquired(&weak, |mut state| {
503            state.handle_focus_in(event.unchecked_ref())
504        }));
505        let pointer_down = callback!(weak, move |event: &Event| acquired(&weak, |mut state| {
506            state.handle_pointer_down(event)
507        }));
508        let click = callback!(weak, move |event: &Event| acquired(&weak, |mut state| {
509            state.handle_click(event.unchecked_ref())
510        }));
511        let key_down = callback!(weak, move |event: &Event| acquired(&weak, |mut state| {
512            state.handle_key_down(event.unchecked_ref())
513        }));
514
515        Mutex::new(State {
516            options,
517            is_activated: false,
518            last_focus: None,
519            return_element: None,
520            callbacks: Callbacks {
521                focus_in,
522                pointer_down,
523                click,
524                key_down,
525            },
526        })
527    });
528
529    FocusTrap { state }
530}