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//! `form-factor` (VAPP-218) = the HOST's form factor, the context's
11//! `form_factor` when set, else `phone` below the breakpoints' `md` (768 by
12//! default) and `wide` from it. `fixtures/style-conditions.json` locks it.
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17
18use crate::generated::catalog as g;
19
20/// The state keys in the order they apply (the last wins).
21pub const STYLE_STATES: &[&str] = g::STYLE_STATES;
22
23/// The size features of the grammar.
24const SIZE_FEATURES: [&str; 4] = ["min-width", "max-width", "min-height", "max-height"];
25
26/// One parsed `@media (feature: value)` key.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct MediaCondition {
29    /// `min-width | max-width | min-height | max-height | orientation | hover | prefers-reduced-motion | form-factor`
30    pub feature: String,
31    /// The px value (`600px`) or `$breakpoint.<name>` for the size features;
32    /// the keyword for the others.
33    pub value: String,
34}
35
36fn digits(s: &str) -> bool {
37    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
38}
39
40/// `\d+(\.\d+)?`
41fn decimal(s: &str) -> bool {
42    match s.split_once('.') {
43        Some((i, f)) => digits(i) && digits(f),
44        None => digits(s),
45    }
46}
47
48/// `\$breakpoint\.[a-zA-Z0-9]+` → the name.
49pub fn breakpoint_ref(value: &str) -> Option<&str> {
50    let name = value.strip_prefix("$breakpoint.")?;
51    (!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric())).then_some(name)
52}
53
54/// `"@media (min-width: $breakpoint.md)"` → `{feature, value}`; `None` when
55/// the key is not a media condition of the grammar (`STYLE_MEDIA_PATTERN`).
56pub fn parse_media_condition(key: &str) -> Option<MediaCondition> {
57    let inner = key.strip_prefix("@media (")?.strip_suffix(')')?;
58    let (feature, value) = inner.split_once(": ")?;
59    let ok = match feature {
60        f if SIZE_FEATURES.contains(&f) => value.strip_suffix("px").is_some_and(decimal) || breakpoint_ref(value).is_some(),
61        "orientation" => matches!(value, "portrait" | "landscape"),
62        "hover" => matches!(value, "hover" | "none"),
63        "prefers-reduced-motion" => matches!(value, "reduce" | "no-preference"),
64        "form-factor" => FormFactor::parse(value).is_some(),
65        _ => false,
66    };
67    ok.then(|| MediaCondition { feature: feature.to_string(), value: value.to_string() })
68}
69
70/// A `@media` key of the grammar.
71pub fn is_media_key(key: &str) -> bool {
72    parse_media_condition(key).is_some()
73}
74
75/// A `@media` or state condition key (`isConditionKey`).
76pub fn is_condition_key(key: &str) -> bool {
77    is_media_key(key) || STYLE_STATES.contains(&key)
78}
79
80/// VAPP-218: what `@media (form-factor: …)` matches: the HOST's form factor
81/// (an editor preview's phone frame, a phone).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum FormFactor {
85    Phone,
86    Wide,
87}
88
89impl FormFactor {
90    pub fn parse(s: &str) -> Option<FormFactor> {
91        match s {
92            "phone" => Some(FormFactor::Phone),
93            "wide" => Some(FormFactor::Wide),
94            _ => None,
95        }
96    }
97
98    pub fn as_str(self) -> &'static str {
99        match self {
100            FormFactor::Phone => "phone",
101            FormFactor::Wide => "wide",
102        }
103    }
104}
105
106/// The `md` breakpoint [`form_factor_of`] falls back to (the built-in themes').
107pub const DEFAULT_FORM_FACTOR_BREAKPOINT: f32 = 768.0;
108
109/// The context's form factor: the host's when set, else `phone` when the
110/// SURFACE width < the breakpoints' `md` (768 by default), else `wide`.
111pub fn form_factor_of(ctx: &ConditionContext) -> FormFactor {
112    if let Some(f) = ctx.form_factor {
113        return f;
114    }
115    let md = ctx.breakpoints.get("md").copied().unwrap_or(DEFAULT_FORM_FACTOR_BREAKPOINT);
116    if ctx.width < md {
117        FormFactor::Phone
118    } else {
119        FormFactor::Wide
120    }
121}
122
123/// What a client knows when it flattens a style: the SURFACE box, the
124/// platform's pointer and motion preference, the node's interaction states
125/// and the theme's breakpoint values (px).
126#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct ConditionContext {
129    pub width: f32,
130    #[serde(default)]
131    pub height: Option<f32>,
132    /// A hover-capable pointer (mouse, trackpad); false on touch.
133    #[serde(default)]
134    pub hover: bool,
135    /// The platform asks for reduced motion.
136    #[serde(default)]
137    pub reduced_motion: bool,
138    /// The node's active states, the state keys without the colon
139    /// (`hover`, `focus-visible`, `pressed`).
140    #[serde(default)]
141    pub states: Vec<String>,
142    /// `$breakpoint.<name>` → px.
143    #[serde(default)]
144    pub breakpoints: IndexMap<String, f32>,
145    /// VAPP-218: the host's form factor; `None` = derived from the width
146    /// ([`form_factor_of`]).
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub form_factor: Option<FormFactor>,
149}
150
151/// The built-in breakpoints (every built-in theme's, Tailwind's).
152pub fn default_breakpoints() -> IndexMap<String, f32> {
153    [("sm", 640.0), ("md", 768.0), ("lg", 1024.0), ("xl", 1280.0)].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
154}
155
156fn size_value(value: &str, breakpoints: &IndexMap<String, f32>) -> Option<f32> {
157    if let Some(name) = breakpoint_ref(value) {
158        return breakpoints.get(name).copied();
159    }
160    value.strip_suffix("px").unwrap_or(value).parse::<f32>().ok()
161}
162
163/// Does a media condition hold for the context?
164pub fn media_matches(condition: &MediaCondition, ctx: &ConditionContext) -> bool {
165    match condition.feature.as_str() {
166        f if SIZE_FEATURES.contains(&f) => {
167            let Some(n) = size_value(&condition.value, &ctx.breakpoints) else { return false };
168            let size = if f.ends_with("width") { Some(ctx.width) } else { ctx.height };
169            let Some(size) = size else { return false };
170            if f.starts_with("min") {
171                size >= n
172            } else {
173                size < n
174            }
175        }
176        "orientation" => {
177            let portrait = ctx.height.is_some_and(|h| h >= ctx.width);
178            condition.value == if portrait { "portrait" } else { "landscape" }
179        }
180        "hover" => condition.value == if ctx.hover { "hover" } else { "none" },
181        "prefers-reduced-motion" => condition.value == if ctx.reduced_motion { "reduce" } else { "no-preference" },
182        "form-factor" => condition.value == form_factor_of(ctx).as_str(),
183        _ => false,
184    }
185}
186
187/// Flatten a style for one pass: the base keys, then every matching `@media`
188/// block in source order, then the matching state blocks in [`STYLE_STATES`]
189/// order. Token values pass through untouched (the theme resolves them).
190pub fn resolve_conditions(style: &Map<String, Value>, ctx: &ConditionContext) -> Map<String, Value> {
191    let mut out = Map::new();
192    for (k, v) in style {
193        if !is_condition_key(k) {
194            out.insert(k.clone(), v.clone());
195        }
196    }
197    for (k, v) in style {
198        let Some(condition) = parse_media_condition(k) else { continue };
199        let Some(block) = v.as_object() else { continue };
200        if media_matches(&condition, ctx) {
201            for (bk, bv) in block {
202                out.insert(bk.clone(), bv.clone());
203            }
204        }
205    }
206    for key in STYLE_STATES {
207        let state = &key[1..];
208        if !ctx.states.iter().any(|s| s == state) {
209            continue;
210        }
211        if let Some(block) = style.get(*key).and_then(Value::as_object) {
212            for (bk, bv) in block {
213                out.insert(bk.clone(), bv.clone());
214            }
215        }
216    }
217    out
218}
219
220/// The breakpoint a surface of this width is in: the LAST `$breakpoint`
221/// name (ascending) whose px is <= width; `None` below the first (= `base`).
222pub fn active_breakpoint(width: f32, breakpoints: &IndexMap<String, f32>) -> Option<String> {
223    let mut out = None;
224    for name in crate::macros::BREAKPOINTS.iter() {
225        if breakpoints.get(name).is_some_and(|px| width >= *px) {
226            out = Some(name.clone());
227        }
228    }
229    out
230}
231
232/// Does any style in the subtree depend on the surface HEIGHT (a height or
233/// orientation media block)? A height change restyles only then.
234pub fn depends_on_height(style: &Map<String, Value>) -> bool {
235    style.keys().filter_map(|k| parse_media_condition(k)).any(|c| c.feature.ends_with("height") || c.feature == "orientation")
236}
237
238/// Does the style carry a state block (`:hover`, …)? Only such nodes (and
239/// those with state recipes) restyle on a state change.
240pub fn has_state_blocks(style: &Map<String, Value>) -> bool {
241    STYLE_STATES.iter().any(|k| style.contains_key(*k))
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use serde_json::json;
248
249    #[test]
250    fn the_grammar() {
251        for key in [
252            "@media (min-width: 600px)",
253            "@media (max-width: 37.5px)",
254            "@media (min-height: $breakpoint.md)",
255            "@media (orientation: portrait)",
256            "@media (hover: none)",
257            "@media (prefers-reduced-motion: reduce)",
258            "@media (form-factor: phone)",
259            "@media (form-factor: wide)",
260        ] {
261            assert!(is_media_key(key), "{key}");
262        }
263        for key in ["@media (form-factor: tablet)", "@media (min-width: 600)", "@media (width: 600px)", "@media (orientation: up)", "@media (min-width: $spacing.md)", "@media screen", ":focus"] {
264            assert!(!is_media_key(key), "{key}");
265        }
266        assert!(is_condition_key(":hover") && is_condition_key(":focus-visible") && is_condition_key(":pressed"));
267    }
268
269    #[test]
270    fn max_is_strict_and_unknown_tokens_never_match() {
271        let style = json!({"gap": 4, "@media (max-width: $breakpoint.md)": {"gap": 1}, "@media (min-width: $breakpoint.huge)": {"gap": 99}});
272        let ctx = |w: f32| ConditionContext { width: w, breakpoints: default_breakpoints(), ..Default::default() };
273        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0))["gap"], json!(1));
274        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0))["gap"], json!(4));
275        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(5000.0))["gap"], json!(4));
276        assert_eq!(active_breakpoint(700.0, &default_breakpoints()).as_deref(), Some("sm"));
277        assert_eq!(active_breakpoint(500.0, &default_breakpoints()), None);
278        assert_eq!(active_breakpoint(1280.0, &default_breakpoints()).as_deref(), Some("xl"));
279    }
280
281    #[test]
282    fn form_factor_from_the_host_else_the_width() {
283        let style = json!({"gap": 4, "@media (form-factor: phone)": {"gap": 8}, "@media (form-factor: wide)": {"gap": 16}});
284        let ctx = |w: f32, f: Option<FormFactor>| ConditionContext { width: w, breakpoints: default_breakpoints(), form_factor: f, ..Default::default() };
285        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0, None))["gap"], json!(8));
286        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0, None))["gap"], json!(16));
287        assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(1200.0, Some(FormFactor::Phone)))["gap"], json!(8));
288        // No breakpoints at all: the 768 default.
289        assert_eq!(form_factor_of(&ConditionContext { width: 500.0, ..Default::default() }), FormFactor::Phone);
290    }
291}