Skip to main content

fusor/dom/
bindings.rs

1use super::{
2    ElementTarget, InputTarget, JsValue, Listener, ListenerTarget, Scope, strings, text_value,
3};
4use crate::{Effect, effect};
5use std::{cell::RefCell, rc::Rc};
6use web_sys::{Event, Text};
7
8impl Scope {
9    pub(super) fn bind(
10        &mut self,
11        update: impl FnMut() -> Result<(), JsValue> + 'static,
12    ) -> Result<(), JsValue> {
13        self.bind_internal(update, false)
14    }
15
16    /// Generated and standard-library DOM effects join attachment validation.
17    /// Native descendants are validated before the first binding writes DOM.
18    #[doc(hidden)]
19    pub fn bind_dom(
20        &mut self,
21        update: impl FnMut() -> Result<(), JsValue> + 'static,
22    ) -> Result<(), JsValue> {
23        self.bind_internal(update, self.hydrating)
24    }
25
26    fn bind_internal(
27        &mut self,
28        mut update: impl FnMut() -> Result<(), JsValue> + 'static,
29        deferred: bool,
30    ) -> Result<(), JsValue> {
31        let first_error = Rc::new(RefCell::new(None));
32        let captured = first_error.clone();
33        let mut first = true;
34        let owner = self.owner();
35        let callback = move || {
36            if owner.is_disposed() {
37                return;
38            }
39            if let Err(error) = update() {
40                if first {
41                    *captured.borrow_mut() = Some(error);
42                } else {
43                    web_sys::console::error_1(&error);
44                }
45            }
46            first = false;
47        };
48        let errors = first_error.clone();
49        let binding = self.install_effect(deferred, callback, move || {
50            errors.take().map_or(Ok(()), Err)
51        })?;
52        if let Some(error) = first_error.take() {
53            return Err(error);
54        }
55        self.effects.push(binding);
56        Ok(())
57    }
58
59    // Native Text writes use a non-catching host helper and cannot return a
60    // JsValue error. Keep the same effect/commit lifecycle without allocating
61    // shared first-error storage for a result that is always successful.
62    pub(super) fn bind_dom_infallible(
63        &mut self,
64        mut update: impl FnMut() + 'static,
65    ) -> Result<(), JsValue> {
66        let owner = self.owner();
67        let callback = move || {
68            if !owner.is_disposed() {
69                update();
70            }
71        };
72        let binding = self.install_effect(self.hydrating, callback, || Ok(()))?;
73        self.effects.push(binding);
74        Ok(())
75    }
76
77    /// Run `callback` now, or defer its first run to commit, which then
78    /// reports `initialized`.
79    fn install_effect(
80        &mut self,
81        deferred: bool,
82        callback: impl FnMut() + 'static,
83        initialized: impl FnOnce() -> Result<(), JsValue> + 'static,
84    ) -> Result<Effect, JsValue> {
85        if !deferred {
86            return Ok(effect(callback));
87        }
88        let binding = crate::reactive::prepared_effect(callback);
89        let initialize = binding.initializer();
90        self.before_commit(move || {
91            initialize();
92            initialized()
93        })?;
94        Ok(binding)
95    }
96
97    /// Replace a text leaf's content reactively. Values are text, never HTML.
98    pub fn text<T: ToString>(
99        &mut self,
100        target: impl ElementTarget,
101        read: impl Fn() -> T + 'static,
102    ) -> Result<(), JsValue> {
103        let node = target.resolve(self)?;
104        self.bind_dom(move || {
105            let value = read().to_string();
106            if node.text_content().as_deref() != Some(value.as_str()) {
107                node.set_text_content(Some(&value));
108            }
109            Ok(())
110        })
111    }
112
113    /// Bind a native text node, preserving its identity and all sibling nodes.
114    pub fn text_node<T: ToString>(
115        &mut self,
116        text: &Text,
117        read: impl Fn() -> T + 'static,
118    ) -> Result<(), JsValue> {
119        self.text_node_string(text, move || read().to_string())
120    }
121
122    #[doc(hidden)]
123    pub fn text_node_string(
124        &mut self,
125        text: &Text,
126        read: impl Fn() -> String + 'static,
127    ) -> Result<(), JsValue> {
128        let text = text.clone();
129        self.bind_dom_infallible(move || strings::set_text_if_changed(&text, &read()))
130    }
131
132    #[doc(hidden)]
133    pub fn text_node_value(
134        &mut self,
135        text: &Text,
136        read: impl Fn() -> text_value::Output + 'static,
137    ) -> Result<(), JsValue> {
138        let text = text.clone();
139        self.bind_dom_infallible(move || match read() {
140            text_value::Output::String(value) => strings::set_text_if_changed(&text, &value),
141            text_value::Output::Integer(value) => {
142                strings::set_integer_text_if_changed(&text, value)
143            }
144        })
145    }
146
147    /// Bind an attribute. `None` removes it, including boolean attributes.
148    pub fn attr(
149        &mut self,
150        target: impl ElementTarget,
151        name: &str,
152        read: impl Fn() -> Option<String> + 'static,
153    ) -> Result<(), JsValue> {
154        let node = target.resolve(self)?;
155        let name = name.to_owned();
156        self.bind_dom(move || match read() {
157            Some(value) => node.set_attribute(&name, &value),
158            None => node.remove_attribute(&name),
159        })
160    }
161
162    /// Compiler entry point for an always-present attribute, with the text
163    /// path's exact-integer conversion.
164    #[doc(hidden)]
165    pub fn attr_value(
166        &mut self,
167        target: impl ElementTarget,
168        name: &str,
169        read: impl Fn() -> text_value::Output + 'static,
170    ) -> Result<(), JsValue> {
171        let node = target.resolve(self)?;
172        let name = name.to_owned();
173        self.bind_dom(move || match read() {
174            text_value::Output::String(value) => node.set_attribute(&name, &value),
175            text_value::Output::Integer(value) => {
176                strings::set_integer_attribute(&node, &name, value)
177            }
178        })
179    }
180
181    pub fn class(
182        &mut self,
183        target: impl ElementTarget,
184        name: &str,
185        read: impl Fn() -> bool + 'static,
186    ) -> Result<(), JsValue> {
187        let node = target.resolve(self)?;
188        let name = name.to_owned();
189        self.bind_dom(move || {
190            node.class_list()
191                .toggle_with_force(&name, read())
192                .map(|_| ())
193        })
194    }
195
196    /// Listen to a DOM event. All signal writes in one handler are batched.
197    pub fn on(
198        &mut self,
199        target: impl ElementTarget,
200        event: &str,
201        handler: impl FnMut(Event) + 'static,
202    ) -> Result<(), JsValue> {
203        let target = ListenerTarget::Node(target.resolve(self)?.into());
204        self.listen(target, event, handler)
205    }
206
207    /// Listen on a validated binding bundle entry, without fetching its node.
208    pub(super) fn on_bundle(
209        &mut self,
210        nodes: &Rc<JsValue>,
211        index: u32,
212        event: &str,
213        handler: impl FnMut(Event) + 'static,
214    ) -> Result<(), JsValue> {
215        self.listen(
216            ListenerTarget::Bundle(Rc::clone(nodes), index),
217            event,
218            handler,
219        )
220    }
221
222    fn listen(
223        &mut self,
224        target: ListenerTarget,
225        event: &str,
226        handler: impl FnMut(Event) + 'static,
227    ) -> Result<(), JsValue> {
228        let owner = self.owner();
229        let listener = Listener::batched(target, event, move || owner.is_active(), handler)?;
230        self.listeners.push(listener);
231        Ok(())
232    }
233
234    pub fn checked(
235        &mut self,
236        target: impl InputTarget,
237        read: impl Fn() -> bool + 'static,
238    ) -> Result<(), JsValue> {
239        let input = target.resolve_input(self)?;
240        self.bind_dom(move || {
241            input.set_checked(read());
242            Ok(())
243        })
244    }
245
246    /// Bind the current input property, including changes after user edits.
247    pub fn value(
248        &mut self,
249        target: impl InputTarget,
250        read: impl Fn() -> String + 'static,
251    ) -> Result<(), JsValue> {
252        let input = target.resolve_input(self)?;
253        self.bind_dom(move || {
254            let value = read();
255            if input.value() != value {
256                input.set_value(&value);
257            }
258            Ok(())
259        })
260    }
261}