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