tailwind_rs_core/
classes.rs

1//! Class management system for tailwind-rs
2
3use crate::responsive::Breakpoint;
4use std::collections::{HashMap, HashSet};
5
6/// A set of CSS classes with metadata
7#[derive(Debug, Clone, PartialEq)]
8pub struct ClassSet {
9    /// The actual CSS classes
10    pub classes: HashSet<String>,
11    /// Responsive classes organized by breakpoint
12    pub responsive: HashMap<Breakpoint, HashSet<String>>,
13    /// Conditional classes
14    pub conditional: HashMap<String, HashSet<String>>,
15    /// Custom CSS properties
16    pub custom: HashMap<String, String>,
17}
18
19impl ClassSet {
20    /// Create a new empty class set
21    pub fn new() -> Self {
22        Self {
23            classes: HashSet::new(),
24            responsive: HashMap::new(),
25            conditional: HashMap::new(),
26            custom: HashMap::new(),
27        }
28    }
29
30    /// Add a base class
31    pub fn add_class(&mut self, class: impl Into<String>) {
32        self.classes.insert(class.into());
33    }
34
35    /// Add multiple base classes
36    pub fn add_classes(&mut self, classes: impl IntoIterator<Item = String>) {
37        for class in classes {
38            self.classes.insert(class);
39        }
40    }
41
42    /// Add a responsive class
43    pub fn add_responsive_class(&mut self, breakpoint: Breakpoint, class: impl Into<String>) {
44        self.responsive
45            .entry(breakpoint)
46            .or_default()
47            .insert(class.into());
48    }
49
50    /// Add a conditional class
51    pub fn add_conditional_class(
52        &mut self,
53        condition: impl Into<String>,
54        class: impl Into<String>,
55    ) {
56        self.conditional
57            .entry(condition.into())
58            .or_default()
59            .insert(class.into());
60    }
61
62    /// Add a custom CSS property
63    pub fn add_custom(&mut self, property: impl Into<String>, value: impl Into<String>) {
64        self.custom.insert(property.into(), value.into());
65    }
66
67    /// Remove a base class
68    pub fn remove_class(&mut self, class: &str) {
69        self.classes.remove(class);
70    }
71
72    /// Check if a base class exists
73    pub fn has_class(&self, class: &str) -> bool {
74        self.classes.contains(class)
75    }
76
77    /// Get all base classes as a vector
78    pub fn get_classes(&self) -> Vec<String> {
79        self.classes.iter().cloned().collect()
80    }
81
82    /// Get responsive classes for a specific breakpoint
83    pub fn get_responsive_classes(&self, breakpoint: Breakpoint) -> Vec<String> {
84        self.responsive
85            .get(&breakpoint)
86            .map(|classes| classes.iter().cloned().collect())
87            .unwrap_or_default()
88    }
89
90    /// Get all responsive classes
91    pub fn get_all_responsive_classes(&self) -> HashMap<Breakpoint, Vec<String>> {
92        self.responsive
93            .iter()
94            .map(|(breakpoint, classes)| (*breakpoint, classes.iter().cloned().collect()))
95            .collect()
96    }
97
98    /// Get conditional classes for a specific condition
99    pub fn get_conditional_classes(&self, condition: &str) -> Vec<String> {
100        self.conditional
101            .get(condition)
102            .map(|classes| classes.iter().cloned().collect())
103            .unwrap_or_default()
104    }
105
106    /// Get all conditional classes
107    pub fn get_all_conditional_classes(&self) -> HashMap<String, Vec<String>> {
108        self.conditional
109            .iter()
110            .map(|(condition, classes)| (condition.clone(), classes.iter().cloned().collect()))
111            .collect()
112    }
113
114    /// Get custom CSS properties
115    pub fn get_custom_properties(&self) -> HashMap<String, String> {
116        self.custom.clone()
117    }
118
119    /// Convert to CSS class string
120    pub fn to_css_classes(&self) -> String {
121        let mut result = Vec::new();
122
123        // Add base classes
124        let mut base_classes: Vec<String> = self.classes.iter().cloned().collect();
125        base_classes.sort();
126        result.extend(base_classes);
127
128        // Add responsive classes
129        let mut responsive_classes: Vec<(Breakpoint, String)> = self
130            .responsive
131            .iter()
132            .flat_map(|(breakpoint, classes)| {
133                classes
134                    .iter()
135                    .map(|class| (*breakpoint, format!("{}{}", breakpoint.prefix(), class)))
136            })
137            .collect();
138        responsive_classes.sort_by(|a, b| a.0.min_width().cmp(&b.0.min_width()));
139        result.extend(responsive_classes.into_iter().map(|(_, class)| class));
140
141        // Add custom variant classes (Tailwind v4.1.13 @custom-variant support)
142        let mut custom_variant_classes: Vec<String> = self
143            .conditional
144            .iter()
145            .flat_map(|(variant, classes)| {
146                let variant = variant.clone();
147                classes
148                    .iter()
149                    .map(move |class| format!("{}:{}", variant, class))
150            })
151            .collect();
152        custom_variant_classes.sort();
153        result.extend(custom_variant_classes);
154
155        result.join(" ")
156    }
157
158    /// Convert to CSS custom properties string
159    pub fn to_css_custom_properties(&self) -> String {
160        if self.custom.is_empty() {
161            return String::new();
162        }
163
164        let properties: Vec<String> = self
165            .custom
166            .iter()
167            .map(|(property, value)| format!("--{}: {}", property, value))
168            .collect();
169
170        format!("style=\"{}\"", properties.join("; "))
171    }
172
173    /// Merge another class set into this one
174    pub fn merge(&mut self, other: ClassSet) {
175        self.classes.extend(other.classes);
176
177        for (breakpoint, classes) in other.responsive {
178            self.responsive
179                .entry(breakpoint)
180                .or_default()
181                .extend(classes);
182        }
183
184        for (condition, classes) in other.conditional {
185            self.conditional
186                .entry(condition)
187                .or_default()
188                .extend(classes);
189        }
190
191        self.custom.extend(other.custom);
192    }
193
194    /// Check if the class set is empty
195    pub fn is_empty(&self) -> bool {
196        self.classes.is_empty()
197            && self.responsive.is_empty()
198            && self.conditional.is_empty()
199            && self.custom.is_empty()
200    }
201
202    /// Get the total number of classes
203    pub fn len(&self) -> usize {
204        self.classes.len()
205            + self
206                .responsive
207                .values()
208                .map(|classes| classes.len())
209                .sum::<usize>()
210            + self
211                .conditional
212                .values()
213                .map(|classes| classes.len())
214                .sum::<usize>()
215    }
216}
217
218impl Default for ClassSet {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224/// Builder for creating class sets
225#[derive(Debug, Clone)]
226pub struct ClassBuilder {
227    class_set: ClassSet,
228}
229
230impl ClassBuilder {
231    /// Create a new class builder
232    pub fn new() -> Self {
233        Self {
234            class_set: ClassSet::new(),
235        }
236    }
237
238    /// Add a base class
239    pub fn class(mut self, class: impl Into<String>) -> Self {
240        self.class_set.add_class(class);
241        self
242    }
243
244    /// Add multiple base classes
245    pub fn classes(mut self, classes: impl IntoIterator<Item = String>) -> Self {
246        self.class_set.add_classes(classes);
247        self
248    }
249
250    /// Add a responsive class
251    pub fn responsive(mut self, breakpoint: Breakpoint, class: impl Into<String>) -> Self {
252        self.class_set.add_responsive_class(breakpoint, class);
253        self
254    }
255
256    /// Add a conditional class
257    pub fn conditional(mut self, condition: impl Into<String>, class: impl Into<String>) -> Self {
258        self.class_set.add_conditional_class(condition, class);
259        self
260    }
261
262    /// Add a custom CSS property
263    pub fn custom(mut self, property: impl Into<String>, value: impl Into<String>) -> Self {
264        self.class_set.add_custom(property, value);
265        self
266    }
267
268    /// Add a custom variant class (Tailwind v4.1.13 @custom-variant support)
269    pub fn custom_variant(mut self, variant: impl Into<String>, class: impl Into<String>) -> Self {
270        let variant = variant.into();
271        let class = class.into();
272        
273        // Add the variant as a conditional class
274        self.class_set.add_conditional_class(variant, class);
275        self
276    }
277
278    /// Add an ARIA variant class
279    pub fn aria(self, aria_attr: impl Into<String>, class: impl Into<String>) -> Self {
280        let variant = format!("aria-{}", aria_attr.into());
281        self.custom_variant(variant, class)
282    }
283
284    /// Add a data variant class
285    pub fn data(self, data_attr: impl Into<String>, value: Option<String>, class: impl Into<String>) -> Self {
286        let variant = if let Some(val) = value {
287            format!("data-{}={}", data_attr.into(), val)
288        } else {
289            format!("data-{}", data_attr.into())
290        };
291        self.custom_variant(variant, class)
292    }
293
294    /// Add a supports variant class
295    pub fn supports(self, feature: impl Into<String>, class: impl Into<String>) -> Self {
296        let variant = format!("supports-{}", feature.into());
297        self.custom_variant(variant, class)
298    }
299
300    /// Build the class set
301    pub fn build(self) -> ClassSet {
302        self.class_set
303    }
304
305    /// Build the class set and convert to CSS string
306    pub fn build_string(self) -> String {
307        self.class_set.to_css_classes()
308    }
309}
310
311impl Default for ClassBuilder {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317/// Utility functions for class management
318#[allow(clippy::module_inception)]
319pub mod classes {
320    use super::*;
321
322    /// Create a new class set with base classes
323    pub fn new(classes: impl IntoIterator<Item = String>) -> ClassSet {
324        let mut class_set = ClassSet::new();
325        class_set.add_classes(classes);
326        class_set
327    }
328
329    /// Create a responsive class set
330    pub fn responsive(
331        base: impl IntoIterator<Item = String>,
332        responsive: impl IntoIterator<Item = (Breakpoint, String)>,
333    ) -> ClassSet {
334        let mut class_set = ClassSet::new();
335        class_set.add_classes(base);
336
337        for (breakpoint, class) in responsive {
338            class_set.add_responsive_class(breakpoint, class);
339        }
340
341        class_set
342    }
343
344    /// Create a conditional class set
345    pub fn conditional(
346        base: impl IntoIterator<Item = String>,
347        conditional: impl IntoIterator<Item = (String, String)>,
348    ) -> ClassSet {
349        let mut class_set = ClassSet::new();
350        class_set.add_classes(base);
351
352        for (condition, class) in conditional {
353            class_set.add_conditional_class(condition, class);
354        }
355
356        class_set
357    }
358
359    /// Merge multiple class sets
360    pub fn merge(class_sets: impl IntoIterator<Item = ClassSet>) -> ClassSet {
361        let mut result = ClassSet::new();
362
363        for class_set in class_sets {
364            result.merge(class_set);
365        }
366
367        result
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_class_set_creation() {
377        let class_set = ClassSet::new();
378        assert!(class_set.is_empty());
379        assert_eq!(class_set.len(), 0);
380    }
381
382    #[test]
383    fn test_class_set_add_remove() {
384        let mut class_set = ClassSet::new();
385
386        class_set.add_class("bg-blue-500");
387        assert!(class_set.has_class("bg-blue-500"));
388        assert_eq!(class_set.len(), 1);
389
390        class_set.add_class("text-white");
391        assert!(class_set.has_class("text-white"));
392        assert_eq!(class_set.len(), 2);
393
394        class_set.remove_class("bg-blue-500");
395        assert!(!class_set.has_class("bg-blue-500"));
396        assert!(class_set.has_class("text-white"));
397        assert_eq!(class_set.len(), 1);
398    }
399
400    #[test]
401    fn test_class_set_responsive() {
402        let mut class_set = ClassSet::new();
403
404        class_set.add_responsive_class(Breakpoint::Sm, "text-sm");
405        class_set.add_responsive_class(Breakpoint::Md, "text-md");
406
407        let sm_classes = class_set.get_responsive_classes(Breakpoint::Sm);
408        assert_eq!(sm_classes, vec!["text-sm"]);
409
410        let md_classes = class_set.get_responsive_classes(Breakpoint::Md);
411        assert_eq!(md_classes, vec!["text-md"]);
412
413        let lg_classes = class_set.get_responsive_classes(Breakpoint::Lg);
414        assert!(lg_classes.is_empty());
415    }
416
417    #[test]
418    fn test_class_set_conditional() {
419        let mut class_set = ClassSet::new();
420
421        class_set.add_conditional_class("hover", "hover:bg-blue-600");
422        class_set.add_conditional_class("focus", "focus:ring-2");
423
424        let hover_classes = class_set.get_conditional_classes("hover");
425        assert_eq!(hover_classes, vec!["hover:bg-blue-600"]);
426
427        let focus_classes = class_set.get_conditional_classes("focus");
428        assert_eq!(focus_classes, vec!["focus:ring-2"]);
429    }
430
431    #[test]
432    fn test_class_set_custom() {
433        let mut class_set = ClassSet::new();
434
435        class_set.add_custom("primary-color", "#3b82f6");
436        class_set.add_custom("spacing", "1rem");
437
438        let custom_properties = class_set.get_custom_properties();
439        assert_eq!(
440            custom_properties.get("primary-color"),
441            Some(&"#3b82f6".to_string())
442        );
443        assert_eq!(custom_properties.get("spacing"), Some(&"1rem".to_string()));
444    }
445
446    #[test]
447    fn test_class_set_to_css() {
448        let mut class_set = ClassSet::new();
449        class_set.add_class("bg-blue-500");
450        class_set.add_class("text-white");
451        class_set.add_responsive_class(Breakpoint::Sm, "text-sm");
452        class_set.add_responsive_class(Breakpoint::Md, "text-md");
453
454        let css = class_set.to_css_classes();
455        assert!(css.contains("bg-blue-500"));
456        assert!(css.contains("text-white"));
457        // Note: responsive classes are sorted by breakpoint
458        assert!(css.contains("sm:text-sm"));
459        assert!(css.contains("md:text-md"));
460    }
461
462    #[test]
463    fn test_class_set_merge() {
464        let mut class_set1 = ClassSet::new();
465        class_set1.add_class("bg-blue-500");
466        class_set1.add_responsive_class(Breakpoint::Sm, "text-sm");
467
468        let mut class_set2 = ClassSet::new();
469        class_set2.add_class("text-white");
470        class_set2.add_responsive_class(Breakpoint::Md, "text-md");
471
472        class_set1.merge(class_set2);
473
474        assert!(class_set1.has_class("bg-blue-500"));
475        assert!(class_set1.has_class("text-white"));
476        assert_eq!(
477            class_set1.get_responsive_classes(Breakpoint::Sm),
478            vec!["text-sm"]
479        );
480        assert_eq!(
481            class_set1.get_responsive_classes(Breakpoint::Md),
482            vec!["text-md"]
483        );
484    }
485
486    #[test]
487    fn test_class_builder() {
488        let class_set = ClassBuilder::new()
489            .class("bg-blue-500")
490            .class("text-white")
491            .responsive(Breakpoint::Sm, "text-sm")
492            .conditional("hover", "hover:bg-blue-600")
493            .custom("primary-color", "#3b82f6")
494            .build();
495
496        assert!(class_set.has_class("bg-blue-500"));
497        assert!(class_set.has_class("text-white"));
498        assert_eq!(
499            class_set.get_responsive_classes(Breakpoint::Sm),
500            vec!["text-sm"]
501        );
502        assert_eq!(
503            class_set.get_conditional_classes("hover"),
504            vec!["hover:bg-blue-600"]
505        );
506        assert_eq!(
507            class_set.get_custom_properties().get("primary-color"),
508            Some(&"#3b82f6".to_string())
509        );
510    }
511
512    #[test]
513    fn test_classes_utility_functions() {
514        let class_set = classes::new(vec!["bg-blue-500".to_string(), "text-white".to_string()]);
515        assert!(class_set.has_class("bg-blue-500"));
516        assert!(class_set.has_class("text-white"));
517
518        let responsive_class_set = classes::responsive(
519            vec!["bg-blue-500".to_string()],
520            vec![(Breakpoint::Sm, "text-sm".to_string())],
521        );
522        assert!(responsive_class_set.has_class("bg-blue-500"));
523        assert_eq!(
524            responsive_class_set.get_responsive_classes(Breakpoint::Sm),
525            vec!["text-sm"]
526        );
527
528        let conditional_class_set = classes::conditional(
529            vec!["bg-blue-500".to_string()],
530            vec![("hover".to_string(), "hover:bg-blue-600".to_string())],
531        );
532        assert!(conditional_class_set.has_class("bg-blue-500"));
533        assert_eq!(
534            conditional_class_set.get_conditional_classes("hover"),
535            vec!["hover:bg-blue-600"]
536        );
537    }
538}