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}