1mod 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
26fn 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
38fn active_element() -> Option<HtmlElement> {
40 document()
41 .active_element()
42 .and_then(|v| v.dyn_into::<HtmlElement>().ok())
43}
44
45fn schedule_focus(target: HtmlElement) {
47 Timeout::new(0, move || {
48 let _ = target.focus();
49 })
50 .forget();
51}
52
53fn 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#[derive(Default)]
77pub enum InitialFocus {
78 None,
80 #[default]
82 Auto,
83 Selector(String),
85 Element(HtmlElement),
87 Function(Box<dyn Fn() -> HtmlElement>),
89}
90
91#[derive(Default)]
93pub struct FocusTrapHooks {
94 pub activate: Option<Box<dyn Fn()>>,
96 pub deactivate: Option<Box<dyn Fn()>>,
98}
99
100pub struct FocusTrapOptions {
102 pub return_focus: bool,
104 pub initial_focus: InitialFocus,
105 pub deactivate_on_escape: bool,
107 pub hooks: FocusTrapHooks,
109 pub scope: HtmlElement,
113 pub target: HtmlElement,
115}
116
117impl FocusTrapOptions {
118 pub fn builder() -> FocusTrapOptionsBuilder {
119 FocusTrapOptionsBuilder::new()
120 }
121}
122
123pub 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 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 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 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 self.remove_listeners();
455 }
456}
457
458#[derive(Clone)]
464pub struct FocusTrap {
465 state: Rc<Mutex<State>>,
466}
467
468impl FocusTrap {
469 pub fn options(&self) -> Rc<FocusTrapOptions> {
473 self.state.lock().unwrap().options.clone()
474 }
475
476 pub fn is_activated(&self) -> bool {
480 self.state.lock().unwrap().is_activated
481 }
482
483 pub fn activate(&self) {
487 self.state.lock().unwrap().activate();
488 }
489
490 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}