Skip to main content

eui_core/
conditions.rs

1//! Round 1 (docs/round-1-contract.md §2): STYLE CONDITIONS — the one media
2//! grammar (`catalog/style.json` `conditions.media`, hand-parsed: no regex
3//! engine), the state keys, and `resolveConditions`, the reference flattening
4//! of `src/style.ts`: the base keys, then every MATCHING `@media` block in
5//! SOURCE order (later wins), then `:hover`, `:focus-visible`, `:pressed` in
6//! that order (pressed wins). Sizes are the SURFACE box; `min-*` = size >= N,
7//! `max-*` = size < N (strict); a `$breakpoint.<name>` resolves through the
8//! context's breakpoints (an unknown token never matches); orientation
9//! portrait = height >= width (landscape when the height is unknown).
10//! `fixtures/style-conditions.json` locks it.
11
12use indexmap::IndexMap;
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::generated::catalog as g;
17
18/// The state keys in the order they apply (the last wins).
19pub const STYLE_STATES: &[&str] = g::STYLE_STATES;
20
21/// The size features of the grammar.
22const SIZE_FEATURES: [&str; 4] = ["min-width", "max-width", "min-height", "max-height"];
23
24/// One parsed `@media (feature: value)` key.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct MediaCondition {
27    /// `min-width | max-width | min-height | max-height | orientation | hover | prefers-reduced-motion`
28    pub feature: String,
29    /// The px value (`600px`) or `$breakpoint.<name>` for the size features;
30    /// the keyword for the others.
31    pub value: String,
32}
33
34fn digits(s: &str) -> bool {
35    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
36}
37
38/// `\d+(\.\d+)?`
39fn decimal(s: &str) -> bool {
40    match s.split_once('.') {
41        Some((i, f)) => digits(i) && digits(f),
42        None => digits(s),
43    }
44}
45
46/// `\$breakpoint\.[a-zA-Z0-9]+` → the name.
47pub fn breakpoint_ref(value: &str) -> Option<&str> {
48    let name = value.strip_prefix("$breakpoint.")?;
49    (!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric())).then_some(name)
50}
51
52/// `"@media (min-width: $breakpoint.md)"` → `{feature, value}`; `None` when
53/// the key is not a media condition of the grammar (`STYLE_MEDIA_PATTERN`).
54pub fn parse_media_condition(key: &str) -> Option<MediaCondition> {
55    let inner = key.strip_prefix("@media (")?.strip_suffix(')')?;
56    let (feature, value) = inner.split_once(": ")?;
57    let ok = match feature {
58        f if SIZE_FEATURES.contains(&f) => value.strip_suffix("px").is_some_and(decimal) || breakpoint_ref(value).is_some(),
59        "orientation" => matches!(value, "portrait" | "landscape"),
60        "hover" => matches!(value, "hover" | "none"),
61        "prefers-reduced-motion" => matches!(value, "reduce" | "no-preference"),
62        _ => false,
63    };
64    ok.then(|| MediaCondition { feature: feature.to_string(), value: value.to_string() })
65}
66
67/// A `@media` key of the grammar.
68pub fn is_media_key(key: &str) -> bool {
69    parse_media_condition(key).is_some()
70}
71
72/// A `@media` or state condition key (`isConditionKey`).
73pub fn is_condition_key(key: &str) -> bool {
74    is_media_key(key) || STYLE_STATES.contains(&key)
75}
76
77/// What a client knows when it flattens a style: the SURFACE box, the
78/// platform's pointer and motion preference, the node's interaction states
79/// and the theme's breakpoint values (px).
80#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct ConditionContext {
83    pub width: f32,
84    #[serde(default)]
85    pub height: Option<f32>,
86    /// A hover-capable pointer (mouse, trackpad); false on touch.
87    #[serde(default)]
88    pub hover: bool,
89    /// The platform asks for reduced motion.
90    #[serde(default)]
91    pub reduced_motion: bool,
92    /// The node's active states, the state keys without the colon
93    /// (`hover`, `focus-visible`, `pressed`).
94    #[serde(default)]
95    pub states: Vec<String>,
96    /// `$breakpoint.<name>` → px.
97    #[serde(default)]
98    pub breakpoints: IndexMap<String, f32>,
99}
100
101/// The built-in breakpoints (every built-in theme's, Tailwind's).
102pub fn default_breakpoints() -> IndexMap<String, f32> {
103    [("sm", 640.0), ("md", 768.0), ("lg", 1024.0), ("xl", 1280.0)].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
104}
105
106fn size_value(value: &str, breakpoints: &IndexMap<String, f32>) -> Option<f32> {
107    if let Some(name) = breakpoint_ref(value) {
108        return breakpoints.get(name).copied();
109    }
110    value.strip_suffix("px").unwrap_or(value).parse::<f32>().ok()
111}
112
113/// Does a media condition hold for the context?
114pub fn media_matches(condition: &MediaCondition, ctx: &ConditionContext) -> bool {
115    match condition.feature.as_str() {
116        f if SIZE_FEATURES.contains(&f) => {
117            let Some(n) = size_value(&condition.value, &ctx.breakpoints) else { return false };
118            let size = if f.ends_with("width") { Some(ctx.width) } else { ctx.height };
119            let Some(size) = size else { return false };
120            if f.starts_with("min") {
121                size >= n
122            } else {
123                size < n
124            }
125        }
126        "orientation" => {
127            let portrait = ctx.height.is_some_and(|h| h >= ctx.width);
128            condition.value == if portrait { "portrait" } else { "landscape" }
129        }
130        "hover" => condition.value == if ctx.hover { "hover" } else { "none" },
131        "prefers-reduced-motion" => condition.value == if ctx.reduced_motion { "reduce" } else { "no-preference" },
132        _ => false,
133    }
134}
135
136/// Flatten a style for one pass: the base keys, then every matching `@media`
137/// block in source order, then the matching state blocks in [`STYLE_STATES`]
138/// order. Token values pass through untouched (the theme resolves them).
139pub fn resolve_conditions(style: &Map<String, Value>, ctx: &ConditionContext) -> Map<String, Value> {
140    let mut out = Map::new();
141    for (k, v) in style {
142        if !is_condition_key(k) {
143            out.insert(k.clone(), v.clone());
144        }
145    }
146    for (k, v) in style {
147        let Some(condition) = parse_media_condition(k) else { continue };
148        let Some(block) = v.as_object() else { continue };
149        if media_matches(&condition, ctx) {
150            for (bk, bv) in block {
151                out.insert(bk.clone(), bv.clone());
152            }
153        }
154    }
155    for key in STYLE_STATES {
156        let state = &key[1..];
157        if !ctx.states.iter().any(|s| s == state) {
158            continue;
159        }
160        if let Some(block) = style.get(*key).and_then(Value::as_object) {
161            for (bk, bv) in block {
162                out.insert(bk.clone(), bv.clone());
163            }
164        }
165    }
166    out
167}
168
169/// The breakpoint a surface of this width is in: the LAST `$breakpoint`
170/// name (ascending) whose px is <= width; `None` below the first (= `base`).
171pub fn active_breakpoint(width: f32, breakpoints: &IndexMap<String, f32>) -> Option<String> {
172    let mut out = None;
173    for name in crate::macros::BREAKPOINTS.iter() {
174        if breakpoints.get(name).is_some_and(|px| width >= *px) {
175            out = Some(name.clone());
176        }
177    }
178    out
179}
180
181/// Does any style in the subtree depend on the surface HEIGHT (a height or
182/// orientation media block)? A height change restyles only then.
183pub fn depends_on_height(style: &Map<String, Value>) -> bool {
184    style.keys().filter_map(|k| parse_media_condition(k)).any(|c| c.feature.ends_with("height") || c.feature == "orientation")
185}
186
187/// Does the style carry a state block (`:hover`, …)? Only such nodes (and
188/// those with state recipes) restyle on a state change.
189pub fn has_state_blocks(style: &Map<String, Value>) -> bool {
190    STYLE_STATES.iter().any(|k| style.contains_key(*k))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::json;
197
198    #[test]
199    fn the_grammar() {
200        for key in [
201            "@media (min-width: 600px)",
202            "@media (max-width: 37.5px)",
203            "@media (min-height: $breakpoint.md)",
204            "@media (orientation: portrait)",
205            "@media (hover: none)",
206            "@media (prefers-reduced-motion: reduce)",
207        ] {
208            assert!(is_media_key(key), "{key}");
209        }
210        for key in ["@media (min-width: 600)", "@media (width: 600px)", "@media (orientation: up)", "@media (min-width: $spacing.md)", "@media screen", ":focus"] {
211            assert!(!is_media_key(key), "{key}");
212        }
213        assert!(is_condition_key(":hover") && is_condition_key(":focus-visible") && is_condition_key(":pressed"));
214    }
215
216    #[test]
217    fn max_is_strict_and_unknown_tokens_never_match() {
218        let style = json!({"gap": 4, "@media (max-width: $breakpoint.md)": {"gap": 1}, "@media (min-width: $breakpoint.huge)": {"gap": 99}});
219        let ctx = |w: f32| ConditionContext { width: w, breakpoints: default_breakpoints(), ..Default::default() };
220        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0))["gap"], json!(1));
221        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0))["gap"], json!(4));
222        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(5000.0))["gap"], json!(4));
223        assert_eq!(active_breakpoint(700.0, &default_breakpoints()).as_deref(), Some("sm"));
224        assert_eq!(active_breakpoint(500.0, &default_breakpoints()), None);
225        assert_eq!(active_breakpoint(1280.0, &default_breakpoints()).as_deref(), Some("xl"));
226    }
227}