leptos_classes/classes.rs
1use crate::class_list::ClassList;
2use crate::class_name::ClassName;
3use crate::condition::ClassCondition;
4
5/// Strategy for handling token collisions in [`Classes::merge`] and
6/// [`ClassesBuilder::with_merged`].
7///
8/// The variants differ only in what happens on a token collision; non-overlapping entries are
9/// appended in either case. [`UnionConditions`](Self::UnionConditions) is the [`Default`] and the
10/// recommended choice when you do not have a specific reason to pick another: it never panics
11/// (regardless of what the caller passes in) and never silently drops a caller-supplied
12/// condition.
13///
14/// # Comparison with `leptos-styles`
15///
16/// `Styles::merge` in the sibling crate has a fixed override-with-fallback semantic
17/// (theme-then-user-override layering). `Classes::merge` instead asks the caller to choose,
18/// because classes have no values to "override" - the right behavior on collision depends entirely
19/// on the caller's intent.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum MergeStrategy {
22 /// On collision, replace `self`'s entry with one whose condition is the logical OR of both
23 /// sides. The token renders when *either* condition is active.
24 ///
25 /// Does not preserve toggle-pair structure: if either side of the collision was a toggle
26 /// half, the OR produces a well-defined condition for the merged entry, but the other half
27 /// of any colliding toggle is left as an orphan flat entry.
28 #[default]
29 UnionConditions,
30 /// On collision, drop the entry from `other` and leave `self`'s entry unchanged. Useful when
31 /// `self` is a layered default that must win against override attempts. To get the opposite
32 /// direction (`other` wins), call `other.merge(self, MergeStrategy::KeepSelf)` - this yields
33 /// the same entry set with `other`'s conditions surviving collisions.
34 ///
35 /// If a dropped entry was half of a toggle pair in `other`, the surviving half lands as a
36 /// regular flat entry under its own reactive condition - the toggle pair is not preserved as
37 /// a structural unit.
38 KeepSelf,
39 /// Panic with the standard duplicate-class-token message on the first colliding entry from
40 /// `other`. The strictest option; equivalent to manually re-adding each entry of `other` into
41 /// `self` via `add` / `add_reactive` / `add_toggle`.
42 ///
43 /// Only safe when both sides of the merge are under your own control. Avoid this strategy
44 /// for any merge that combines an arbitrary `classes: Classes` prop with internal classes:
45 /// a caller passing a colliding token would crash the component. Use
46 /// [`UnionConditions`](Self::UnionConditions) or [`KeepSelf`](Self::KeepSelf) there.
47 PanicOnConflict,
48}
49
50/// Leptos component-prop-utility to drill down a list of classes.
51///
52/// # Duplicate Handling
53///
54/// Each class token may appear in at most one entry across a `Classes` value. Registering the
55/// same token twice, including the case where an `add` / `add_reactive` entry's name matches one
56/// branch of an `add_toggle`, panics in both debug and release builds at the point of insertion.
57/// Compose conditions instead: if you want `"foo"` to render when either of two signals is true,
58/// write `add_reactive("foo", move || a.get() || b.get())` rather than adding `"foo"` twice.
59///
60/// # Class Token Validation
61///
62/// Each entry must be one class token, not a whitespace-separated class string. Invalid
63/// input (empty, whitespace-only, or containing any whitespace, by the Unicode definition)
64/// panics in both debug and release builds at the [`ClassName`] conversion. For runtime input
65/// you want to handle without a panic, validate via [`ClassName::try_new`] and only feed
66/// successfully-validated tokens through the entry methods.
67///
68/// # Attribute Ownership
69///
70/// `Classes` represents a complete `class="..."` attribute value. When rendered onto an element,
71/// it owns the full `class` attribute and will overwrite unmanaged class mutations on the next
72/// managed update pass or rebuild.
73///
74/// # Example
75/// ```rust
76/// use leptos::prelude::*;
77/// use leptos_classes::Classes;
78///
79/// /// The lowest-level component renders the class-list onto an actual HTML element.
80/// #[component]
81/// fn NeedingClasses(
82/// #[prop(into, optional)] classes: Classes,
83/// ) -> impl IntoView {
84/// view! {
85/// <div class=classes/>
86/// }
87/// }
88///
89/// /// Components sitting in the middle can add their own classes.
90/// #[component]
91/// fn ExtendingClasses(
92/// #[prop(into, optional)] classes: Classes,
93/// ) -> impl IntoView {
94/// view! {
95/// <NeedingClasses classes=classes.add("additional-class")/>
96/// }
97/// }
98///
99/// /// Root component defines the initial classes using a builder pattern or can rely on `Into`
100/// /// conversions (see docs).
101/// #[component]
102/// fn ProvidingClasses() -> impl IntoView {
103/// let (show_second, _) = signal(true);
104/// view! {
105/// <ExtendingClasses classes="single-class"/>
106/// <ExtendingClasses classes=Classes::builder()
107/// .with("first")
108/// .with_reactive("second", show_second)
109/// .build()/>
110/// }
111/// }
112/// ```
113#[derive(Clone, Debug, Default)]
114pub struct Classes {
115 pub(crate) classes: ClassList,
116}
117
118impl Classes {
119 /// Creates a builder for a class list.
120 #[must_use]
121 pub fn builder() -> ClassesBuilder {
122 ClassesBuilder::default()
123 }
124
125 /// Creates an empty class list.
126 #[must_use]
127 pub fn new() -> Self {
128 Self {
129 classes: ClassList::empty(),
130 }
131 }
132
133 /// Parses a whitespace-separated class string into a list of always-active entries.
134 ///
135 /// Splits `input` on Unicode whitespace ([`str::split_whitespace`]) and creates one
136 /// always-active entry per non-empty token. Empty input or whitespace-only input produces an
137 /// empty `Classes`. Non-breaking spaces (`U+00A0`) and other non-ASCII whitespace split
138 /// tokens just like ASCII whitespace, so pasting `"foo\u{00A0}bar"` from a rich-text source
139 /// yields two tokens rather than one whitespace-bearing token that would then fail
140 /// validation.
141 ///
142 /// Unlike `Classes::from(&str)`, which treats its argument as a single class token (and
143 /// panics on embedded whitespace), `parse` is the explicit opt-in for turning a runtime
144 /// `"foo bar baz"` style string into multiple class entries.
145 ///
146 /// Tokens are inserted with the same uniqueness rule as [`Classes::add`]: if `input`
147 /// contains the same token more than once (e.g. `"foo foo"`), the second insertion panics.
148 /// Pre-deduplicate runtime input if you cannot guarantee distinct tokens.
149 ///
150 /// # Example
151 /// ```rust
152 /// use assertr::prelude::*;
153 /// use leptos_classes::Classes;
154 ///
155 /// let classes = Classes::parse("btn btn-primary btn-large");
156 /// assert_that!(classes.to_class_string()).is_equal_to("btn btn-primary btn-large");
157 /// ```
158 #[must_use]
159 pub fn parse(input: &str) -> Self {
160 Self::new().add_parsed(input)
161 }
162
163 /// Adds one always-active class token.
164 ///
165 /// Panics if `name` is empty, whitespace-only, or contains any whitespace (Unicode
166 /// definition: see [`char::is_whitespace`]), or if the token is already present in this
167 /// `Classes` (see [Duplicate Handling](Classes#duplicate-handling)).
168 #[must_use]
169 #[allow(clippy::should_implement_trait)]
170 pub fn add(mut self, name: impl Into<ClassName>) -> Self {
171 self.classes
172 .add_single(name.into(), ClassCondition::always());
173 self
174 }
175
176 /// Adds one reactive class token, controlled by `when`.
177 ///
178 /// Same validation policy for `name` as [`Classes::add`].
179 ///
180 /// # Accepted `when` shapes
181 ///
182 /// `when` accepts any value that converts into the internal condition type:
183 ///
184 /// - `bool` - treated as always-active when `true`, never-active when `false`; no
185 /// reactive subscription is installed.
186 /// - `Signal<bool>` - reactive Leptos signal.
187 /// - `ReadSignal<bool>` - read half of a `signal(...)` pair.
188 /// - `RwSignal<bool>` - reactive read-write signal.
189 /// - `Memo<bool>` - reactive memoized computation.
190 /// - Any `Fn() -> bool + Send + Sync + 'static` closure, e.g.
191 /// `move || is_active.get() && !disabled.get()`.
192 #[must_use]
193 pub fn add_reactive(
194 mut self,
195 name: impl Into<ClassName>,
196 when: impl Into<ClassCondition>,
197 ) -> Self {
198 self.classes.add_single(name.into(), when.into());
199 self
200 }
201
202 /// Adds multiple always-active class tokens.
203 ///
204 /// Each `name` is validated independently per [`Classes::add`]'s policy. Iteration
205 /// short-circuits on the first invalid or duplicate token: the panic fires from inside
206 /// the loop, so items past the offending one are never inspected.
207 #[must_use]
208 pub fn add_all<I>(mut self, iter: I) -> Self
209 where
210 I: IntoIterator,
211 I::Item: Into<ClassName>,
212 {
213 for name in iter {
214 self.classes
215 .add_single(name.into(), ClassCondition::always());
216 }
217 self
218 }
219
220 /// Splits `input` on Unicode whitespace ([`str::split_whitespace`]) and appends each
221 /// non-empty token as an always-active class entry.
222 ///
223 /// Use this when you have a runtime class string you cannot pre-tokenize. Empty input or
224 /// whitespace-only input is a no-op. Non-breaking spaces (`U+00A0`) and other non-ASCII
225 /// whitespace split tokens just like ASCII whitespace.
226 ///
227 /// Tokens land under the same uniqueness rule as [`Classes::add`]: a token from `input`
228 /// that duplicates one already present on `self`, or that appears twice within `input`
229 /// itself, panics at insertion time. Pre-deduplicate runtime input if you cannot guarantee
230 /// distinct tokens.
231 ///
232 /// # Example
233 /// ```rust
234 /// use assertr::prelude::*;
235 /// use leptos_classes::Classes;
236 ///
237 /// let classes = Classes::from("base").add_parsed(" primary large ");
238 /// assert_that!(classes.to_class_string()).is_equal_to("base primary large");
239 /// ```
240 #[must_use]
241 pub fn add_parsed(self, input: &str) -> Self {
242 self.add_all(input.split_whitespace().map(str::to_owned))
243 }
244
245 /// Adds a pair of mutually exclusive reactive classes.
246 ///
247 /// The `when_true` class is active when the condition is `true`, the `when_false` class
248 /// when it is `false`. Panics if either branch is invalid (empty, whitespace-only, or
249 /// containing any whitespace, by the Unicode definition: see [`char::is_whitespace`]), if
250 /// `when_true` equals `when_false`, or if either branch collides with a class token
251 /// already registered on this `Classes` (see [Duplicate Handling](Classes#duplicate-handling)).
252 ///
253 /// See [`Classes::add_reactive`] for the list of accepted `when` shapes.
254 ///
255 /// # Example
256 /// ```rust
257 /// use leptos::prelude::*;
258 /// use leptos_classes::Classes;
259 ///
260 /// let (is_active, _) = signal(true);
261 /// let classes = Classes::new()
262 /// .add_toggle(is_active, "active", "inactive");
263 /// ```
264 #[must_use]
265 pub fn add_toggle(
266 mut self,
267 when: impl Into<ClassCondition>,
268 when_true: impl Into<ClassName>,
269 when_false: impl Into<ClassName>,
270 ) -> Self {
271 self.classes
272 .add_toggle(when.into(), when_true.into(), when_false.into());
273 self
274 }
275
276 /// Combines another `Classes` value into this one, appending every entry from `other` and
277 /// applying `strategy` on token collisions.
278 ///
279 /// Use `merge` when you receive two independently-produced `Classes` values (a
280 /// `classes: Classes` prop combined with a helper return, two hook return values, a
281 /// third-party value combined with your own) that you cannot fold into one chained
282 /// construction. When you control both producers, prefer chained `add_*` calls on a single
283 /// `Classes`.
284 ///
285 /// Prefer [`MergeStrategy::default()`] (which is
286 /// [`UnionConditions`](MergeStrategy::UnionConditions)) unless you have a specific reason to
287 /// drop or reject collisions. It is the only strategy that never panics on caller input and
288 /// never silently discards a caller-supplied condition. See [`MergeStrategy`] for per-variant
289 /// semantics, including how each strategy treats collisions that involve a toggle half
290 /// (toggle-pair structure is not preserved across any merge).
291 ///
292 /// # Example
293 /// ```rust
294 /// use leptos::prelude::*;
295 /// use leptos_classes::{Classes, MergeStrategy};
296 ///
297 /// /// A helper that produces a self-contained `Classes`.
298 /// fn primary_button_classes() -> Classes {
299 /// Classes::from("btn").add("bg-blue-600").add("text-white")
300 /// }
301 ///
302 /// /// Component receives a `Classes` prop and merges in its own internal classes.
303 /// /// `MergeStrategy::default()` (== `UnionConditions`) is the right pick here: a caller
304 /// /// passing a colliding token must not crash the component.
305 /// #[component]
306 /// fn Button(#[prop(into, optional)] classes: Classes) -> impl IntoView {
307 /// let merged = classes.merge(primary_button_classes(), MergeStrategy::default());
308 /// view! { <button class=merged>"Click me"</button> }
309 /// }
310 /// ```
311 #[must_use]
312 pub fn merge(mut self, other: Classes, strategy: MergeStrategy) -> Self {
313 self.classes.merge(other.classes, strategy);
314 self
315 }
316
317 /// Returns the currently active classes as a space-separated `String`.
318 ///
319 /// If called within a reactive scope, signal reads register the surrounding scope as a
320 /// subscriber. Prefer `class=classes` (via `IntoClass`) for rendering: it reuses the
321 /// string buffer across reactive updates instead of allocating a fresh `String` each time.
322 #[must_use]
323 pub fn to_class_string(&self) -> String {
324 let mut s = String::new();
325 self.write_active_classes(&mut s);
326 s
327 }
328
329 /// Appends all active classes to the given string buffer.
330 ///
331 /// If `buf` is non-empty, a single space is written before the first active token so it
332 /// separates cleanly from existing content. If no entries are active, `buf` is left
333 /// untouched. This method is zero-allocation when the buffer has sufficient capacity.
334 pub(crate) fn write_active_classes(&self, buf: &mut String) {
335 self.classes.write_active_classes(buf);
336 }
337
338 pub(crate) fn estimated_class_len(&self) -> usize {
339 self.classes.estimated_class_len()
340 }
341
342 /// Whether there is any reactivity involved in this set of classes. When this returns `true`,
343 /// rendering should take place in a reactivity-tracking context. When this returns `false`, one
344 /// could say that these classes are "static" in the sense that a one-time rendering is enough.
345 pub(crate) fn is_reactive(&self) -> bool {
346 self.classes.is_reactive()
347 }
348
349 pub(crate) fn touch_reactive_dependencies(&self) {
350 self.classes.touch_reactive_dependencies();
351 }
352}
353
354/// Builder for [`Classes`].
355#[derive(Clone, Debug, Default)]
356pub struct ClassesBuilder {
357 classes: ClassList,
358}
359
360impl ClassesBuilder {
361 /// Adds one always-active class token to the builder.
362 ///
363 /// Validation policy matches [`Classes::add`].
364 #[must_use]
365 pub fn with(mut self, name: impl Into<ClassName>) -> Self {
366 self.classes
367 .add_single(name.into(), ClassCondition::always());
368 self
369 }
370
371 /// Adds one reactive class token, controlled by `when`.
372 ///
373 /// Validation policy for `name` matches [`Classes::add`]. See
374 /// [`Classes::add_reactive`] for the list of accepted `when` shapes.
375 #[must_use]
376 pub fn with_reactive(
377 mut self,
378 name: impl Into<ClassName>,
379 when: impl Into<ClassCondition>,
380 ) -> Self {
381 self.classes.add_single(name.into(), when.into());
382 self
383 }
384
385 /// Adds multiple always-active class tokens to the builder. Same short-circuit panic
386 /// behavior as [`Classes::add_all`].
387 #[must_use]
388 pub fn with_all<I>(mut self, iter: I) -> Self
389 where
390 I: IntoIterator,
391 I::Item: Into<ClassName>,
392 {
393 for name in iter {
394 self.classes
395 .add_single(name.into(), ClassCondition::always());
396 }
397 self
398 }
399
400 /// Splits `input` on Unicode whitespace ([`str::split_whitespace`]) and adds each non-empty
401 /// token as an always-active class entry. Empty or whitespace-only input is a no-op. Same
402 /// duplicate-panic and whitespace-semantic behavior as [`Classes::add_parsed`].
403 #[must_use]
404 pub fn with_parsed(self, input: &str) -> Self {
405 self.with_all(input.split_whitespace().map(str::to_owned))
406 }
407
408 /// Adds a pair of mutually exclusive reactive classes. Mirrors [`Classes::add_toggle`].
409 ///
410 /// See [`Classes::add_reactive`] for the list of accepted `when` shapes.
411 ///
412 /// # Example
413 /// ```rust
414 /// use leptos::prelude::*;
415 /// use leptos_classes::Classes;
416 ///
417 /// let (is_active, _) = signal(true);
418 /// let classes = Classes::builder()
419 /// .with_toggle(is_active, "active", "inactive")
420 /// .build();
421 /// ```
422 #[must_use]
423 pub fn with_toggle(
424 mut self,
425 when: impl Into<ClassCondition>,
426 when_true: impl Into<ClassName>,
427 when_false: impl Into<ClassName>,
428 ) -> Self {
429 self.classes
430 .add_toggle(when.into(), when_true.into(), when_false.into());
431 self
432 }
433
434 /// Merges another `Classes` value into this builder. See [`Classes::merge`] for semantics
435 /// and the [`MergeStrategy`] variants.
436 ///
437 /// Prefer [`MergeStrategy::default()`] (which is
438 /// [`UnionConditions`](MergeStrategy::UnionConditions)) unless you specifically need
439 /// [`KeepSelf`](MergeStrategy::KeepSelf) or
440 /// [`PanicOnConflict`](MergeStrategy::PanicOnConflict). It is the only strategy that never
441 /// panics on caller input and never silently discards a caller-supplied condition.
442 #[must_use]
443 pub fn with_merged(mut self, other: Classes, strategy: MergeStrategy) -> Self {
444 self.classes.merge(other.classes, strategy);
445 self
446 }
447
448 /// Builds the configured [`Classes`].
449 #[must_use]
450 pub fn build(self) -> Classes {
451 Classes {
452 classes: self.classes,
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use assertr::prelude::*;
460 use leptos::prelude::{Get, Set, signal};
461
462 use crate::condition::ClassCondition;
463 use crate::{Classes, MergeStrategy};
464
465 mod construction {
466 use super::*;
467
468 #[test]
469 fn single_str_renders_token() {
470 let classes: Classes = "foo".into();
471 assert_that!(classes.to_class_string()).is_equal_to("foo");
472 }
473
474 #[test]
475 fn new_renders_nothing() {
476 let classes = Classes::new();
477 assert_that!(classes.to_class_string()).is_equal_to(String::new());
478 }
479
480 #[test]
481 fn add_chain_appends_tokens_in_order() {
482 let classes = Classes::new().add("foo").add("bar");
483 assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
484 }
485
486 #[test]
487 fn builder_with_chain_accumulates() {
488 let classes = Classes::builder().with("foo").with("bar").build();
489 assert_that!(classes.to_class_string()).is_equal_to("foo bar");
490 }
491
492 #[test]
493 fn extends_across_chained_layers() {
494 let initial: Classes = "base".into();
495 let extended = initial.add("extended");
496 let final_classes = extended.add("final");
497 assert_that!(final_classes.to_class_string())
498 .is_equal_to("base extended final".to_string());
499 }
500
501 #[test]
502 fn add_all_accepts_iterator() {
503 let classes = Classes::new().add_all(vec!["foo", "bar"]);
504 assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
505 }
506
507 #[test]
508 fn with_all_accepts_iterator() {
509 let classes = Classes::builder().with_all(vec!["foo", "bar"]).build();
510 assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
511 }
512
513 #[test]
514 fn from_tuple_with_bool_true_renders_token() {
515 let classes: Classes = ("foo", true).into();
516 assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
517 }
518
519 #[test]
520 fn from_tuple_with_bool_false_renders_nothing() {
521 let classes: Classes = ("foo", false).into();
522 assert_that!(classes.to_class_string()).is_equal_to(String::new());
523 }
524
525 #[test]
526 fn with_reactive_mix_renders_only_active_entries() {
527 let classes = Classes::builder()
528 .with_reactive("always", true)
529 .with_reactive("never", false)
530 .with_reactive("also-always", true)
531 .build();
532 assert_that!(classes.to_class_string()).is_equal_to("always also-always".to_string());
533 }
534 }
535
536 mod toggle {
537 use super::*;
538
539 #[test]
540 fn renders_true_branch_when_active() {
541 let classes = Classes::new().add_toggle(true, "active", "inactive");
542 assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
543 }
544
545 #[test]
546 fn renders_false_branch_when_inactive() {
547 let classes = Classes::new().add_toggle(false, "active", "inactive");
548 assert_that!(classes.to_class_string()).is_equal_to("inactive".to_string());
549 }
550
551 #[test]
552 fn static_bool_true_is_not_reactive() {
553 let classes = Classes::new().add_toggle(true, "active", "inactive");
554 assert_that!(classes.is_reactive()).is_false();
555 }
556
557 #[test]
558 fn static_bool_false_is_not_reactive() {
559 let classes = Classes::new().add_toggle(false, "active", "inactive");
560 assert_that!(classes.is_reactive()).is_false();
561 }
562
563 #[test]
564 fn chained_with_add_keeps_order() {
565 let classes = Classes::from("base")
566 .add_toggle(true, "on", "off")
567 .add("extra");
568 assert_that!(classes.to_class_string()).is_equal_to("base on extra".to_string());
569 }
570
571 #[test]
572 fn builder_renders_true_branch() {
573 let classes = Classes::builder()
574 .with("base")
575 .with_toggle(true, "on", "off")
576 .build();
577 assert_that!(classes.to_class_string()).is_equal_to("base on".to_string());
578 }
579
580 #[test]
581 fn builder_renders_false_branch() {
582 let classes = Classes::builder().with_toggle(false, "on", "off").build();
583 assert_that!(classes.to_class_string()).is_equal_to("off".to_string());
584 }
585 }
586
587 mod parsing {
588 use super::*;
589
590 #[test]
591 fn empty_or_whitespace_only_yields_empty() {
592 assert_that!(Classes::parse("").to_class_string()).is_equal_to(String::new());
593 assert_that!(Classes::parse(" \t\n").to_class_string()).is_equal_to(String::new());
594 }
595
596 #[test]
597 fn multiple_tokens_preserve_order() {
598 let classes = Classes::parse("btn btn-primary btn-large");
599 assert_that!(classes.to_class_string())
600 .is_equal_to("btn btn-primary btn-large".to_string());
601 }
602
603 #[test]
604 fn collapses_mixed_whitespace_separators() {
605 let classes = Classes::parse(" foo\tbar\n\nbaz ");
606 assert_that!(classes.to_class_string()).is_equal_to("foo bar baz".to_string());
607 }
608
609 #[test]
610 fn splits_on_non_breaking_space() {
611 // U+00A0 NO-BREAK SPACE sneaks in when text is pasted from rich-text sources.
612 // `parse` splits on Unicode whitespace, so this separates tokens cleanly instead of
613 // landing as one whitespace-bearing token that would then fail validation.
614 let classes = Classes::parse("foo\u{00A0}bar");
615 assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
616 }
617
618 #[test]
619 fn splits_on_mixed_ascii_and_unicode_whitespace() {
620 // ASCII space, NBSP, and line separator (U+2028) all separate tokens.
621 let classes = Classes::parse("foo bar\u{00A0}baz\u{2028}qux");
622 assert_that!(classes.to_class_string()).is_equal_to("foo bar baz qux".to_string());
623 }
624
625 #[test]
626 fn unicode_whitespace_only_yields_empty() {
627 assert_that!(Classes::parse("\u{00A0}\u{2028}").to_class_string())
628 .is_equal_to(String::new());
629 }
630
631 #[test]
632 fn result_is_not_reactive() {
633 let classes = Classes::parse("foo bar");
634 assert_that!(classes.is_reactive()).is_false();
635 }
636
637 #[test]
638 fn add_parsed_appends_to_existing() {
639 let classes = Classes::from("base").add_parsed("primary large");
640 assert_that!(classes.to_class_string()).is_equal_to("base primary large".to_string());
641 }
642
643 #[test]
644 fn add_parsed_chains_with_add_and_toggle() {
645 let classes = Classes::from("base")
646 .add_parsed("middle tail")
647 .add("extra")
648 .add_toggle(true, "on", "off");
649 assert_that!(classes.to_class_string())
650 .is_equal_to("base middle tail extra on".to_string());
651 }
652
653 #[test]
654 fn add_parsed_empty_input_is_noop() {
655 let classes = Classes::from("base").add_parsed("");
656 assert_that!(classes.to_class_string()).is_equal_to("base".to_string());
657 }
658
659 #[test]
660 fn with_parsed_in_builder() {
661 let classes = Classes::builder()
662 .with("base")
663 .with_parsed("middle tail")
664 .with_reactive("extra", true)
665 .build();
666 assert_that!(classes.to_class_string())
667 .is_equal_to("base middle tail extra".to_string());
668 }
669
670 #[test]
671 fn mixing_parsed_with_reactive_entry_makes_list_reactive() {
672 let (is_active, set_is_active) = signal(true);
673 let classes = Classes::parse("base middle").add_reactive("trailing", is_active);
674
675 assert_that!(classes.is_reactive()).is_true();
676 assert_that!(classes.to_class_string()).is_equal_to("base middle trailing".to_string());
677
678 set_is_active.set(false);
679 assert_that!(classes.to_class_string()).is_equal_to("base middle".to_string());
680 }
681
682 #[test]
683 #[should_panic(expected = "was registered with Classes more than once")]
684 fn parse_panics_on_intra_input_duplicate() {
685 let _ = Classes::parse("foo foo");
686 }
687
688 #[test]
689 #[should_panic(expected = "was registered with Classes more than once")]
690 fn add_parsed_panics_on_collision_with_existing_entry() {
691 let _ = Classes::from("base").add_parsed("base extra");
692 }
693 }
694
695 mod reactivity {
696 use super::*;
697
698 #[test]
699 fn signal_flip_updates_active_entry() {
700 let (is_active, set_is_active) = signal(true);
701 let classes = Classes::from(("active", is_active));
702
703 assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
704
705 set_is_active.set(false);
706 assert_that!(classes.to_class_string()).is_equal_to(String::new());
707 }
708
709 #[test]
710 fn signal_flip_swaps_toggle_branch() {
711 let (is_active, set_is_active) = signal(true);
712 let classes = Classes::new().add_toggle(is_active, "active", "inactive");
713
714 assert_that!(classes.is_reactive()).is_true();
715 assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
716
717 set_is_active.set(false);
718 assert_that!(classes.to_class_string()).is_equal_to("inactive".to_string());
719
720 set_is_active.set(true);
721 assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
722 }
723
724 #[test]
725 fn closure_drives_toggle_reactivity() {
726 let (is_active, set_is_active) = signal(true);
727 let classes = Classes::new().add_toggle(move || is_active.get(), "active", "inactive");
728
729 assert_that!(classes.is_reactive()).is_true();
730 assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
731
732 set_is_active.set(false);
733 assert_that!(classes.to_class_string()).is_equal_to("inactive".to_string());
734 }
735 }
736
737 mod validation {
738 use super::*;
739
740 #[test]
741 #[should_panic(expected = "Class name is empty or whitespace-only")]
742 fn empty_input_panics() {
743 let _ = Classes::from("");
744 }
745
746 #[test]
747 #[should_panic(expected = "Class name is empty or whitespace-only")]
748 fn whitespace_only_input_panics() {
749 let _ = Classes::builder().with(" ").build();
750 }
751
752 #[test]
753 #[should_panic(expected = "Class names must not be whitespace-separated")]
754 fn whitespace_separated_input_panics() {
755 let _ = Classes::from("foo bar");
756 }
757
758 #[test]
759 #[should_panic(expected = "Class names must not be whitespace-separated")]
760 fn whitespace_around_input_panics() {
761 let _ = Classes::from(" foo ");
762 }
763
764 #[test]
765 #[should_panic(expected = "Class names must not be whitespace-separated")]
766 fn non_breaking_space_inside_token_panics() {
767 // Single-token construction must reject NBSP just like ASCII whitespace, otherwise
768 // a token rendered into a `class` attribute would contain an unprintable space and
769 // not match any CSS selector the user expects.
770 let _ = Classes::from("foo\u{00A0}bar");
771 }
772
773 #[test]
774 #[should_panic(expected = "Class name is empty or whitespace-only")]
775 fn unicode_whitespace_only_input_panics() {
776 // NBSP alone classifies as whitespace-only under the Unicode definition.
777 let _ = Classes::from("\u{00A0}\u{00A0}");
778 }
779
780 #[test]
781 #[should_panic(expected = "Class name is empty or whitespace-only")]
782 fn add_with_empty_panics() {
783 let _ = Classes::from("base").add("");
784 }
785
786 #[test]
787 #[should_panic(expected = "Class name is empty or whitespace-only")]
788 fn toggle_branch_empty_panics() {
789 let _ = Classes::from("base").add_toggle(false, "active", "");
790 }
791
792 #[test]
793 #[should_panic(expected = "Class name is empty or whitespace-only")]
794 fn add_all_panics_on_first_invalid_item() {
795 let _ = Classes::new().add_all(["foo", ""]);
796 }
797
798 #[test]
799 #[should_panic(expected = "add_toggle requires two distinct branch names")]
800 fn add_toggle_with_identical_branches_panics() {
801 let _ = Classes::new().add_toggle(true, "foo", "foo");
802 }
803 }
804
805 mod rendering {
806 use super::*;
807
808 #[test]
809 fn only_writes_active_classes() {
810 let (is_active, set_active) = signal(false);
811 let classes = Classes::builder()
812 .with_reactive("never", ClassCondition::never())
813 .with_reactive("always", ClassCondition::always())
814 .with_reactive("sometimes", ClassCondition::when_signal(is_active))
815 .build();
816
817 let mut rendered = String::new();
818 classes.write_active_classes(&mut rendered);
819 assert_that!(rendered).is_equal_to("always");
820
821 set_active.set(true);
822 let mut rendered = String::new();
823 classes.write_active_classes(&mut rendered);
824 assert_that!(rendered).is_equal_to("always sometimes");
825 }
826
827 #[test]
828 fn write_appends_to_non_empty_buffer_with_separator() {
829 let classes = Classes::builder().with("foo").with("bar").build();
830
831 let mut rendered = String::from("existing");
832 classes.write_active_classes(&mut rendered);
833 assert_that!(rendered).is_equal_to("existing foo bar");
834 }
835
836 #[test]
837 fn no_entries_skips_separator() {
838 let classes = Classes::new();
839 let mut rendered = String::from("existing");
840 classes.write_active_classes(&mut rendered);
841 assert_that!(rendered).is_equal_to("existing");
842 }
843
844 #[test]
845 fn all_inactive_skips_separator() {
846 let classes = Classes::from(("inactive", false));
847 let mut rendered = String::from("existing");
848 classes.write_active_classes(&mut rendered);
849 assert_that!(rendered).is_equal_to("existing");
850 }
851 }
852
853 mod merge {
854 use super::*;
855
856 /// Guards the recommended default in [`MergeStrategy`]'s docs and the
857 /// `MergeStrategy::default()` call in `Classes::merge`'s doctest: if the `#[default]`
858 /// attribute ever moves to a different variant, this test fires before the docs go stale.
859 #[test]
860 fn default_strategy_is_union_conditions() {
861 assert_that!(MergeStrategy::default()).is_equal_to(MergeStrategy::UnionConditions);
862 }
863
864 mod using_the_panic_on_conflict_strategy {
865 use super::*;
866
867 mod without_collisions {
868 use super::*;
869
870 #[test]
871 fn non_overlapping_appends_in_order() {
872 let a = Classes::from("foo");
873 let b = Classes::from("bar");
874 let merged = a.merge(b, MergeStrategy::PanicOnConflict);
875 assert_that!(merged.to_class_string()).is_equal_to("foo bar".to_string());
876 }
877
878 #[test]
879 fn empty_other_is_identity() {
880 let a = Classes::from("foo");
881 let merged = a.merge(Classes::new(), MergeStrategy::PanicOnConflict);
882 assert_that!(merged.to_class_string()).is_equal_to("foo".to_string());
883 }
884
885 #[test]
886 fn empty_self_yields_other() {
887 let merged =
888 Classes::new().merge(Classes::from("foo"), MergeStrategy::PanicOnConflict);
889 assert_that!(merged.to_class_string()).is_equal_to("foo".to_string());
890 }
891
892 #[test]
893 fn preserves_reactivity_from_other() {
894 let (is_active, set_active) = signal(true);
895 let a = Classes::from("base");
896 let b = Classes::from(("active", is_active));
897 let merged = a.merge(b, MergeStrategy::PanicOnConflict);
898
899 assert_that!(merged.is_reactive()).is_true();
900 assert_that!(merged.to_class_string()).is_equal_to("base active");
901 set_active.set(false);
902 assert_that!(merged.to_class_string()).is_equal_to("base");
903 }
904
905 #[test]
906 fn preserves_non_colliding_toggle_from_other() {
907 let (is_active, set_active) = signal(true);
908 let a = Classes::from("base");
909 let b = Classes::new().add_toggle(is_active, "on", "off");
910 let merged = a.merge(b, MergeStrategy::PanicOnConflict);
911
912 assert_that!(merged.to_class_string()).is_equal_to("base on");
913 set_active.set(false);
914 assert_that!(merged.to_class_string()).is_equal_to("base off");
915 }
916 }
917
918 mod with_collisions {
919 use super::*;
920
921 /// Builds the exact panic message `panic_duplicate` emits for `token`.
922 /// Kept inline so the tests assert against a string that is independent of any
923 /// helper in `src/class_list.rs` (defense against a silent message rewording).
924 fn duplicate_message(token: &str) -> String {
925 format!(
926 "class token `{token}` was registered with Classes more than \
927 once. Each class name may appear in at most one entry; \
928 combine conditions instead (e.g. add_reactive(\"{token}\", \
929 move || a.get() || b.get()))."
930 )
931 }
932
933 #[test]
934 fn panics_on_single_collision() {
935 let a = Classes::from("foo");
936 let b = Classes::from("foo");
937 assert_that_panic_by(|| a.merge(b, MergeStrategy::PanicOnConflict))
938 .has_type::<String>()
939 .is_equal_to(duplicate_message("foo"));
940 }
941
942 #[test]
943 fn panics_on_toggle_half_collision() {
944 let a = Classes::new().add_toggle(true, "on", "off");
945 let b = Classes::from("on");
946 assert_that_panic_by(|| a.merge(b, MergeStrategy::PanicOnConflict))
947 .has_type::<String>()
948 .is_equal_to(duplicate_message("on"));
949 }
950 }
951 }
952
953 mod using_the_keep_self_strategy {
954 use super::*;
955
956 #[test]
957 fn reactivity_is_preserved_and_only_depends_on_own_classes() {
958 let (is_active, _) = signal(false);
959 let a = Classes::from("foo");
960 let b = Classes::from(("foo", is_active)).add("bar");
961 let merged = a.merge(b, MergeStrategy::KeepSelf);
962
963 assert_that!(merged.to_class_string()).is_equal_to("foo bar");
964 assert_that!(merged.is_reactive()).is_false();
965 }
966
967 #[test]
968 fn preserves_self_toggle_against_other_collision() {
969 let (is_active, set_active) = signal(true);
970 let a = Classes::new().add_toggle(is_active, "on", "off");
971 let b = Classes::from("on");
972 let merged = a.merge(b, MergeStrategy::KeepSelf);
973
974 assert_that!(merged.is_reactive()).is_true();
975 assert_that!(merged.to_class_string()).is_equal_to("on");
976 set_active.set(false);
977 assert_that!(merged.to_class_string()).is_equal_to("off");
978 }
979 }
980
981 mod using_the_union_conditions_strategy {
982 use super::*;
983
984 #[test]
985 fn or_connects_conditions_rendering_when_either_signal_is_true() {
986 let (a_sig, set_a_sig) = signal(false);
987 let (b_sig, set_b_sig) = signal(false);
988 let a = Classes::from(("foo", a_sig));
989 let b = Classes::from(("foo", b_sig));
990 let merged = a.merge(b, MergeStrategy::UnionConditions);
991
992 assert_that!(merged.is_reactive()).is_true();
993 assert_that!(merged.to_class_string()).is_equal_to("");
994 set_a_sig.set(true);
995 assert_that!(merged.to_class_string()).is_equal_to("foo");
996 set_a_sig.set(false);
997 set_b_sig.set(true);
998 assert_that!(merged.to_class_string()).is_equal_to("foo");
999 set_a_sig.set(true);
1000 assert_that!(merged.to_class_string()).is_equal_to("foo");
1001 }
1002
1003 #[test]
1004 fn always_collapses_to_always() {
1005 let (is_active, set_active) = signal(false);
1006 let a = Classes::from("foo");
1007 let b = Classes::from(("foo", is_active));
1008 let merged = a.merge(b, MergeStrategy::UnionConditions);
1009
1010 assert_that!(merged.is_reactive()).is_false();
1011 assert_that!(merged.to_class_string()).is_equal_to("foo");
1012 set_active.set(true);
1013 assert_that!(merged.to_class_string()).is_equal_to("foo");
1014 }
1015 }
1016
1017 mod in_builder_chain {
1018 use super::*;
1019
1020 #[test]
1021 fn with_merged_merges_classes() {
1022 let merged = Classes::builder()
1023 .with("base")
1024 .with_merged(Classes::from("extra"), MergeStrategy::default())
1025 .with("tail")
1026 .build();
1027 assert_that!(merged.to_class_string()).is_equal_to("base extra tail");
1028 }
1029 }
1030 }
1031}