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/// On the server this will always return an empty string as ´content` and a no-op `trigger_resize`.
103// #[doc(cfg(feature = "use_textarea_autosize"))]
104pub fn use_textarea_autosize<El, M>(
105    el: El,
106) -> UseTextareaAutosizeReturn<impl Fn() + Clone + Send + Sync>
107where
108    El: IntoElementMaybeSignal<web_sys::Element, M> + Clone,
109{
110    use_textarea_autosize_with_options::<El, M>(el, UseTextareaAutosizeOptions::default())
111}
112
113/// Version of [`fn@crate::use_textarea_autosize`] that takes a `UseTextareaAutosizeOptions`. See [`fn@crate::use_textarea_autosize`] for how to use.
114// #[doc(cfg(feature = "use_textarea_autosize"))]
115pub fn use_textarea_autosize_with_options<El, M>(
116    el: El,
117    options: UseTextareaAutosizeOptions,
118) -> UseTextareaAutosizeReturn<impl Fn() + Clone + Send + Sync>
119where
120    El: IntoElementMaybeSignal<web_sys::Element, M> + Clone,
121{
122    #[cfg(not(feature = "ssr"))]
123    {
124        use crate::sendwrap_fn;
125        use wasm_bindgen::JsCast;
126
127        let el = el.into_element_maybe_signal();
128        let textarea = Signal::derive_local(move || {
129            el.get()
130                .map(|el| el.unchecked_into::<web_sys::HtmlTextAreaElement>())
131        });
132
133        let UseTextareaAutosizeOptions {
134            content,
135            watch: watch_fn,
136            on_resize,
137            style_target,
138            style_prop,
139        } = options;
140
141        let (content, set_content) = content.into_signal();
142
143        let (textarea_scroll_height, set_textarea_scroll_height) = signal(1);
144        let (textarea_old_width, set_textarea_old_width) = signal(0.0);
145
146        let trigger_resize = sendwrap_fn!(move || {
147            textarea.with_untracked(|textarea| {
148                if let Some(textarea) = textarea {
149                    let mut height = "".to_string();
150
151                    let border_offset =
152                        if let Ok(Some(style)) = window().get_computed_style(textarea) {
153                            (parse_num(
154                                &style
155                                    .get_property_value("border-top-width")
156                                    .unwrap_or_default(),
157                            ) + parse_num(
158                                &style
159                                    .get_property_value("border-bottom-width")
160                                    .unwrap_or_default(),
161                            )) as i32
162                        } else {
163                            0
164                        };
165
166                    web_sys::HtmlElement::style(textarea)
167                        .set_property(&style_prop, "1px")
168                        .ok();
169                    set_textarea_scroll_height.set(textarea.scroll_height() + border_offset + 1);
170
171                    if let Some(style_target) = style_target.get() {
172                        // If style target is provided update its height
173                        style_target
174                            .unchecked_into::<web_sys::HtmlElement>()
175                            .style()
176                            .set_property(
177                                &style_prop,
178                                &format!("{}px", textarea_scroll_height.get_untracked()),
179                            )
180                            .ok();
181                    } else {
182                        // else update textarea's height by updating height variable
183                        height = format!("{}px", textarea_scroll_height.get_untracked());
184                    }
185
186                    web_sys::HtmlElement::style(textarea)
187                        .set_property(&style_prop, &height)
188                        .ok();
189                }
190            })
191        });
192
193        Effect::watch(
194            move || {
195                content.with(|_| ());
196                textarea.with(|_| ());
197            },
198            {
199                let trigger_resize = trigger_resize.clone();
200
201                move |_, _, _| {
202                    trigger_resize();
203                }
204            },
205            true,
206        );
207
208        Effect::watch(
209            move || textarea_scroll_height.track(),
210            move |_, _, _| {
211                on_resize();
212            },
213            false,
214        );
215
216        crate::use_resize_observer(textarea, {
217            let trigger_resize = trigger_resize.clone();
218
219            move |entries, _| {
220                for entry in entries {
221                    let width = entry.content_rect().width();
222
223                    if width != textarea_old_width.get_untracked() {
224                        set_textarea_old_width.set(width);
225                        trigger_resize();
226                    }
227                }
228            }
229        });
230
231        Effect::watch(
232            move || watch_fn(),
233            {
234                let trigger_resize = trigger_resize.clone();
235
236                move |_, _, _| {
237                    trigger_resize();
238                }
239            },
240            false,
241        );
242
243        UseTextareaAutosizeReturn {
244            content,
245            set_content,
246            trigger_resize,
247        }
248    }
249
250    #[cfg(feature = "ssr")]
251    {
252        let _ = el;
253        let _ = options;
254
255        let (content, set_content) = signal("".to_string());
256
257        UseTextareaAutosizeReturn {
258            content: content.into(),
259            set_content,
260            trigger_resize: || {},
261        }
262    }
263}
264
265/// Options for [`fn@crate::use_textarea_autosize_with_options`].
266// #[doc(cfg(feature = "use_textarea_autosize"))]
267#[derive(DefaultBuilder)]
268#[cfg_attr(feature = "ssr", allow(dead_code))]
269pub struct UseTextareaAutosizeOptions {
270    /// Textarea content
271    #[builder(into)]
272    content: MaybeRwSignal<String>,
273
274    /// Watch sources that should trigger a textarea resize
275    watch: Arc<dyn Fn() + Send + Sync>,
276
277    /// Function called when the textarea size changes
278    on_resize: Arc<dyn Fn() + Send + Sync>,
279
280    /// Specify style target to apply the height based on textarea content.
281    /// If not provided it will use textarea it self.
282    #[builder(skip)]
283    style_target: ElementMaybeSignal<web_sys::Element>,
284
285    /// Specify the style property that will be used to manipulate height.
286    /// Should be `"height"` or `"min-height"`. Default value is `"height"`.
287    #[builder(into)]
288    style_prop: String,
289}
290
291impl Default for UseTextareaAutosizeOptions {
292    fn default() -> Self {
293        Self {
294            content: MaybeRwSignal::default(),
295            watch: Arc::new(|| ()),
296            on_resize: Arc::new(|| ()),
297            style_target: Default::default(),
298            style_prop: "height".to_string(),
299        }
300    }
301}
302
303impl UseTextareaAutosizeOptions {
304    /// List of elementss that should not trigger the callback. Defaults to `[]`.
305    #[cfg_attr(feature = "ssr", allow(dead_code))]
306    pub fn style_target<M>(
307        self,
308        style_target: impl IntoElementMaybeSignal<web_sys::Element, M>,
309    ) -> Self {
310        Self {
311            style_target: style_target.into_element_maybe_signal(),
312            ..self
313        }
314    }
315}
316
317/// Return type of [`fn@crate::use_textarea_autosize`].
318// #[doc(cfg(feature = "use_textarea_autosize"))]
319pub struct UseTextareaAutosizeReturn<F>
320where
321    F: Fn() + Clone + Send + Sync,
322{
323    /// The textarea content
324    pub content: Signal<String>,
325
326    /// Set the textarea content
327    pub set_content: WriteSignal<String>,
328
329    /// Function to trigger a textarea resize manually
330    pub trigger_resize: F,
331}
332
333#[cfg(not(feature = "ssr"))]
334fn parse_num(s: &str) -> u32 {
335    s.chars()
336        .map_while(|c| c.to_digit(10))
337        .fold(0, |acc, digit| acc * 10 + digit)
338}