Skip to main content

leptos_classes/
convert.rs

1// Coherence invariant: the slice and array impls fall into two pairs - "name only" (`&[N]`,
2// `[N; M]`) and "name + condition" (`&[(N, C)]`, `[(N, C); M]`). The pairs coexist only because no
3// `From<(_, _)> for ClassName` impl exists in the crate. Adding such an impl would make the plain
4// and tuple variants overlap.
5
6use std::borrow::Cow;
7
8use crate::Classes;
9use crate::class_name::ClassName;
10use crate::condition::ClassCondition;
11
12/// Creates a `Classes` containing a single always-active class name from a `&'static str`.
13///
14/// The input is treated as **one** class name. Panics if the string is empty, whitespace-only,
15/// or contains any whitespace (Unicode definition). See [`Classes::add`] for the validation
16/// policy.
17///
18/// For a runtime string that may contain multiple whitespace-separated tokens, use
19/// [`Classes::parse`] instead. For runtime input you want to handle without a panic, pre-validate
20/// with [`ClassName::try_new`].
21///
22/// # Examples
23/// ```
24/// use assertr::prelude::*;
25/// use leptos_classes::Classes;
26///
27/// let c: Classes = "btn-primary".into();
28/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
29/// ```
30impl From<&'static str> for Classes {
31    fn from(name: &'static str) -> Self {
32        Classes::new().add(name)
33    }
34}
35
36/// Creates a `Classes` containing a single always-active class token from an owned `String`.
37///
38/// Same validation as [`Classes::add`]. Use [`Classes::parse`] for a runtime `String` that may
39/// contain whitespace-separated tokens.
40///
41/// # Examples
42/// ```
43/// use assertr::prelude::*;
44/// use leptos_classes::Classes;
45///
46/// let c: Classes = String::from("btn-primary").into();
47/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
48/// ```
49impl From<String> for Classes {
50    fn from(name: String) -> Self {
51        Classes::new().add(name)
52    }
53}
54
55/// Creates a `Classes` containing a single always-active class token from a `Cow<'static, str>`.
56///
57/// Same validation as [`Classes::add`]. Useful when the caller already holds a `Cow`,
58/// e.g. from a configuration lookup. Use [`Classes::parse`] for runtime input that may contain
59/// whitespace-separated tokens.
60///
61/// # Examples
62/// ```
63/// use std::borrow::Cow;
64/// use assertr::prelude::*;
65/// use leptos_classes::Classes;
66///
67/// let c: Classes = Cow::Borrowed("btn-primary").into();
68/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
69/// ```
70impl From<Cow<'static, str>> for Classes {
71    fn from(name: Cow<'static, str>) -> Self {
72        Classes::new().add(name)
73    }
74}
75
76/// Creates a `Classes` containing a single always-active entry from a pre-validated [`ClassName`].
77///
78/// Use this when you constructed a [`ClassName`] via [`ClassName::try_new`] (the non-panicking
79/// constructor); the conversion itself cannot fail.
80///
81/// # Examples
82/// ```
83/// use assertr::prelude::*;
84/// use leptos_classes::{ClassName, Classes};
85///
86/// let name = ClassName::try_new("btn-primary").unwrap();
87/// let c: Classes = name.into();
88/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
89/// ```
90impl From<ClassName> for Classes {
91    fn from(name: ClassName) -> Self {
92        Classes::new().add(name)
93    }
94}
95
96/// Creates a `Classes` from a slice of class tokens, each added as an always-active entry.
97///
98/// Each element is validated independently per [`Classes::add`]: any invalid token panics.
99/// Duplicate tokens within the slice also panic; see the `# Duplicate Handling` section on
100/// [`Classes`].
101///
102/// `N` must be `Clone` because the impl clones each element during conversion. All four
103/// built-in `Into<ClassName>` sources (`&'static str`, `String`, `Cow<'static, str>`,
104/// `ClassName`) satisfy this.
105///
106/// # Examples
107/// ```
108/// use assertr::prelude::*;
109/// use leptos_classes::Classes;
110///
111/// let names: &[&'static str] = &["btn", "btn-primary"];
112/// let c: Classes = names.into();
113/// assert_that!(c.to_class_string()).is_equal_to("btn btn-primary");
114/// ```
115impl<N: Clone + Into<ClassName>> From<&[N]> for Classes {
116    fn from(names: &[N]) -> Self {
117        Classes::new().add_all(names.iter().cloned())
118    }
119}
120
121/// Creates a `Classes` from an array of class tokens, each added as an always-active entry.
122///
123/// Each element is validated independently per [`Classes::add`]: any invalid token panics.
124/// Duplicate tokens within the array also panic; see the `# Duplicate Handling` section on
125/// [`Classes`].
126///
127/// # Examples
128/// ```
129/// use assertr::prelude::*;
130/// use leptos_classes::Classes;
131///
132/// let c: Classes = ["btn", "btn-primary", "btn-large"].into();
133/// assert_that!(c.to_class_string()).is_equal_to("btn btn-primary btn-large");
134/// ```
135impl<N: Into<ClassName>, const M: usize> From<[N; M]> for Classes {
136    fn from(names: [N; M]) -> Self {
137        Classes::new().add_all(names)
138    }
139}
140
141/// Creates a `Classes` containing a single reactive class entry from a `(name, condition)` tuple.
142///
143/// `name` is validated per [`Classes::add`]; see [`Classes::add_reactive`] for the accepted
144/// condition shapes.
145///
146/// # Examples
147/// ```
148/// use assertr::prelude::*;
149/// use leptos::prelude::*;
150/// use leptos_classes::Classes;
151///
152/// let (is_active, _) = signal(true);
153/// let c: Classes = ("active", is_active).into();
154/// assert_that!(c.to_class_string()).is_equal_to("active");
155/// ```
156impl<N: Into<ClassName>, C: Into<ClassCondition>> From<(N, C)> for Classes {
157    fn from((name, when): (N, C)) -> Self {
158        Classes::new().add_reactive(name, when)
159    }
160}
161
162/// Creates a `Classes` from a slice of `(name, condition)` pairs, each added as a reactive entry.
163///
164/// Names are validated per [`Classes::add`]; see [`Classes::add_reactive`] for the accepted
165/// condition shapes. Duplicate tokens within the slice also panic; see the `# Duplicate Handling`
166/// section on [`Classes`].
167///
168/// Both `N` and `C` must be `Clone` because the impl clones each element during conversion.
169/// All five built-in `Into<ClassCondition>` sources (`bool`, `Signal<bool>`, `ReadSignal<bool>`,
170/// `RwSignal<bool>`, `Memo<bool>`) are `Clone`; bare closures are not and must be placed in an
171/// array (`[(N, C); M]`) instead.
172///
173/// # Examples
174/// ```
175/// use assertr::prelude::*;
176/// use leptos::prelude::*;
177/// use leptos_classes::Classes;
178///
179/// let (is_first, _) = signal(true);
180/// let (is_second, _) = signal(false);
181/// let entries: &[(&'static str, ReadSignal<bool>)] =
182///     &[("first", is_first), ("second", is_second)];
183/// let c: Classes = entries.into();
184/// assert_that!(c.to_class_string()).is_equal_to("first");
185/// ```
186impl<N: Clone + Into<ClassName>, C: Clone + Into<ClassCondition>> From<&[(N, C)]> for Classes {
187    fn from(entries: &[(N, C)]) -> Self {
188        let mut classes = Classes::new();
189        for (name, when) in entries.iter().cloned() {
190            classes = classes.add_reactive(name, when);
191        }
192        classes
193    }
194}
195
196/// Creates a `Classes` from an array of `(name, condition)` pairs, each added as a reactive entry.
197///
198/// Names are validated per [`Classes::add`]; see [`Classes::add_reactive`] for the accepted
199/// condition shapes. Duplicate tokens within the array also panic; see the `# Duplicate Handling`
200/// section on [`Classes`].
201///
202/// # Examples
203/// ```
204/// use assertr::prelude::*;
205/// use leptos::prelude::*;
206/// use leptos_classes::Classes;
207///
208/// let (is_first, _) = signal(true);
209/// let (is_second, _) = signal(false);
210/// let c: Classes = [("first", is_first), ("second", is_second)].into();
211/// assert_that!(c.to_class_string()).is_equal_to("first");
212/// ```
213impl<N: Into<ClassName>, C: Into<ClassCondition>, const M: usize> From<[(N, C); M]> for Classes {
214    fn from(entries: [(N, C); M]) -> Self {
215        let mut classes = Classes::new();
216        for (name, when) in entries {
217            classes = classes.add_reactive(name, when);
218        }
219        classes
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use std::borrow::Cow;
226
227    use assertr::prelude::*;
228    use leptos::prelude::{Get, Memo, ReadSignal, RwSignal, Set, signal};
229
230    use crate::{ClassName, Classes};
231
232    mod from_unconditional {
233        use super::*;
234
235        #[test]
236        fn static_str_renders_token() {
237            let classes: Classes = "foo".into();
238            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
239        }
240
241        #[test]
242        fn string_renders_token() {
243            let classes: Classes = String::from("foo").into();
244            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
245        }
246
247        #[test]
248        fn cow_borrowed_renders_token() {
249            let classes: Classes = Cow::Borrowed("foo").into();
250            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
251        }
252
253        #[test]
254        fn cow_owned_renders_token() {
255            let cow: Cow<'static, str> = Cow::Owned(String::from("foo"));
256            let classes: Classes = cow.into();
257            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
258        }
259
260        #[test]
261        fn class_name_renders_token() {
262            let name = ClassName::try_new("foo").unwrap();
263            let classes: Classes = name.into();
264            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
265        }
266
267        #[test]
268        fn slice_of_static_str_renders_all_tokens_in_order() {
269            let names: &[&'static str] = &["foo", "bar", "baz"];
270            let classes: Classes = names.into();
271            assert_that!(classes.to_class_string()).is_equal_to("foo bar baz".to_string());
272        }
273
274        #[test]
275        fn slice_of_string_renders_all_tokens_in_order() {
276            let names: &[String] = &[String::from("foo"), String::from("bar")];
277            let classes: Classes = names.into();
278            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
279        }
280
281        #[test]
282        fn array_of_static_str_renders_all_tokens_in_order() {
283            let classes = Classes::from(["foo", "bar", "baz"]);
284            assert_that!(classes.to_class_string()).is_equal_to("foo bar baz".to_string());
285        }
286
287        #[test]
288        fn array_of_string_renders_all_tokens_in_order() {
289            let classes = Classes::from([String::from("foo"), String::from("bar")]);
290            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
291        }
292
293        #[test]
294        fn slice_of_class_name_renders_all_tokens_in_order() {
295            let names: &[ClassName] = &[
296                ClassName::try_new("foo").unwrap(),
297                ClassName::try_new("bar").unwrap(),
298            ];
299            let classes: Classes = names.into();
300            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
301        }
302
303        #[test]
304        fn array_of_class_name_renders_all_tokens_in_order() {
305            let classes = Classes::from([
306                ClassName::try_new("foo").unwrap(),
307                ClassName::try_new("bar").unwrap(),
308            ]);
309            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
310        }
311
312        #[test]
313        fn empty_slice_yields_empty_classes() {
314            let names: &[&'static str] = &[];
315            let classes: Classes = names.into();
316            assert_that!(classes.to_class_string()).is_equal_to(String::new());
317        }
318
319        #[test]
320        fn empty_array_yields_empty_classes() {
321            let names: [&'static str; 0] = [];
322            let classes = Classes::from(names);
323            assert_that!(classes.to_class_string()).is_equal_to(String::new());
324        }
325    }
326
327    mod from_conditional {
328        use super::*;
329
330        // Each construction test verifies that a particular condition shape converts into
331        // `Classes` and renders its initial value. Mutation-propagation lives in a single
332        // shared test below: the runtime path is identical once a value reaches
333        // `ClassCondition` (via any `From` impl).
334
335        #[test]
336        fn read_signal_renders_initial_active_token() {
337            let (is_active, _) = signal(true);
338            let classes = Classes::from(("active", is_active));
339            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
340        }
341
342        #[test]
343        fn rw_signal_renders_initial_active_token() {
344            let is_active = RwSignal::new(true);
345            let classes = Classes::from(("active", is_active));
346            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
347        }
348
349        #[test]
350        fn memo_renders_initial_active_token() {
351            let backing = RwSignal::new(true);
352            let memo = Memo::new(move |_| backing.get());
353            let classes = Classes::from(("active", memo));
354            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
355        }
356
357        #[test]
358        fn closure_renders_initial_active_token() {
359            let (is_active, _) = signal(true);
360            let classes = Classes::from(("active", move || is_active.get()));
361            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
362        }
363
364        #[test]
365        fn string_name_with_signal_renders() {
366            let (is_active, _) = signal(true);
367            let classes = Classes::from((String::from("active"), is_active));
368            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
369        }
370
371        #[test]
372        fn array_of_tuples_renders_active_entries_only() {
373            let (is_first, _) = signal(true);
374            let (is_second, _) = signal(false);
375            let classes = Classes::from([("first", is_first), ("second", is_second)]);
376            assert_that!(classes.to_class_string()).is_equal_to("first".to_string());
377        }
378
379        #[test]
380        fn slice_of_tuples_renders_active_entries_only() {
381            let (is_first, _) = signal(true);
382            let (is_second, _) = signal(false);
383            let entries: &[(&'static str, ReadSignal<bool>)] =
384                &[("first", is_first), ("second", is_second)];
385            let classes: Classes = entries.into();
386            assert_that!(classes.to_class_string()).is_equal_to("first".to_string());
387        }
388
389        #[test]
390        fn signal_mutation_propagates_to_render() {
391            // One shared reactivity test. Other condition shapes (`RwSignal`, `Memo`,
392            // closures) route through the same `When(Signal<bool>)` arm, so covering
393            // `ReadSignal` covers them.
394            let (is_active, set_is_active) = signal(true);
395            let classes = Classes::from(("active", is_active));
396
397            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
398
399            set_is_active.set(false);
400            assert_that!(classes.to_class_string()).is_equal_to(String::new());
401
402            set_is_active.set(true);
403            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
404        }
405    }
406}