AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/css/src/variants.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

//! Variant prefixes: `hover:`, `md:`, `data-[state=open]:`, `dark:`, …
//!
//! A class is `variant:variant:…:utility`, split on `:` outside square
//! brackets. [`split`] separates the parts (and the `!` important modifier);
//! [`Context::apply`] folds each variant into the selector/media context that
//! the generator renders:
//!
//! - **pseudo-classes** append to the class selector (`hover:` ⇒ `:hover`,
//!   `first:` ⇒ `:first-child`, `odd:` ⇒ `:nth-child(odd)`, …);
//! - **attribute** variants append an attribute selector (`aria-selected:` ⇒
//!   `[aria-selected="true"]`, `aria-current:` ⇒
//!   `[aria-current]:not([aria-current="false"])`, `data-[k=v]:` ⇒
//!   `[data-k="v"]`, `data-[k]:` ⇒ `[data-k]`, `aria-[k=v]:`);
//! - **pseudo-elements** go last (`placeholder:` ⇒ `::placeholder`);
//! - **group/peer** (`group-hover:` ⇒ `.group:hover .x`, `peer-checked:` ⇒
//!   `.peer:checked ~ .x`) for any pseudo-class variant;
//! - **media**: breakpoints `sm` 640 · `md` 768 · `lg` 1024 · `xl` 1280 ·
//!   `2xl` 1536 (min-width), `dark:` (prefers-color-scheme), `motion-safe:` /
//!   `motion-reduce:` (prefers-reduced-motion) and `print:`.

/// A class split into its variants, bare utility and important flag.
#[derive(Debug, PartialEq, Eq)]
pub struct Parsed<'a> {
    pub variants: Vec<&'a str>,
    pub utility: &'a str,
    pub important: bool,
}

/// Split `class` on top-level `:`. `!` before the utility (`hover:!p-0`) or
/// after it (`p-0!`) marks it important. Empty segments are rejected.
pub fn split(class: &str) -> Option<Parsed<'_>> {
    let mut parts = Vec::new();
    let mut depth = 0i32;
    let mut start = 0;
    for (i, b) in class.bytes().enumerate() {
        match b {
            b'[' => depth += 1,
            b']' => depth -= 1,
            b':' if depth == 0 => {
                parts.push(&class[start..i]);
                start = i + 1;
            }
            _ => {}
        }
    }
    let last = &class[start..];
    let (utility, important) = if let Some(u) = last.strip_prefix('!') {
        (u, true)
    } else if let Some(u) = last.strip_suffix('!') {
        (u, true)
    } else {
        (last, false)
    };
    if utility.is_empty() || parts.iter().any(|p| p.is_empty()) {
        return None;
    }
    Some(Parsed {
        variants: parts,
        utility,
        important,
    })
}

/// `(variant, pseudo-class)`, in Tailwind's precedence order: later entries
/// are emitted later and so win (`disabled:` beats `hover:`).
const PSEUDO_CLASSES: &[(&str, &str)] = &[
    ("first", ":first-child"),
    ("last", ":last-child"),
    ("only", ":only-child"),
    ("odd", ":nth-child(odd)"),
    ("even", ":nth-child(even)"),
    ("visited", ":visited"),
    ("target", ":target"),
    ("checked", ":checked"),
    ("indeterminate", ":indeterminate"),
    ("placeholder-shown", ":placeholder-shown"),
    ("required", ":required"),
    ("invalid", ":invalid"),
    ("read-only", ":read-only"),
    ("empty", ":empty"),
    ("focus-within", ":focus-within"),
    ("hover", ":hover"),
    ("focus", ":focus"),
    ("focus-visible", ":focus-visible"),
    ("active", ":active"),
    ("enabled", ":enabled"),
    ("disabled", ":disabled"),
];

/// Boolean ARIA states: `aria-{state}:` ⇒ `[aria-{state}="true"]`.
const ARIA_STATES: &[&str] = &[
    "busy", "checked", "disabled", "expanded", "hidden", "invalid", "pressed", "readonly",
    "required", "selected",
];

/// Rank bases above every pseudo-class rank.
const RANK_ELEMENT: u8 = 1;
const RANK_PSEUDO_BASE: u8 = 2;
const RANK_GROUP: u8 = 40;
const RANK_PEER: u8 = 41;
const RANK_ARIA: u8 = 50;
const RANK_DATA: u8 = 51;

/// Breakpoints (min-width px), ascending; index + 1 is the sort rank.
pub const BREAKPOINTS: &[(&str, u32)] = &[
    ("sm", 640),
    ("md", 768),
    ("lg", 1024),
    ("xl", 1280),
    ("2xl", 1536),
];

/// Media-feature bits, in the order they appear in a query.
const MEDIA_FEATURES: &[(&str, u8, &str)] = &[
    ("dark", 1, "(prefers-color-scheme:dark)"),
    ("motion-safe", 2, "(prefers-reduced-motion:no-preference)"),
    ("motion-reduce", 4, "(prefers-reduced-motion:reduce)"),
];
const PRINT: u8 = 8;

/// Accumulated selector + media context for one class.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Context {
    /// Pseudo-classes required on the `.group` ancestor.
    pub group: String,
    /// Pseudo-classes required on the `.peer` sibling.
    pub peer: String,
    /// Pseudo-classes/attribute selectors on the element itself.
    pub pseudo: String,
    /// Pseudo-element appended last (`::placeholder`).
    pub element: String,
    /// Breakpoint rank: 0 = none, 1 = `sm` … 5 = `2xl`.
    pub breakpoint: u8,
    /// [`MEDIA_FEATURES`] bits plus print.
    pub media: u8,
    /// Highest state-variant precedence seen (0 = none).
    pub rank: u8,
}

impl Context {
    /// Fold one variant in; `None` for an unknown variant.
    pub fn apply(&mut self, variant: &str) -> Option<()> {
        if let Some(i) = BREAKPOINTS.iter().position(|(k, _)| *k == variant) {
            self.breakpoint = self.breakpoint.max(i as u8 + 1);
            return Some(());
        }
        if let Some((_, bit, _)) = MEDIA_FEATURES.iter().find(|(k, _, _)| *k == variant) {
            self.media |= bit;
            return Some(());
        }
        if variant == "print" {
            self.media |= PRINT;
            return Some(());
        }
        if variant == "placeholder" {
            self.element = "::placeholder".into();
            return self.rank(RANK_ELEMENT);
        }
        if let Some((pseudo, rank)) = pseudo_class(variant) {
            self.pseudo.push_str(pseudo);
            return self.rank(rank);
        }
        if let Some(inner) = variant.strip_prefix("group-") {
            self.group.push_str(pseudo_class(inner)?.0);
            return self.rank(RANK_GROUP);
        }
        if let Some(inner) = variant.strip_prefix("peer-") {
            self.peer.push_str(pseudo_class(inner)?.0);
            return self.rank(RANK_PEER);
        }
        if let Some(attr) = aria(variant) {
            self.pseudo.push_str(&attr);
            return self.rank(RANK_ARIA);
        }
        let attr = data(variant)?;
        self.pseudo.push_str(&attr);
        self.rank(RANK_DATA)
    }

    fn rank(&mut self, rank: u8) -> Option<()> {
        self.rank = self.rank.max(rank);
        Some(())
    }

    /// Full selector for an (already escaped) `.class` selector, with the
    /// utility's own child suffix.
    pub fn selector(&self, class_selector: &str, child: Option<&str>) -> String {
        let mut out = String::new();
        if !self.group.is_empty() {
            out.push_str(&format!(".group{} ", self.group));
        }
        if !self.peer.is_empty() {
            out.push_str(&format!(".peer{} ~ ", self.peer));
        }
        out.push_str(class_selector);
        out.push_str(&self.pseudo);
        out.push_str(child.unwrap_or(""));
        out.push_str(&self.element);
        out
    }

    /// The `@media` query (without `@media `), or `None` when unconditional.
    pub fn media_query(&self) -> Option<String> {
        let mut parts: Vec<String> = Vec::new();
        if self.media & PRINT != 0 {
            parts.push("print".into());
        }
        if self.breakpoint > 0 {
            let (_, px) = BREAKPOINTS[usize::from(self.breakpoint - 1)];
            parts.push(format!("(min-width:{px}px)"));
        }
        for (_, bit, feature) in MEDIA_FEATURES {
            if self.media & bit != 0 {
                parts.push((*feature).to_string());
            }
        }
        (!parts.is_empty()).then(|| parts.join(" and "))
    }
}

/// A pseudo-class variant and its precedence rank.
fn pseudo_class(variant: &str) -> Option<(&'static str, u8)> {
    PSEUDO_CLASSES
        .iter()
        .position(|(k, _)| *k == variant)
        .map(|i| (PSEUDO_CLASSES[i].1, RANK_PSEUDO_BASE + i as u8))
}

/// `aria-selected`, `aria-current`, `aria-[sort=ascending]`.
fn aria(variant: &str) -> Option<String> {
    let rest = variant.strip_prefix("aria-")?;
    if rest == "current" {
        return Some(r#"[aria-current]:not([aria-current="false"])"#.into());
    }
    if ARIA_STATES.contains(&rest) {
        return Some(format!(r#"[aria-{rest}="true"]"#));
    }
    attribute("aria", rest)
}

/// `data-[state=open]` ⇒ `[data-state="open"]`, `data-[active]` ⇒
/// `[data-active]`.
fn data(variant: &str) -> Option<String> {
    attribute("data", variant.strip_prefix("data-")?)
}

/// `[key=value]` / `[key]` after an `aria-`/`data-` prefix.
fn attribute(prefix: &str, bracketed: &str) -> Option<String> {
    let inner = bracketed.strip_prefix('[')?.strip_suffix(']')?;
    let (key, value) = match inner.split_once('=') {
        Some((k, v)) => (k, Some(v)),
        None => (inner, None),
    };
    let key_ok = !key.is_empty()
        && key
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
    if !key_ok {
        return None;
    }
    let Some(value) = value else {
        return Some(format!("[{prefix}-{key}]"));
    };
    let value = value
        .strip_prefix('"')
        .and_then(|v| v.strip_suffix('"'))
        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
        .unwrap_or(value);
    let safe = !value
        .bytes()
        .any(|b| matches!(b, b'"' | b'\\' | b'[' | b']' | b'{' | b'}' | b';' | b'\n'));
    safe.then(|| format!("[{prefix}-{key}=\"{}\"]", value.replace('_', " ")))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ctx(variants: &[&str]) -> Option<Context> {
        let mut c = Context::default();
        for v in variants {
            c.apply(v)?;
        }
        Some(c)
    }

    #[test]
    fn split_respects_brackets_and_important() {
        assert_eq!(
            split("md:hover:!bg-accent"),
            Some(Parsed {
                variants: vec!["md", "hover"],
                utility: "bg-accent",
                important: true
            })
        );
        let p = split("data-[state=open]:[mask-type:alpha]").unwrap();
        assert_eq!(p.variants, vec!["data-[state=open]"]);
        assert_eq!(p.utility, "[mask-type:alpha]");
        assert!(split("p-0!").unwrap().important);
        assert_eq!(split("hover:"), None);
        assert_eq!(split(":flex"), None);
        assert_eq!(split("hover::flex"), None);
        assert_eq!(split("!"), None);
    }

    #[test]
    fn selectors() {
        let c = ctx(&["group-hover", "focus", "placeholder"]).unwrap();
        assert_eq!(
            c.selector(".x", Some(" > *")),
            ".group:hover .x:focus > *::placeholder"
        );
        let c = ctx(&["peer-checked"]).unwrap();
        assert_eq!(c.selector(".x", None), ".peer:checked ~ .x");
        assert_eq!(ctx(&["group-wat"]), None);
    }

    #[test]
    fn attribute_variants() {
        assert_eq!(
            aria("aria-expanded").as_deref(),
            Some(r#"[aria-expanded="true"]"#)
        );
        assert_eq!(
            aria("aria-[sort=ascending]").as_deref(),
            Some(r#"[aria-sort="ascending"]"#)
        );
        assert_eq!(aria("aria-wat"), None);
        assert_eq!(
            data("data-[state='open']").as_deref(),
            Some(r#"[data-state="open"]"#)
        );
        assert_eq!(
            data("data-[side=top_left]").as_deref(),
            Some(r#"[data-side="top left"]"#)
        );
        assert_eq!(data("data-[x=a\"b]"), None);
        assert_eq!(data("data-[a b]"), None);
        assert_eq!(data("data-[]"), None);
    }

    #[test]
    fn media_queries() {
        assert_eq!(ctx(&[]).unwrap().media_query(), None);
        assert_eq!(
            ctx(&["sm", "lg"]).unwrap().media_query().as_deref(),
            Some("(min-width:1024px)")
        );
        assert_eq!(
            ctx(&["motion-reduce", "print", "xl"])
                .unwrap()
                .media_query()
                .as_deref(),
            Some("print and (min-width:1280px) and (prefers-reduced-motion:reduce)")
        );
    }

    #[test]
    fn ranks_follow_tailwind_order() {
        let r = |v: &str| ctx(&[v]).unwrap().rank;
        assert!(r("hover") < r("focus"));
        assert!(r("focus") < r("active"));
        assert!(r("active") < r("disabled"));
        assert!(r("disabled") < r("group-hover"));
        assert!(r("placeholder") < r("first"));
        assert_eq!(r("md"), 0);
    }
}