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);
}
}