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}