use crate::class_list::ClassList;
use crate::class_name::ClassName;
use crate::condition::ClassCondition;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MergeStrategy {
#[default]
UnionConditions,
KeepSelf,
PanicOnConflict,
}
#[derive(Clone, Debug, Default)]
pub struct Classes {
pub(crate) classes: ClassList,
}
impl Classes {
#[must_use]
pub fn builder() -> ClassesBuilder {
ClassesBuilder::default()
}
#[must_use]
pub fn new() -> Self {
Self {
classes: ClassList::empty(),
}
}
#[must_use]
pub fn parse(input: &str) -> Self {
Self::new().add_parsed(input)
}
#[must_use]
#[allow(clippy::should_implement_trait)]
pub fn add(mut self, name: impl Into<ClassName>) -> Self {
self.classes
.add_single(name.into(), ClassCondition::always());
self
}
#[must_use]
pub fn add_reactive(
mut self,
name: impl Into<ClassName>,
when: impl Into<ClassCondition>,
) -> Self {
self.classes.add_single(name.into(), when.into());
self
}
#[must_use]
pub fn add_all<I>(mut self, iter: I) -> Self
where
I: IntoIterator,
I::Item: Into<ClassName>,
{
for name in iter {
self.classes
.add_single(name.into(), ClassCondition::always());
}
self
}
#[must_use]
pub fn add_parsed(self, input: &str) -> Self {
self.add_all(input.split_whitespace().map(str::to_owned))
}
#[must_use]
pub fn add_toggle(
mut self,
when: impl Into<ClassCondition>,
when_true: impl Into<ClassName>,
when_false: impl Into<ClassName>,
) -> Self {
self.classes
.add_toggle(when.into(), when_true.into(), when_false.into());
self
}
#[must_use]
pub fn merge(mut self, other: Classes, strategy: MergeStrategy) -> Self {
self.classes.merge(other.classes, strategy);
self
}
#[must_use]
pub fn to_class_string(&self) -> String {
let mut s = String::new();
self.write_active_classes(&mut s);
s
}
pub(crate) fn write_active_classes(&self, buf: &mut String) {
self.classes.write_active_classes(buf);
}
pub(crate) fn estimated_class_len(&self) -> usize {
self.classes.estimated_class_len()
}
pub(crate) fn is_reactive(&self) -> bool {
self.classes.is_reactive()
}
pub(crate) fn touch_reactive_dependencies(&self) {
self.classes.touch_reactive_dependencies();
}
}
#[derive(Clone, Debug, Default)]
pub struct ClassesBuilder {
classes: ClassList,
}
impl ClassesBuilder {
#[must_use]
pub fn with(mut self, name: impl Into<ClassName>) -> Self {
self.classes
.add_single(name.into(), ClassCondition::always());
self
}
#[must_use]
pub fn with_reactive(
mut self,
name: impl Into<ClassName>,
when: impl Into<ClassCondition>,
) -> Self {
self.classes.add_single(name.into(), when.into());
self
}
#[must_use]
pub fn with_all<I>(mut self, iter: I) -> Self
where
I: IntoIterator,
I::Item: Into<ClassName>,
{
for name in iter {
self.classes
.add_single(name.into(), ClassCondition::always());
}
self
}
#[must_use]
pub fn with_parsed(self, input: &str) -> Self {
self.with_all(input.split_whitespace().map(str::to_owned))
}
#[must_use]
pub fn with_toggle(
mut self,
when: impl Into<ClassCondition>,
when_true: impl Into<ClassName>,
when_false: impl Into<ClassName>,
) -> Self {
self.classes
.add_toggle(when.into(), when_true.into(), when_false.into());
self
}
#[must_use]
pub fn with_merged(mut self, other: Classes, strategy: MergeStrategy) -> Self {
self.classes.merge(other.classes, strategy);
self
}
#[must_use]
pub fn build(self) -> Classes {
Classes {
classes: self.classes,
}
}
}
#[cfg(test)]
mod tests {
use assertr::prelude::*;
use leptos::prelude::{Get, Set, signal};
use crate::condition::ClassCondition;
use crate::{Classes, MergeStrategy};
mod construction {
use super::*;
#[test]
fn single_str_renders_token() {
let classes: Classes = "foo".into();
assert_that!(classes.to_class_string()).is_equal_to("foo");
}
#[test]
fn new_renders_nothing() {
let classes = Classes::new();
assert_that!(classes.to_class_string()).is_equal_to(String::new());
}
#[test]
fn add_chain_appends_tokens_in_order() {
let classes = Classes::new().add("foo").add("bar");
assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
}
#[test]
fn builder_with_chain_accumulates() {
let classes = Classes::builder().with("foo").with("bar").build();
assert_that!(classes.to_class_string()).is_equal_to("foo bar");
}
#[test]
fn extends_across_chained_layers() {
let initial: Classes = "base".into();
let extended = initial.add("extended");
let final_classes = extended.add("final");
assert_that!(final_classes.to_class_string())
.is_equal_to("base extended final".to_string());
}
#[test]
fn add_all_accepts_iterator() {
let classes = Classes::new().add_all(vec!["foo", "bar"]);
assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
}
#[test]
fn with_all_accepts_iterator() {
let classes = Classes::builder().with_all(vec!["foo", "bar"]).build();
assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
}
#[test]
fn from_tuple_with_bool_true_renders_token() {
let classes: Classes = ("foo", true).into();
assert_that!(classes.to_class_string()).is_equal_to("foo".to_string());
}
#[test]
fn from_tuple_with_bool_false_renders_nothing() {
let classes: Classes = ("foo", false).into();
assert_that!(classes.to_class_string()).is_equal_to(String::new());
}
#[test]
fn with_reactive_mix_renders_only_active_entries() {
let classes = Classes::builder()
.with_reactive("always", true)
.with_reactive("never", false)
.with_reactive("also-always", true)
.build();
assert_that!(classes.to_class_string()).is_equal_to("always also-always".to_string());
}
}
mod toggle {
use super::*;
#[test]
fn renders_true_branch_when_active() {
let classes = Classes::new().add_toggle(true, "active", "inactive");
assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
}
#[test]
fn renders_false_branch_when_inactive() {
let classes = Classes::new().add_toggle(false, "active", "inactive");
assert_that!(classes.to_class_string()).is_equal_to("inactive".to_string());
}
#[test]
fn static_bool_true_is_not_reactive() {
let classes = Classes::new().add_toggle(true, "active", "inactive");
assert_that!(classes.is_reactive()).is_false();
}
#[test]
fn static_bool_false_is_not_reactive() {
let classes = Classes::new().add_toggle(false, "active", "inactive");
assert_that!(classes.is_reactive()).is_false();
}
#[test]
fn chained_with_add_keeps_order() {
let classes = Classes::from("base")
.add_toggle(true, "on", "off")
.add("extra");
assert_that!(classes.to_class_string()).is_equal_to("base on extra".to_string());
}
#[test]
fn builder_renders_true_branch() {
let classes = Classes::builder()
.with("base")
.with_toggle(true, "on", "off")
.build();
assert_that!(classes.to_class_string()).is_equal_to("base on".to_string());
}
#[test]
fn builder_renders_false_branch() {
let classes = Classes::builder().with_toggle(false, "on", "off").build();
assert_that!(classes.to_class_string()).is_equal_to("off".to_string());
}
}
mod parsing {
use super::*;
#[test]
fn empty_or_whitespace_only_yields_empty() {
assert_that!(Classes::parse("").to_class_string()).is_equal_to(String::new());
assert_that!(Classes::parse(" \t\n").to_class_string()).is_equal_to(String::new());
}
#[test]
fn multiple_tokens_preserve_order() {
let classes = Classes::parse("btn btn-primary btn-large");
assert_that!(classes.to_class_string())
.is_equal_to("btn btn-primary btn-large".to_string());
}
#[test]
fn collapses_mixed_whitespace_separators() {
let classes = Classes::parse(" foo\tbar\n\nbaz ");
assert_that!(classes.to_class_string()).is_equal_to("foo bar baz".to_string());
}
#[test]
fn splits_on_non_breaking_space() {
let classes = Classes::parse("foo\u{00A0}bar");
assert_that!(classes.to_class_string()).is_equal_to("foo bar".to_string());
}
#[test]
fn splits_on_mixed_ascii_and_unicode_whitespace() {
let classes = Classes::parse("foo bar\u{00A0}baz\u{2028}qux");
assert_that!(classes.to_class_string()).is_equal_to("foo bar baz qux".to_string());
}
#[test]
fn unicode_whitespace_only_yields_empty() {
assert_that!(Classes::parse("\u{00A0}\u{2028}").to_class_string())
.is_equal_to(String::new());
}
#[test]
fn result_is_not_reactive() {
let classes = Classes::parse("foo bar");
assert_that!(classes.is_reactive()).is_false();
}
#[test]
fn add_parsed_appends_to_existing() {
let classes = Classes::from("base").add_parsed("primary large");
assert_that!(classes.to_class_string()).is_equal_to("base primary large".to_string());
}
#[test]
fn add_parsed_chains_with_add_and_toggle() {
let classes = Classes::from("base")
.add_parsed("middle tail")
.add("extra")
.add_toggle(true, "on", "off");
assert_that!(classes.to_class_string())
.is_equal_to("base middle tail extra on".to_string());
}
#[test]
fn add_parsed_empty_input_is_noop() {
let classes = Classes::from("base").add_parsed("");
assert_that!(classes.to_class_string()).is_equal_to("base".to_string());
}
#[test]
fn with_parsed_in_builder() {
let classes = Classes::builder()
.with("base")
.with_parsed("middle tail")
.with_reactive("extra", true)
.build();
assert_that!(classes.to_class_string())
.is_equal_to("base middle tail extra".to_string());
}
#[test]
fn mixing_parsed_with_reactive_entry_makes_list_reactive() {
let (is_active, set_is_active) = signal(true);
let classes = Classes::parse("base middle").add_reactive("trailing", is_active);
assert_that!(classes.is_reactive()).is_true();
assert_that!(classes.to_class_string()).is_equal_to("base middle trailing".to_string());
set_is_active.set(false);
assert_that!(classes.to_class_string()).is_equal_to("base middle".to_string());
}
#[test]
#[should_panic(expected = "was registered with Classes more than once")]
fn parse_panics_on_intra_input_duplicate() {
let _ = Classes::parse("foo foo");
}
#[test]
#[should_panic(expected = "was registered with Classes more than once")]
fn add_parsed_panics_on_collision_with_existing_entry() {
let _ = Classes::from("base").add_parsed("base extra");
}
}
mod reactivity {
use super::*;
#[test]
fn signal_flip_updates_active_entry() {
let (is_active, set_is_active) = signal(true);
let classes = Classes::from(("active", is_active));
assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
set_is_active.set(false);
assert_that!(classes.to_class_string()).is_equal_to(String::new());
}
#[test]
fn signal_flip_swaps_toggle_branch() {
let (is_active, set_is_active) = signal(true);
let classes = Classes::new().add_toggle(is_active, "active", "inactive");
assert_that!(classes.is_reactive()).is_true();
assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
set_is_active.set(false);
assert_that!(classes.to_class_string()).is_equal_to("inactive".to_string());
set_is_active.set(true);
assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
}
#[test]
fn closure_drives_toggle_reactivity() {
let (is_active, set_is_active) = signal(true);
let classes = Classes::new().add_toggle(move || is_active.get(), "active", "inactive");
assert_that!(classes.is_reactive()).is_true();
assert_that!(classes.to_class_string()).is_equal_to("active".to_string());
set_is_active.set(false);
assert_that!(classes.to_class_string()).is_equal_to("inactive".to_string());
}
}
mod validation {
use super::*;
#[test]
#[should_panic(expected = "Class name is empty or whitespace-only")]
fn empty_input_panics() {
let _ = Classes::from("");
}
#[test]
#[should_panic(expected = "Class name is empty or whitespace-only")]
fn whitespace_only_input_panics() {
let _ = Classes::builder().with(" ").build();
}
#[test]
#[should_panic(expected = "Class names must not be whitespace-separated")]
fn whitespace_separated_input_panics() {
let _ = Classes::from("foo bar");
}
#[test]
#[should_panic(expected = "Class names must not be whitespace-separated")]
fn whitespace_around_input_panics() {
let _ = Classes::from(" foo ");
}
#[test]
#[should_panic(expected = "Class names must not be whitespace-separated")]
fn non_breaking_space_inside_token_panics() {
let _ = Classes::from("foo\u{00A0}bar");
}
#[test]
#[should_panic(expected = "Class name is empty or whitespace-only")]
fn unicode_whitespace_only_input_panics() {
let _ = Classes::from("\u{00A0}\u{00A0}");
}
#[test]
#[should_panic(expected = "Class name is empty or whitespace-only")]
fn add_with_empty_panics() {
let _ = Classes::from("base").add("");
}
#[test]
#[should_panic(expected = "Class name is empty or whitespace-only")]
fn toggle_branch_empty_panics() {
let _ = Classes::from("base").add_toggle(false, "active", "");
}
#[test]
#[should_panic(expected = "Class name is empty or whitespace-only")]
fn add_all_panics_on_first_invalid_item() {
let _ = Classes::new().add_all(["foo", ""]);
}
#[test]
#[should_panic(expected = "add_toggle requires two distinct branch names")]
fn add_toggle_with_identical_branches_panics() {
let _ = Classes::new().add_toggle(true, "foo", "foo");
}
}
mod rendering {
use super::*;
#[test]
fn only_writes_active_classes() {
let (is_active, set_active) = signal(false);
let classes = Classes::builder()
.with_reactive("never", ClassCondition::never())
.with_reactive("always", ClassCondition::always())
.with_reactive("sometimes", ClassCondition::when_signal(is_active))
.build();
let mut rendered = String::new();
classes.write_active_classes(&mut rendered);
assert_that!(rendered).is_equal_to("always");
set_active.set(true);
let mut rendered = String::new();
classes.write_active_classes(&mut rendered);
assert_that!(rendered).is_equal_to("always sometimes");
}
#[test]
fn write_appends_to_non_empty_buffer_with_separator() {
let classes = Classes::builder().with("foo").with("bar").build();
let mut rendered = String::from("existing");
classes.write_active_classes(&mut rendered);
assert_that!(rendered).is_equal_to("existing foo bar");
}
#[test]
fn no_entries_skips_separator() {
let classes = Classes::new();
let mut rendered = String::from("existing");
classes.write_active_classes(&mut rendered);
assert_that!(rendered).is_equal_to("existing");
}
#[test]
fn all_inactive_skips_separator() {
let classes = Classes::from(("inactive", false));
let mut rendered = String::from("existing");
classes.write_active_classes(&mut rendered);
assert_that!(rendered).is_equal_to("existing");
}
}
mod merge {
use super::*;
#[test]
fn default_strategy_is_union_conditions() {
assert_that!(MergeStrategy::default()).is_equal_to(MergeStrategy::UnionConditions);
}
mod using_the_panic_on_conflict_strategy {
use super::*;
mod without_collisions {
use super::*;
#[test]
fn non_overlapping_appends_in_order() {
let a = Classes::from("foo");
let b = Classes::from("bar");
let merged = a.merge(b, MergeStrategy::PanicOnConflict);
assert_that!(merged.to_class_string()).is_equal_to("foo bar".to_string());
}
#[test]
fn empty_other_is_identity() {
let a = Classes::from("foo");
let merged = a.merge(Classes::new(), MergeStrategy::PanicOnConflict);
assert_that!(merged.to_class_string()).is_equal_to("foo".to_string());
}
#[test]
fn empty_self_yields_other() {
let merged =
Classes::new().merge(Classes::from("foo"), MergeStrategy::PanicOnConflict);
assert_that!(merged.to_class_string()).is_equal_to("foo".to_string());
}
#[test]
fn preserves_reactivity_from_other() {
let (is_active, set_active) = signal(true);
let a = Classes::from("base");
let b = Classes::from(("active", is_active));
let merged = a.merge(b, MergeStrategy::PanicOnConflict);
assert_that!(merged.is_reactive()).is_true();
assert_that!(merged.to_class_string()).is_equal_to("base active");
set_active.set(false);
assert_that!(merged.to_class_string()).is_equal_to("base");
}
#[test]
fn preserves_non_colliding_toggle_from_other() {
let (is_active, set_active) = signal(true);
let a = Classes::from("base");
let b = Classes::new().add_toggle(is_active, "on", "off");
let merged = a.merge(b, MergeStrategy::PanicOnConflict);
assert_that!(merged.to_class_string()).is_equal_to("base on");
set_active.set(false);
assert_that!(merged.to_class_string()).is_equal_to("base off");
}
}
mod with_collisions {
use super::*;
fn duplicate_message(token: &str) -> String {
format!(
"class token `{token}` was registered with Classes more than \
once. Each class name may appear in at most one entry; \
combine conditions instead (e.g. add_reactive(\"{token}\", \
move || a.get() || b.get()))."
)
}
#[test]
fn panics_on_single_collision() {
let a = Classes::from("foo");
let b = Classes::from("foo");
assert_that_panic_by(|| a.merge(b, MergeStrategy::PanicOnConflict))
.has_type::<String>()
.is_equal_to(duplicate_message("foo"));
}
#[test]
fn panics_on_toggle_half_collision() {
let a = Classes::new().add_toggle(true, "on", "off");
let b = Classes::from("on");
assert_that_panic_by(|| a.merge(b, MergeStrategy::PanicOnConflict))
.has_type::<String>()
.is_equal_to(duplicate_message("on"));
}
}
}
mod using_the_keep_self_strategy {
use super::*;
#[test]
fn reactivity_is_preserved_and_only_depends_on_own_classes() {
let (is_active, _) = signal(false);
let a = Classes::from("foo");
let b = Classes::from(("foo", is_active)).add("bar");
let merged = a.merge(b, MergeStrategy::KeepSelf);
assert_that!(merged.to_class_string()).is_equal_to("foo bar");
assert_that!(merged.is_reactive()).is_false();
}
#[test]
fn preserves_self_toggle_against_other_collision() {
let (is_active, set_active) = signal(true);
let a = Classes::new().add_toggle(is_active, "on", "off");
let b = Classes::from("on");
let merged = a.merge(b, MergeStrategy::KeepSelf);
assert_that!(merged.is_reactive()).is_true();
assert_that!(merged.to_class_string()).is_equal_to("on");
set_active.set(false);
assert_that!(merged.to_class_string()).is_equal_to("off");
}
}
mod using_the_union_conditions_strategy {
use super::*;
#[test]
fn or_connects_conditions_rendering_when_either_signal_is_true() {
let (a_sig, set_a_sig) = signal(false);
let (b_sig, set_b_sig) = signal(false);
let a = Classes::from(("foo", a_sig));
let b = Classes::from(("foo", b_sig));
let merged = a.merge(b, MergeStrategy::UnionConditions);
assert_that!(merged.is_reactive()).is_true();
assert_that!(merged.to_class_string()).is_equal_to("");
set_a_sig.set(true);
assert_that!(merged.to_class_string()).is_equal_to("foo");
set_a_sig.set(false);
set_b_sig.set(true);
assert_that!(merged.to_class_string()).is_equal_to("foo");
set_a_sig.set(true);
assert_that!(merged.to_class_string()).is_equal_to("foo");
}
#[test]
fn always_collapses_to_always() {
let (is_active, set_active) = signal(false);
let a = Classes::from("foo");
let b = Classes::from(("foo", is_active));
let merged = a.merge(b, MergeStrategy::UnionConditions);
assert_that!(merged.is_reactive()).is_false();
assert_that!(merged.to_class_string()).is_equal_to("foo");
set_active.set(true);
assert_that!(merged.to_class_string()).is_equal_to("foo");
}
}
mod in_builder_chain {
use super::*;
#[test]
fn with_merged_merges_classes() {
let merged = Classes::builder()
.with("base")
.with_merged(Classes::from("extra"), MergeStrategy::default())
.with("tail")
.build();
assert_that!(merged.to_class_string()).is_equal_to("base extra tail");
}
}
}
}