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}