Skip to main content

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}