leptos_use/
use_textarea_autosize.rs

1use crate::core::{ElementMaybeSignal, IntoElementMaybeSignal, MaybeRwSignal};
2use default_struct_builder::DefaultBuilder;
3use leptos::prelude::*;
4#[cfg(not(feature = "ssr"))]
5use send_wrapper::SendWrapper;
6use std::sync::Arc;
7
8/// Automatically update the height of a textarea depending on the content.
9///
10/// ## Demo
11///
12/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_textarea_autosize)
13///
14/// ## Usage
15///
16/// ### Simple example
17///
18/// ```
19/// # use leptos::prelude::*;
20/// # use leptos::html::Textarea;
21/// # use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn};
22/// #
23/// # #[component]
24/// # fn Demo() -> impl IntoView {
25/// let textarea = NodeRef::new();
26///
27/// let UseTextareaAutosizeReturn {
28///     content,
29///     set_content,
30///     trigger_resize
31/// } = use_textarea_autosize(textarea);
32///
33/// view! {
34///     <textarea
35///         prop:value=content
36///         on:input=move |evt| set_content.set(event_target_value(&evt))
37///         node_ref=textarea
38///         class="resize-none"
39///         placeholder="What's on your mind?"
40///     />
41/// }
42/// # }
43/// ```
44///
45/// > Make sure that you set `box-sizing: border-box` on the textarea element.
46/// >
47/// > It's also recommended to reset the scrollbar styles for the textarea element to avoid
48/// > incorrect height values for large amounts of text.
49///
50/// ```css
51/// textarea {
52///   -ms-overflow-style: none;
53///   scrollbar-width: none;
54/// }
55///
56/// textarea::-webkit-scrollbar {
57///   display: none;
58/// }
59/// ```
60///
61/// ### With `rows` attribute
62///
63/// If you need support for the rows attribute on a textarea element, then you should set the
64/// `style_prop` option to `"min-height"`.
65///
66/// ```
67/// # use leptos::prelude::*;
68/// # use leptos::html::Textarea;
69/// # use leptos_use::{use_textarea_autosize_with_options, UseTextareaAutosizeOptions, UseTextareaAutosizeReturn};
70/// #
71/// # #[component]
72/// # fn Demo() -> impl IntoView {
73/// let textarea = NodeRef::new();
74///
75/// let UseTextareaAutosizeReturn {
76///     content,
77///     set_content,
78///     ..
79/// } = use_textarea_autosize_with_options(
80///     textarea,
81///     UseTextareaAutosizeOptions::default().style_prop("min-height"),
82/// );
83///
84/// view! {
85///     <textarea
86///         prop:value=content
87///         on:input=move |evt| set_content.set(event_target_value(&evt))
88///         node_ref=textarea
89///         class="resize-none"
90///         placeholder="What's on your mind?"
91///         rows="3"
92///     />
93/// }
94/// # }
95/// ```
96///
97/// ## SendWrapped Return
98///
99/// The returned closure `trigger_resize` is a sendwrapped function. It can
100/// only be called from the same thread that called `use_textarea_autosize`.
101///
102/// ## Server-Side Rendering
103///
104/// > Make sure you follow the [instructions in Server-Side Rendering](https://leptos-use.rs/server_side_rendering.html).
105///
106/// On the server this will always return an empty string as ´content` and a no-op `trigger_resize`.
107// #[doc(cfg(feature = "use_textarea_autosize"))]
108pub fn use_textarea_autosize<El, M>(
109    el: El,
110) -> UseTextareaAutosizeReturn<impl Fn() + Clone + Send + Sync>
111where
112    El: IntoElementMaybeSignal<web_sys::Element, M> + Clone,
113{
114    use_textarea_autosize_with_options::<El, M>(el, UseTextareaAutosizeOptions::default())
115}
116
117/// Version of [`fn@crate::use_textarea_autosize`] that takes a `UseTextareaAutosizeOptions`. See [`fn@crate::use_textarea_autosize`] for how to use.
118// #[doc(cfg(feature = "use_textarea_autosize"))]
119pub fn use_textarea_autosize_with_options<El, M>(
120    el: El,
121    options: UseTextareaAutosizeOptions,
122) -> UseTextareaAutosizeReturn<impl Fn() + Clone + Send + Sync>
123where
124    El: IntoElementMaybeSignal<web_sys::Element, M> + Clone,
125{
126    #[cfg(not(feature = "ssr"))]
127    {
128        use crate::sendwrap_fn;
129        use wasm_bindgen::JsCast;
130
131        let el = el.into_element_maybe_signal();
132        let textarea = Signal::derive(move || {
133            el.get().map(|el| {
134                SendWrapper::new(el.take().unchecked_into::<web_sys::HtmlTextAreaElement>())
135            })
136        });
137
138        let UseTextareaAutosizeOptions {
139            content,
140            watch: watch_fn,
141            on_resize,
142            style_target,
143            style_prop,
144        } = options;
145
146        let (content, set_content) = content.into_signal();
147
148        let (textarea_scroll_height, set_textarea_scroll_height) = signal(1);
149        let (textarea_old_width, set_textarea_old_width) = signal(0.0);
150
151        let trigger_resize = sendwrap_fn!(move || {
152            textarea.with_untracked(|textarea| {
153                if let Some(textarea) = textarea {
154                    let mut height = "".to_string();
155
156                    let border_offset =
157                        if let Ok(Some(style)) = window().get_computed_style(textarea) {
158                            (parse_num(
159                                &style
160                                    .get_property_value("border-top-width")
161                                    .unwrap_or_default(),
162                            ) + parse_num(
163                                &style
164                                    .get_property_value("border-bottom-width")
165                                    .unwrap_or_default(),
166                            )) as i32
167                        } else {
168                            0
169                        };
170
171                    web_sys::HtmlElement::style(textarea)
172                        .set_property(&style_prop, "1px")
173                        .ok();
174                    set_textarea_scroll_height.set(textarea.scroll_height() + border_offset + 1);
175
176                    if let Some(style_target) = style_target.get() {
177                        // If style target is provided update its height
178                        style_target
179                            .take()
180                            .unchecked_into::<web_sys::HtmlElement>()
181                            .style()
182                            .set_property(
183                                &style_prop,
184                                &format!("{}px", textarea_scroll_height.get_untracked()),
185                            )
186                            .ok();
187                    } else {
188                        // else update textarea's height by updating height variable
189                        height = format!("{}px", textarea_scroll_height.get_untracked());
190                    }
191
192                    web_sys::HtmlElement::style(textarea)
193                        .set_property(&style_prop, &height)
194                        .ok();
195                }
196            })
197        });
198
199        Effect::watch(
200            move || {
201                content.with(|_| ());
202                textarea.with(|_| ());
203            },
204            {
205                let trigger_resize = trigger_resize.clone();
206
207                move |_, _, _| {
208                    trigger_resize();
209                }
210            },
211            true,
212        );
213
214        Effect::watch(
215            move || textarea_scroll_height.track(),
216            move |_, _, _| {
217                on_resize();
218            },
219            false,
220        );
221
222        crate::use_resize_observer(textarea, {
223            let trigger_resize = trigger_resize.clone();
224
225            move |entries, _| {
226                for entry in entries {
227                    let width = entry.content_rect().width();
228
229                    if width != textarea_old_width.get_untracked() {
230                        set_textarea_old_width.set(width);
231                        trigger_resize();
232                    }
233                }
234            }
235        });
236
237        Effect::watch(
238            move || watch_fn(),
239            {
240                let trigger_resize = trigger_resize.clone();
241
242                move |_, _, _| {
243                    trigger_resize();
244                }
245            },
246            false,
247        );
248
249        UseTextareaAutosizeReturn {
250            content,
251            set_content,
252            trigger_resize,
253        }
254    }
255
256    #[cfg(feature = "ssr")]
257    {
258        let _ = el;
259        let _ = options;
260
261        let (content, set_content) = signal("".to_string());
262
263        UseTextareaAutosizeReturn {
264            content: content.into(),
265            set_content,
266            trigger_resize: || {},
267        }
268    }
269}
270
271/// Options for [`fn@crate::use_textarea_autosize_with_options`].
272// #[doc(cfg(feature = "use_textarea_autosize"))]
273#[derive(DefaultBuilder)]
274#[cfg_attr(feature = "ssr", allow(dead_code))]
275pub struct UseTextareaAutosizeOptions {
276    /// Textarea content
277    #[builder(into)]
278    content: MaybeRwSignal<String>,
279
280    /// Watch sources that should trigger a textarea resize
281    watch: Arc<dyn Fn() + Send + Sync>,
282
283    /// Function called when the textarea size changes
284    on_resize: Arc<dyn Fn() + Send + Sync>,
285
286    /// Specify style target to apply the height based on textarea content.
287    /// If not provided it will use textarea it self.
288    #[builder(skip)]
289    style_target: ElementMaybeSignal<web_sys::Element>,
290
291    /// Specify the style property that will be used to manipulate height.
292    /// Should be `"height"` or `"min-height"`. Default value is `"height"`.
293    #[builder(into)]
294    style_prop: String,
295}
296
297impl Default for UseTextareaAutosizeOptions {
298    fn default() -> Self {
299        Self {
300            content: MaybeRwSignal::default(),
301            watch: Arc::new(|| ()),
302            on_resize: Arc::new(|| ()),
303            style_target: Default::default(),
304            style_prop: "height".to_string(),
305        }
306    }
307}
308
309impl UseTextareaAutosizeOptions {
310    /// List of elementss that should not trigger the callback. Defaults to `[]`.
311    #[cfg_attr(feature = "ssr", allow(dead_code))]
312    pub fn style_target<M>(
313        self,
314        style_target: impl IntoElementMaybeSignal<web_sys::Element, M>,
315    ) -> Self {
316        Self {
317            style_target: style_target.into_element_maybe_signal(),
318            ..self
319        }
320    }
321}
322
323/// Return type of [`fn@crate::use_textarea_autosize`].
324// #[doc(cfg(feature = "use_textarea_autosize"))]
325pub struct UseTextareaAutosizeReturn<F>
326where
327    F: Fn() + Clone + Send + Sync,
328{
329    /// The textarea content
330    pub content: Signal<String>,
331
332    /// Set the textarea content
333    pub set_content: WriteSignal<String>,
334
335    /// Function to trigger a textarea resize manually
336    pub trigger_resize: F,
337}
338
339#[cfg(not(feature = "ssr"))]
340fn parse_num(s: &str) -> u32 {
341    s.chars()
342        .map_while(|c| c.to_digit(10))
343        .fold(0, |acc, digit| acc * 10 + digit)
344}