Skip to content

Commit 2901fd2

Browse files
authored
Cleanup global event listeners (#124)
* Cleanup global event listeners * rename ReadOnlySignal to ReadSignal * fix clippy * remove patch * use main branch for patch * pull out keydown listener logic * only call the top escape handler * fix popover * fix clippy * bump dioxus version * bump to rc.1 * update lockfile * fix use_global_escape_listener
1 parent 9e865c6 commit 2901fd2

File tree

6 files changed

+137
-84
lines changed

6 files changed

+137
-84
lines changed

complaints.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ It's verbose to set a `Signal` or `ReadSignal`'s default value through props.
1212
pub struct SomeProps {
1313

1414
// This sets bool to be false
15-
#[props(default)]
15+
#[props(default)]
1616
value: ReadSignal<bool>,
1717

1818
// This is what I'd like, except it wants a ReadSignal
19-
#[props(default = true)]
19+
#[props(default = true)]
2020
value: ReadSignal<bool>,
2121

2222
// Instead you have to do this:

primitives/src/alert_dialog.rs

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Defines the [`AlertDialogRoot`] component and its sub-components.
22
3+
use crate::use_global_escape_listener;
34
use crate::{use_animated_open, use_id_or, use_unique_id, FOCUS_TRAP_JS};
45
use dioxus::document;
56
use dioxus::prelude::*;
@@ -93,25 +94,6 @@ pub fn AlertDialogRoot(props: AlertDialogRootProps) -> Element {
9394
describedby,
9495
});
9596

96-
// Add a escape key listener to the document when the dialog is open. We can't
97-
// just add this to the dialog itself because it might not be focused if the user
98-
// is highlighting text or interacting with another element.
99-
use_effect(move || {
100-
let mut escape = document::eval(
101-
"document.addEventListener('keydown', (event) => {
102-
if (event.key === 'Escape') {
103-
event.preventDefault();
104-
dioxus.send(true);
105-
}
106-
});",
107-
);
108-
spawn(async move {
109-
while let Ok(true) = escape.recv().await {
110-
set_open.call(false);
111-
}
112-
});
113-
});
114-
11597
let id = use_unique_id();
11698
let id = use_id_or(id, props.id);
11799
let render_element = use_animated_open(id, open);
@@ -195,6 +177,12 @@ pub fn AlertDialogContent(props: AlertDialogContentProps) -> Element {
195177
let ctx: AlertDialogCtx = use_context();
196178

197179
let open = ctx.open;
180+
let set_open = ctx.set_open;
181+
182+
// Add a escape key listener to the document when the dialog is open. We can't
183+
// just add this to the dialog itself because it might not be focused if the user
184+
// is highlighting text or interacting with another element.
185+
use_global_escape_listener(move || set_open.call(false));
198186

199187
let gen_id = use_unique_id();
200188
let id = use_id_or(gen_id, props.id);

primitives/src/dialog.rs

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use dioxus::document;
44
use dioxus::prelude::*;
55

6+
use crate::use_global_escape_listener;
67
use crate::{use_animated_open, use_controlled, use_id_or, use_unique_id, FOCUS_TRAP_JS};
78

89
#[derive(Clone, Copy)]
@@ -109,25 +110,6 @@ pub fn DialogRoot(props: DialogRootProps) -> Element {
109110
dialog_describedby,
110111
});
111112

112-
// Add a escape key listener to the document when the dialog is open. We can't
113-
// just add this to the dialog itself because it might not be focused if the user
114-
// is highlighting text or interacting with another element.
115-
use_effect(move || {
116-
let mut escape = document::eval(
117-
"document.addEventListener('keydown', (event) => {
118-
if (event.key === 'Escape') {
119-
event.preventDefault();
120-
dioxus.send(true);
121-
}
122-
});",
123-
);
124-
spawn(async move {
125-
while let Ok(true) = escape.recv().await {
126-
set_open.call(false);
127-
}
128-
});
129-
});
130-
131113
let unique_id = use_unique_id();
132114
let id = use_id_or(unique_id, props.id);
133115

@@ -224,6 +206,12 @@ pub fn DialogContent(props: DialogContentProps) -> Element {
224206
let ctx: DialogCtx = use_context();
225207
let open = ctx.open;
226208
let is_modal = ctx.is_modal;
209+
let set_open = ctx.set_open;
210+
211+
// Add a escape key listener to the document when the dialog is open. We can't
212+
// just add this to the dialog itself because it might not be focused if the user
213+
// is highlighting text or interacting with another element.
214+
use_global_escape_listener(move || set_open.call(false));
227215

228216
let gen_id = use_unique_id();
229217
let id = use_id_or(gen_id, props.id);

primitives/src/lib.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
#![doc = include_str!("../README.md")]
22
#![warn(missing_docs)]
33

4+
use std::cell::RefCell;
5+
use std::rc::Rc;
46
use std::sync::atomic::{AtomicUsize, Ordering};
57

8+
use dioxus::core::{current_scope_id, use_drop};
69
use dioxus::prelude::*;
710
use dioxus::prelude::{asset, manganis, Asset};
811

@@ -107,6 +110,76 @@ fn use_effect_cleanup<F: FnOnce() + 'static>(#[allow(unused)] cleanup: F) {
107110
client!(crate::dioxus_core::use_drop(cleanup))
108111
}
109112

113+
/// Run some cleanup code when the component is unmounted if the effect was run.
114+
fn use_effect_with_cleanup<F: FnMut() -> C + 'static, C: FnOnce() + 'static>(mut effect: F) {
115+
let mut cleanup = use_hook(|| CopyValue::new(None as Option<C>));
116+
use_effect(move || {
117+
if let Some(cleanup) = cleanup.take() {
118+
cleanup();
119+
}
120+
cleanup.set(Some(effect()));
121+
});
122+
client!(crate::dioxus_core::use_drop(move || {
123+
if let Some(cleanup) = cleanup.take() {
124+
cleanup();
125+
}
126+
}))
127+
}
128+
129+
/// A stack of escape listeners to allow only the top-most listener to be called.
130+
#[derive(Clone)]
131+
struct EscapeListenerStack(Rc<RefCell<Vec<ScopeId>>>);
132+
133+
fn use_global_escape_listener(mut on_escape: impl FnMut() + Clone + 'static) {
134+
let scope_id = current_scope_id();
135+
let stack = use_hook(move || {
136+
// Get or create the escape listener stack
137+
let stack: EscapeListenerStack = try_consume_context()
138+
.unwrap_or_else(|| provide_context(EscapeListenerStack(Default::default())));
139+
// Push the current scope onto the stack
140+
stack.0.borrow_mut().push(scope_id);
141+
stack
142+
});
143+
// Remove the current scope id from the stack when we unmount
144+
use_drop({
145+
let stack = stack.clone();
146+
move || {
147+
let mut stack = stack.0.borrow_mut();
148+
stack.retain(|id| *id != scope_id);
149+
}
150+
});
151+
use_global_keydown_listener("Escape", move || {
152+
// Only call the listener if this component is on top of the stack
153+
let stack = stack.0.borrow();
154+
if stack.last() == Some(&scope_id) {
155+
on_escape();
156+
}
157+
});
158+
}
159+
160+
fn use_global_keydown_listener(key: &'static str, on_escape: impl FnMut() + Clone + 'static) {
161+
use_effect_with_cleanup(move || {
162+
let mut escape = document::eval(&format!(
163+
"function listener(event) {{
164+
if (event.key === '{key}') {{
165+
event.preventDefault();
166+
dioxus.send(true);
167+
}}
168+
}}
169+
document.addEventListener('keydown', listener);
170+
await dioxus.recv();
171+
document.removeEventListener('keydown', listener);"
172+
));
173+
let mut on_escape = on_escape.clone();
174+
spawn(async move {
175+
while let Ok(true) = escape.recv().await {
176+
on_escape();
177+
}
178+
});
179+
move || _ = escape.send(true)
180+
});
181+
}
182+
110183
fn use_animated_open(
111184
id: impl Readable<Target = String> + Copy + 'static,
112185
open: impl Readable<Target = bool> + Copy + 'static,

primitives/src/popover.rs

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use dioxus::document;
44
use dioxus::prelude::*;
55

6+
use crate::use_global_escape_listener;
67
use crate::{
78
use_animated_open, use_controlled, use_id_or, use_unique_id, ContentAlign, ContentSide,
89
FOCUS_TRAP_JS,
@@ -109,25 +110,6 @@ pub fn PopoverRoot(props: PopoverRootProps) -> Element {
109110
labelledby,
110111
});
111112

112-
// Add a escape key listener to the document when the dialog is open. We can't
113-
// just add this to the dialog itself because it might not be focused if the user
114-
// is highlighting text or interacting with another element.
115-
use_effect(move || {
116-
let mut escape = document::eval(
117-
"document.addEventListener('keydown', (event) => {
118-
if (event.key === 'Escape') {
119-
event.preventDefault();
120-
dioxus.send(true);
121-
}
122-
});",
123-
);
124-
spawn(async move {
125-
while let Ok(true) = escape.recv().await {
126-
set_open.call(false);
127-
}
128-
});
129-
});
130-
131113
rsx! {
132114
div {
133115
"data-state": if open() { "open" } else { "closed" },
@@ -219,7 +201,6 @@ pub struct PopoverContentProps {
219201
pub fn PopoverContent(props: PopoverContentProps) -> Element {
220202
let ctx: PopoverCtx = use_context();
221203
let open = ctx.open;
222-
let is_open = open();
223204
let is_modal = ctx.is_modal;
224205

225206
let gen_id = use_unique_id();
@@ -257,23 +238,56 @@ pub fn PopoverContent(props: PopoverContentProps) -> Element {
257238
defer: true
258239
}
259240
if render() {
260-
div {
241+
PopoverContentRendered {
261242
id,
262-
role: "dialog",
263-
aria_modal: "true",
264-
aria_labelledby: ctx.labelledby,
265-
aria_hidden: (!is_open).then_some("true"),
266-
class: props.class.clone().unwrap_or_else(|| "popover-content".to_string()),
267-
"data-state": if is_open { "open" } else { "closed" },
268-
"data-side": props.side.as_str(),
269-
"data-align": props.align.as_str(),
270-
..props.attributes,
271-
{props.children}
243+
class: props.class,
244+
side: props.side,
245+
align: props.align,
246+
attributes: props.attributes,
247+
children: props.children
272248
}
273249
}
274250
}
275251
}
276252

253+
/// The rendered content of the popover. This is separated out so the global event listener
254+
/// is only added when the popover is actually rendered.
255+
#[component]
256+
pub fn PopoverContentRendered(
257+
id: String,
258+
class: Option<String>,
259+
side: ContentSide,
260+
align: ContentAlign,
261+
attributes: Vec<Attribute>,
262+
children: Element,
263+
) -> Element {
264+
let ctx: PopoverCtx = use_context();
265+
let open = ctx.open;
266+
let is_open = open();
267+
let set_open = ctx.set_open;
268+
269+
// Add a escape key listener to the document when the dialog is open. We can't
270+
// just add this to the dialog itself because it might not be focused if the user
271+
// is highlighting text or interacting with another element.
272+
use_global_escape_listener(move || set_open.call(false));
273+
274+
rsx! {
275+
div {
276+
id,
277+
role: "dialog",
278+
aria_modal: "true",
279+
aria_labelledby: ctx.labelledby,
280+
aria_hidden: (!is_open).then_some("true"),
281+
class: class.unwrap_or_else(|| "popover-content".to_string()),
282+
"data-state": if is_open { "open" } else { "closed" },
283+
"data-side": side.as_str(),
284+
"data-align": align.as_str(),
285+
..attributes,
286+
{children}
287+
}
288+
}
289+
}
290+
277291
/// The props for the [`PopoverTrigger`] component.
278292
#[derive(Props, Clone, PartialEq)]
279293
pub struct PopoverTriggerProps {

primitives/src/toast.rs

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use crate::{
44
portal::{use_portal, PortalIn, PortalOut},
5-
use_unique_id,
5+
use_global_keydown_listener, use_unique_id,
66
};
77
use dioxus::dioxus_core::DynamicNode;
88
use dioxus::prelude::*;
@@ -209,18 +209,8 @@ pub fn ToastProvider(props: ToastProviderProps) -> Element {
209209
});
210210
});
211211

212-
// Mount the first toast when the user presses f6
213-
use_effect(move || {
214-
let mut eval = dioxus::document::eval(
215-
"document.addEventListener('keydown', (event) => { if (event.key === 'F6') { dioxus.send(true) } });",
216-
);
217-
spawn(async move {
218-
while let Ok(true) = eval.recv().await {
219-
// Focus the first toast when F6 is pressed
220-
focus_region(())
221-
}
222-
});
223-
});
212+
// Focus the first toast when the user presses f6
213+
use_global_keydown_listener("F6", move || focus_region(()));
224214

225215
// Provide the context
226216
let ctx = use_context_provider(|| ToastCtx {

0 commit comments

Comments
 (0)