leptos_use/
use_textarea_autosize.rs

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