leptos-classes 0.1.2

Prop-drillable, reactive class container for Leptos.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// Coherence invariant: the slice and array impls fall into two pairs - "name only" (`&[N]`,
// `[N; M]`) and "name + condition" (`&[(N, C)]`, `[(N, C); M]`). The pairs coexist only because no
// `From<(_, _)> for ClassName` impl exists in the crate. Adding such an impl would make the plain
// and tuple variants overlap.

use std::borrow::Cow;

use crate::Classes;
use crate::class_name::ClassName;
use crate::condition::ClassCondition;

/// Creates a `Classes` containing a single always-active class name from a `&'static str`.
///
/// The input is treated as **one** class name. Panics if the string is empty, whitespace-only,
/// or contains any whitespace (Unicode definition). See [`Classes::add`] for the validation
/// policy.
///
/// For a runtime string that may contain multiple whitespace-separated tokens, use
/// [`Classes::parse`] instead. For runtime input you want to handle without a panic, pre-validate
/// with [`ClassName::try_new`].
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos_classes::Classes;
///
/// let c: Classes = "btn-primary".into();
/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
/// ```
impl From<&'static str> for Classes {
    fn from(name: &'static str) -> Self {
        Classes::new().add(name)
    }
}

/// Creates a `Classes` containing a single always-active class token from an owned `String`.
///
/// Same validation as [`Classes::add`]. Use [`Classes::parse`] for a runtime `String` that may
/// contain whitespace-separated tokens.
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos_classes::Classes;
///
/// let c: Classes = String::from("btn-primary").into();
/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
/// ```
impl From<String> for Classes {
    fn from(name: String) -> Self {
        Classes::new().add(name)
    }
}

/// Creates a `Classes` containing a single always-active class token from a `Cow<'static, str>`.
///
/// Same validation as [`Classes::add`]. Useful when the caller already holds a `Cow`,
/// e.g. from a configuration lookup. Use [`Classes::parse`] for runtime input that may contain
/// whitespace-separated tokens.
///
/// # Examples
/// ```
/// use std::borrow::Cow;
/// use assertr::prelude::*;
/// use leptos_classes::Classes;
///
/// let c: Classes = Cow::Borrowed("btn-primary").into();
/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
/// ```
impl From<Cow<'static, str>> for Classes {
    fn from(name: Cow<'static, str>) -> Self {
        Classes::new().add(name)
    }
}

/// Creates a `Classes` containing a single always-active entry from a pre-validated [`ClassName`].
///
/// Use this when you constructed a [`ClassName`] via [`ClassName::try_new`] (the non-panicking
/// constructor); the conversion itself cannot fail.
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos_classes::{ClassName, Classes};
///
/// let name = ClassName::try_new("btn-primary").unwrap();
/// let c: Classes = name.into();
/// assert_that!(c.to_class_string()).is_equal_to("btn-primary");
/// ```
impl From<ClassName> for Classes {
    fn from(name: ClassName) -> Self {
        Classes::new().add(name)
    }
}

/// Creates a `Classes` from a slice of class tokens, each added as an always-active entry.
///
/// Each element is validated independently per [`Classes::add`]: any invalid token panics.
/// Duplicate tokens within the slice also panic; see the `# Duplicate Handling` section on
/// [`Classes`].
///
/// `N` must be `Clone` because the impl clones each element during conversion. All four
/// built-in `Into<ClassName>` sources (`&'static str`, `String`, `Cow<'static, str>`,
/// `ClassName`) satisfy this.
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos_classes::Classes;
///
/// let names: &[&'static str] = &["btn", "btn-primary"];
/// let c: Classes = names.into();
/// assert_that!(c.to_class_string()).is_equal_to("btn btn-primary");
/// ```
impl<N: Clone + Into<ClassName>> From<&[N]> for Classes {
    fn from(names: &[N]) -> Self {
        Classes::new().add_all(names.iter().cloned())
    }
}

/// Creates a `Classes` from an array of class tokens, each added as an always-active entry.
///
/// Each element is validated independently per [`Classes::add`]: any invalid token panics.
/// Duplicate tokens within the array also panic; see the `# Duplicate Handling` section on
/// [`Classes`].
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos_classes::Classes;
///
/// let c: Classes = ["btn", "btn-primary", "btn-large"].into();
/// assert_that!(c.to_class_string()).is_equal_to("btn btn-primary btn-large");
/// ```
impl<N: Into<ClassName>, const M: usize> From<[N; M]> for Classes {
    fn from(names: [N; M]) -> Self {
        Classes::new().add_all(names)
    }
}

/// Creates a `Classes` containing a single reactive class entry from a `(name, condition)` tuple.
///
/// `name` is validated per [`Classes::add`]; see [`Classes::add_reactive`] for the accepted
/// condition shapes.
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos::prelude::*;
/// use leptos_classes::Classes;
///
/// let (is_active, _) = signal(true);
/// let c: Classes = ("active", is_active).into();
/// assert_that!(c.to_class_string()).is_equal_to("active");
/// ```
impl<N: Into<ClassName>, C: Into<ClassCondition>> From<(N, C)> for Classes {
    fn from((name, when): (N, C)) -> Self {
        Classes::new().add_reactive(name, when)
    }
}

/// Creates a `Classes` from a slice of `(name, condition)` pairs, each added as a reactive entry.
///
/// Names are validated per [`Classes::add`]; see [`Classes::add_reactive`] for the accepted
/// condition shapes. Duplicate tokens within the slice also panic; see the `# Duplicate Handling`
/// section on [`Classes`].
///
/// Both `N` and `C` must be `Clone` because the impl clones each element during conversion.
/// All five built-in `Into<ClassCondition>` sources (`bool`, `Signal<bool>`, `ReadSignal<bool>`,
/// `RwSignal<bool>`, `Memo<bool>`) are `Clone`; bare closures are not and must be placed in an
/// array (`[(N, C); M]`) instead.
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos::prelude::*;
/// use leptos_classes::Classes;
///
/// let (is_first, _) = signal(true);
/// let (is_second, _) = signal(false);
/// let entries: &[(&'static str, ReadSignal<bool>)] =
///     &[("first", is_first), ("second", is_second)];
/// let c: Classes = entries.into();
/// assert_that!(c.to_class_string()).is_equal_to("first");
/// ```
impl<N: Clone + Into<ClassName>, C: Clone + Into<ClassCondition>> From<&[(N, C)]> for Classes {
    fn from(entries: &[(N, C)]) -> Self {
        let mut classes = Classes::new();
        for (name, when) in entries.iter().cloned() {
            classes = classes.add_reactive(name, when);
        }
        classes
    }
}

/// Creates a `Classes` from an array of `(name, condition)` pairs, each added as a reactive entry.
///
/// Names are validated per [`Classes::add`]; see [`Classes::add_reactive`] for the accepted
/// condition shapes. Duplicate tokens within the array also panic; see the `# Duplicate Handling`
/// section on [`Classes`].
///
/// # Examples
/// ```
/// use assertr::prelude::*;
/// use leptos::prelude::*;
/// use leptos_classes::Classes;
///
/// let (is_first, _) = signal(true);
/// let (is_second, _) = signal(false);
/// let c: Classes = [("first", is_first), ("second", is_second)].into();
/// assert_that!(c.to_class_string()).is_equal_to("first");
/// ```
impl<N: Into<ClassName>, C: Into<ClassCondition>, const M: usize> From<[(N, C); M]> for Classes {
    fn from(entries: [(N, C); M]) -> Self {
        let mut classes = Classes::new();
        for (name, when) in entries {
            classes = classes.add_reactive(name, when);
        }
        classes
    }
}

#[cfg(test)]
mod tests {
    use std::borrow::Cow;

    use assertr::prelude::*;
    use leptos::prelude::{Get, Memo, ReadSignal, RwSignal, Set, signal};

    use crate::{ClassName, Classes};

    mod from_unconditional {
        use super::*;

        #[test]
        fn static_str_renders_token() {
            let classes: Classes = "foo".into();
            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
        }

        #[test]
        fn string_renders_token() {
            let classes: Classes = String::from("foo").into();
            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
        }

        #[test]
        fn cow_borrowed_renders_token() {
            let classes: Classes = Cow::Borrowed("foo").into();
            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
        }

        #[test]
        fn cow_owned_renders_token() {
            let cow: Cow<'static, str> = Cow::Owned(String::from("foo"));
            let classes: Classes = cow.into();
            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
        }

        #[test]
        fn class_name_renders_token() {
            let name = ClassName::try_new("foo").unwrap();
            let classes: Classes = name.into();
            assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
        }

        #[test]
        fn slice_of_static_str_renders_all_tokens_in_order() {
            let names: &[&'static str] = &["foo", "bar", "baz"];
            let classes: Classes = names.into();
            assert_that!(classes.to_class_string()).is_equal_to("foo bar baz".to_string());
        }

        #[test]
        fn slice_of_string_renders_all_tokens_in_order() {
            let names: &[String] = &[String::from("foo"), String::from("bar")];
            let classes: Classes = names.into();
            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
        }

        #[test]
        fn array_of_static_str_renders_all_tokens_in_order() {
            let classes = Classes::from(["foo", "bar", "baz"]);
            assert_that!(classes.to_class_string()).is_equal_to("foo bar baz".to_string());
        }

        #[test]
        fn array_of_string_renders_all_tokens_in_order() {
            let classes = Classes::from([String::from("foo"), String::from("bar")]);
            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
        }

        #[test]
        fn slice_of_class_name_renders_all_tokens_in_order() {
            let names: &[ClassName] = &[
                ClassName::try_new("foo").unwrap(),
                ClassName::try_new("bar").unwrap(),
            ];
            let classes: Classes = names.into();
            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
        }

        #[test]
        fn array_of_class_name_renders_all_tokens_in_order() {
            let classes = Classes::from([
                ClassName::try_new("foo").unwrap(),
                ClassName::try_new("bar").unwrap(),
            ]);
            assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
        }

        #[test]
        fn empty_slice_yields_empty_classes() {
            let names: &[&'static str] = &[];
            let classes: Classes = names.into();
            assert_that!(classes.to_class_string()).is_equal_to(String::new());
        }

        #[test]
        fn empty_array_yields_empty_classes() {
            let names: [&'static str; 0] = [];
            let classes = Classes::from(names);
            assert_that!(classes.to_class_string()).is_equal_to(String::new());
        }
    }

    mod from_conditional {
        use super::*;

        // Each construction test verifies that a particular condition shape converts into
        // `Classes` and renders its initial value. Mutation-propagation lives in a single
        // shared test below: the runtime path is identical once a value reaches
        // `ClassCondition` (via any `From` impl).

        #[test]
        fn read_signal_renders_initial_active_token() {
            let (is_active, _) = signal(true);
            let classes = Classes::from(("active", is_active));
            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
        }

        #[test]
        fn rw_signal_renders_initial_active_token() {
            let is_active = RwSignal::new(true);
            let classes = Classes::from(("active", is_active));
            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
        }

        #[test]
        fn memo_renders_initial_active_token() {
            let backing = RwSignal::new(true);
            let memo = Memo::new(move |_| backing.get());
            let classes = Classes::from(("active", memo));
            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
        }

        #[test]
        fn closure_renders_initial_active_token() {
            let (is_active, _) = signal(true);
            let classes = Classes::from(("active", move || is_active.get()));
            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
        }

        #[test]
        fn string_name_with_signal_renders() {
            let (is_active, _) = signal(true);
            let classes = Classes::from((String::from("active"), is_active));
            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
        }

        #[test]
        fn array_of_tuples_renders_active_entries_only() {
            let (is_first, _) = signal(true);
            let (is_second, _) = signal(false);
            let classes = Classes::from([("first", is_first), ("second", is_second)]);
            assert_that!(classes.to_class_string()).is_equal_to("first".to_string());
        }

        #[test]
        fn slice_of_tuples_renders_active_entries_only() {
            let (is_first, _) = signal(true);
            let (is_second, _) = signal(false);
            let entries: &[(&'static str, ReadSignal<bool>)] =
                &[("first", is_first), ("second", is_second)];
            let classes: Classes = entries.into();
            assert_that!(classes.to_class_string()).is_equal_to("first".to_string());
        }

        #[test]
        fn signal_mutation_propagates_to_render() {
            // One shared reactivity test. Other condition shapes (`RwSignal`, `Memo`,
            // closures) route through the same `When(Signal<bool>)` arm, so covering
            // `ReadSignal` covers them.
            let (is_active, set_is_active) = signal(true);
            let classes = Classes::from(("active", is_active));

            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());

            set_is_active.set(false);
            assert_that!(classes.to_class_string()).is_equal_to(String::new());

            set_is_active.set(true);
            assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
        }
    }
}