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}