tailwind_rs_core/
custom_variant.rs

1//! Custom variant system for tailwind-rs
2//!
3//! Implements Tailwind CSS v4.1.13 @custom-variant features
4//! This replaces the old aria, data, and supports theme keys with a unified system
5
6use crate::error::{Result, TailwindError};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::fmt;
10
11/// Custom variant types supported by Tailwind v4.1.13
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum CustomVariantType {
14    /// ARIA attributes (aria-*)
15    Aria,
16    /// Data attributes (data-*)
17    Data,
18    /// CSS feature queries (@supports)
19    Supports,
20    /// Container queries (@container)
21    Container,
22    /// Media queries (@media)
23    Media,
24    /// User-defined custom variants
25    Custom(String),
26}
27
28impl CustomVariantType {
29    /// Get the prefix for this variant type
30    pub fn prefix(&self) -> &'static str {
31        match self {
32            CustomVariantType::Aria => "aria-",
33            CustomVariantType::Data => "data-",
34            CustomVariantType::Supports => "supports-",
35            CustomVariantType::Container => "container-",
36            CustomVariantType::Media => "media-",
37            CustomVariantType::Custom(_name) => {
38                // Custom variants should not start or end with - or _
39                // For now, return empty string for all custom variants
40                ""
41            }
42        }
43    }
44
45    /// Validate a custom variant name
46    pub fn validate_name(name: &str) -> Result<()> {
47        if name.is_empty() {
48            return Err(TailwindError::validation(
49                "Custom variant name cannot be empty",
50            ));
51        }
52
53        // Custom variants cannot start or end with - or _
54        if name.starts_with('-')
55            || name.starts_with('_')
56            || name.ends_with('-')
57            || name.ends_with('_')
58        {
59            return Err(TailwindError::validation(format!(
60                "Custom variant '{}' cannot start or end with '-' or '_'",
61                name
62            )));
63        }
64
65        // Custom variants cannot start with @-
66        if name.starts_with("@-") {
67            return Err(TailwindError::validation(format!(
68                "Custom variant '{}' cannot start with '@-'",
69                name
70            )));
71        }
72
73        Ok(())
74    }
75}
76
77/// A custom variant definition
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct CustomVariant {
80    /// The variant type
81    pub variant_type: CustomVariantType,
82    /// The variant name (without prefix)
83    pub name: String,
84    /// The variant value (for aria, data, etc.)
85    pub value: Option<String>,
86    /// Whether this variant is enabled
87    pub enabled: bool,
88    /// Custom configuration for this variant
89    pub config: HashMap<String, serde_json::Value>,
90}
91
92impl CustomVariant {
93    /// Create a new custom variant
94    pub fn new(
95        variant_type: CustomVariantType,
96        name: String,
97        value: Option<String>,
98    ) -> Result<Self> {
99        // Validate the name
100        CustomVariantType::validate_name(&name)?;
101
102        Ok(Self {
103            variant_type,
104            name,
105            value,
106            enabled: true,
107            config: HashMap::new(),
108        })
109    }
110
111    /// Create an ARIA variant
112    pub fn aria(name: String, value: Option<String>) -> Result<Self> {
113        Self::new(CustomVariantType::Aria, name, value)
114    }
115
116    /// Create a data variant
117    pub fn data(name: String, value: Option<String>) -> Result<Self> {
118        Self::new(CustomVariantType::Data, name, value)
119    }
120
121    /// Create a supports variant
122    pub fn supports(name: String, value: Option<String>) -> Result<Self> {
123        Self::new(CustomVariantType::Supports, name, value)
124    }
125
126    /// Create a container variant
127    pub fn container(name: String, value: Option<String>) -> Result<Self> {
128        Self::new(CustomVariantType::Container, name, value)
129    }
130
131    /// Create a media variant
132    pub fn media(name: String, value: Option<String>) -> Result<Self> {
133        Self::new(CustomVariantType::Media, name, value)
134    }
135
136    /// Create a custom variant
137    pub fn custom(name: String, value: Option<String>) -> Result<Self> {
138        Self::new(CustomVariantType::Custom(name.clone()), name, value)
139    }
140
141    /// Get the full variant string (e.g., "aria-checked", "data-theme=dark")
142    pub fn to_variant_string(&self) -> String {
143        let prefix = self.variant_type.prefix();
144        let base = format!("{}{}", prefix, self.name);
145
146        if let Some(value) = &self.value {
147            format!("{}={}", base, value)
148        } else {
149            base
150        }
151    }
152
153    /// Get the CSS selector for this variant
154    pub fn to_css_selector(&self) -> String {
155        match &self.variant_type {
156            CustomVariantType::Aria => {
157                if let Some(value) = &self.value {
158                    format!("[aria-{}={}]", self.name, value)
159                } else {
160                    format!("[aria-{}]", self.name)
161                }
162            }
163            CustomVariantType::Data => {
164                if let Some(value) = &self.value {
165                    format!("[data-{}={}]", self.name, value)
166                } else {
167                    format!("[data-{}]", self.name)
168                }
169            }
170            CustomVariantType::Supports => {
171                format!("@supports ({})", self.name)
172            }
173            CustomVariantType::Container => {
174                format!("@container {}", self.name)
175            }
176            CustomVariantType::Media => {
177                format!("@media {}", self.name)
178            }
179            CustomVariantType::Custom(name) => {
180                // Custom variants are handled by the user
181                name.clone()
182            }
183        }
184    }
185
186    /// Enable this variant
187    pub fn enable(&mut self) {
188        self.enabled = true;
189    }
190
191    /// Disable this variant
192    pub fn disable(&mut self) {
193        self.enabled = false;
194    }
195
196    /// Set a configuration value
197    pub fn set_config(&mut self, key: String, value: serde_json::Value) {
198        self.config.insert(key, value);
199    }
200
201    /// Get a configuration value
202    pub fn get_config(&self, key: &str) -> Option<&serde_json::Value> {
203        self.config.get(key)
204    }
205}
206
207/// Manager for custom variants
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct CustomVariantManager {
210    /// Registered custom variants
211    variants: HashMap<String, CustomVariant>,
212    /// Known variant values for suggestions
213    known_values: HashMap<String, HashSet<String>>,
214}
215
216impl CustomVariantManager {
217    /// Create a new custom variant manager
218    pub fn new() -> Self {
219        Self {
220            variants: HashMap::new(),
221            known_values: HashMap::new(),
222        }
223    }
224
225    /// Register a custom variant
226    pub fn register(&mut self, variant: CustomVariant) -> Result<()> {
227        let key = variant.to_variant_string();
228
229        // Check for conflicts
230        if self.variants.contains_key(&key) {
231            return Err(TailwindError::validation(format!(
232                "Custom variant '{}' is already registered",
233                key
234            )));
235        }
236
237        self.variants.insert(key, variant);
238        Ok(())
239    }
240
241    /// Get a custom variant by key
242    pub fn get(&self, key: &str) -> Option<&CustomVariant> {
243        self.variants.get(key)
244    }
245
246    /// Get all registered variants
247    pub fn get_all(&self) -> &HashMap<String, CustomVariant> {
248        &self.variants
249    }
250
251    /// Get variants by type
252    pub fn get_by_type(&self, variant_type: &CustomVariantType) -> Vec<&CustomVariant> {
253        self.variants
254            .values()
255            .filter(|v| &v.variant_type == variant_type)
256            .collect()
257    }
258
259    /// Remove a custom variant
260    pub fn remove(&mut self, key: &str) -> Option<CustomVariant> {
261        self.variants.remove(key)
262    }
263
264    /// Check if a variant is registered
265    pub fn contains(&self, key: &str) -> bool {
266        self.variants.contains_key(key)
267    }
268
269    /// Add known values for a variant (for suggestions)
270    pub fn add_known_values(&mut self, variant_key: String, values: HashSet<String>) {
271        self.known_values.insert(variant_key, values);
272    }
273
274    /// Get known values for a variant
275    pub fn get_known_values(&self, variant_key: &str) -> Option<&HashSet<String>> {
276        self.known_values.get(variant_key)
277    }
278
279    /// Get suggestions for a variant
280    pub fn get_suggestions(&self, partial: &str) -> Vec<String> {
281        let mut suggestions = Vec::new();
282
283        // Add exact matches
284        for key in self.variants.keys() {
285            if key.starts_with(partial) {
286                suggestions.push(key.clone());
287            }
288        }
289
290        // Add known values
291        for (variant_key, values) in &self.known_values {
292            if variant_key.starts_with(partial) {
293                for value in values {
294                    suggestions.push(format!("{}={}", variant_key, value));
295                }
296            }
297        }
298
299        suggestions.sort();
300        suggestions.dedup();
301        suggestions
302    }
303
304    /// Validate a variant string
305    pub fn validate_variant(&self, variant: &str) -> Result<()> {
306        // Check if it's a registered variant
307        if self.variants.contains_key(variant) {
308            return Ok(());
309        }
310
311        // Check if it matches a known pattern
312        if variant.starts_with("aria-")
313            || variant.starts_with("data-")
314            || variant.starts_with("supports-")
315            || variant.starts_with("container-")
316            || variant.starts_with("media-")
317        {
318            return Ok(());
319        }
320
321        // Check for invalid patterns
322        if variant.starts_with("@-") {
323            return Err(TailwindError::validation(format!(
324                "Variant '{}' cannot start with '@-'",
325                variant
326            )));
327        }
328
329        if variant.starts_with('-')
330            || variant.starts_with('_')
331            || variant.ends_with('-')
332            || variant.ends_with('_')
333        {
334            return Err(TailwindError::validation(format!(
335                "Variant '{}' cannot start or end with '-' or '_'",
336                variant
337            )));
338        }
339
340        Ok(())
341    }
342
343    /// Create default variants (migrated from old theme keys)
344    pub fn with_defaults() -> Self {
345        let mut manager = Self::new();
346
347        // Add common ARIA variants
348        let aria_variants = vec![
349            ("checked", None),
350            ("disabled", None),
351            ("expanded", None),
352            ("hidden", None),
353            ("pressed", None),
354            ("required", None),
355            ("selected", None),
356        ];
357
358        for (name, value) in aria_variants {
359            if let Ok(variant) = CustomVariant::aria(name.to_string(), value) {
360                let _ = manager.register(variant);
361            }
362        }
363
364        // Add common data variants
365        let data_variants = vec![
366            ("theme", Some("dark".to_string())),
367            ("theme", Some("light".to_string())),
368            ("state", Some("loading".to_string())),
369            ("state", Some("error".to_string())),
370        ];
371
372        for (name, value) in data_variants {
373            if let Ok(variant) = CustomVariant::data(name.to_string(), value) {
374                let _ = manager.register(variant);
375            }
376        }
377
378        // Add common supports variants
379        let supports_variants = vec![("backdrop-filter", None), ("grid", None), ("flexbox", None)];
380
381        for (name, value) in supports_variants {
382            if let Ok(variant) = CustomVariant::supports(name.to_string(), value) {
383                let _ = manager.register(variant);
384            }
385        }
386
387        manager
388    }
389}
390
391impl Default for CustomVariantManager {
392    fn default() -> Self {
393        Self::with_defaults()
394    }
395}
396
397impl fmt::Display for CustomVariant {
398    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399        write!(f, "{}", self.to_variant_string())
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_custom_variant_creation() {
409        let variant = CustomVariant::aria("checked".to_string(), None).unwrap();
410        assert_eq!(variant.to_variant_string(), "aria-checked");
411        assert_eq!(variant.to_css_selector(), "[aria-checked]");
412    }
413
414    #[test]
415    fn test_custom_variant_with_value() {
416        let variant = CustomVariant::data("theme".to_string(), Some("dark".to_string())).unwrap();
417        assert_eq!(variant.to_variant_string(), "data-theme=dark");
418        assert_eq!(variant.to_css_selector(), "[data-theme=dark]");
419    }
420
421    #[test]
422    fn test_custom_variant_validation() {
423        // Valid variants
424        assert!(CustomVariantType::validate_name("valid-name").is_ok());
425        assert!(CustomVariantType::validate_name("valid_name").is_ok());
426        assert!(CustomVariantType::validate_name("validname").is_ok());
427
428        // Invalid variants
429        assert!(CustomVariantType::validate_name("-invalid").is_err());
430        assert!(CustomVariantType::validate_name("invalid-").is_err());
431        assert!(CustomVariantType::validate_name("_invalid").is_err());
432        assert!(CustomVariantType::validate_name("invalid_").is_err());
433    }
434
435    #[test]
436    fn test_custom_variant_manager() {
437        let mut manager = CustomVariantManager::new();
438
439        let variant = CustomVariant::aria("checked".to_string(), None).unwrap();
440        manager.register(variant).unwrap();
441
442        assert!(manager.contains("aria-checked"));
443        assert!(manager.get("aria-checked").is_some());
444    }
445
446    #[test]
447    fn test_custom_variant_suggestions() {
448        let manager = CustomVariantManager::with_defaults();
449
450        let suggestions = manager.get_suggestions("aria-");
451        assert!(!suggestions.is_empty());
452        assert!(suggestions.contains(&"aria-checked".to_string()));
453    }
454
455    #[test]
456    fn test_custom_variant_validation_in_manager() {
457        let manager = CustomVariantManager::with_defaults();
458
459        // Valid variants
460        assert!(manager.validate_variant("aria-checked").is_ok());
461        assert!(manager.validate_variant("data-theme=dark").is_ok());
462
463        // Invalid variants
464        assert!(manager.validate_variant("@-invalid").is_err());
465        assert!(manager.validate_variant("-invalid").is_err());
466        assert!(manager.validate_variant("invalid-").is_err());
467    }
468}