Skip to main content

stratum_core/
render.rs

1use crate::aria::AriaAttributes;
2use serde::{Deserialize, Serialize};
3
4/// Framework-agnostic render description produced by components.
5///
6/// A `RenderOutput` describes what a component looks like without
7/// committing to any specific framework's rendering model. Framework
8/// adapters translate this into Leptos `view!` or Dioxus `rsx!` output.
9#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
10pub struct RenderOutput {
11    /// HTML attributes to set on the root element.
12    pub attrs: Vec<(String, AttrValue)>,
13
14    /// CSS class names to add to the root element.
15    pub classes: Vec<String>,
16
17    /// ARIA attributes for accessibility.
18    pub aria: AriaAttributes,
19
20    /// Child content specification.
21    pub children: ChildrenSpec,
22
23    /// Data attributes (data-*) for testing and JS interop.
24    pub data_attrs: Vec<(String, String)>,
25
26    /// The HTML tag name to render (default: "div").
27    pub tag: Option<String>,
28
29    /// Inline styles (escape hatch — prefer classes).
30    pub styles: Vec<(String, String)>,
31}
32
33impl RenderOutput {
34    /// Create a new empty RenderOutput.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Set the HTML tag name.
40    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
41        self.tag = Some(tag.into());
42        self
43    }
44
45    /// Add an HTML attribute.
46    pub fn with_attr(mut self, name: impl Into<String>, value: AttrValue) -> Self {
47        self.attrs.push((name.into(), value));
48        self
49    }
50
51    /// Add a CSS class.
52    ///
53    /// Validates the class name to prevent CSS injection. Invalid class
54    /// names are silently dropped. Use [`crate::security::is_safe_class_name`]
55    /// to check before calling if you need to handle the error.
56    pub fn with_class(mut self, class: impl Into<String>) -> Self {
57        let c = class.into();
58        // Split on whitespace to validate each class individually
59        for part in c.split_whitespace() {
60            if crate::security::is_safe_class_name(part) {
61                self.classes.push(part.to_string());
62            }
63        }
64        self
65    }
66
67    /// Add multiple CSS classes.
68    ///
69    /// Each class is validated individually via [`crate::security::is_safe_class_name`].
70    /// Invalid class names are silently dropped.
71    pub fn with_classes(mut self, classes: impl IntoIterator<Item = impl Into<String>>) -> Self {
72        for class in classes {
73            let c = class.into();
74            for part in c.split_whitespace() {
75                if crate::security::is_safe_class_name(part) {
76                    self.classes.push(part.to_string());
77                }
78            }
79        }
80        self
81    }
82
83    /// Set the ARIA attributes.
84    pub fn with_aria(mut self, aria: AriaAttributes) -> Self {
85        self.aria = aria;
86        self
87    }
88
89    /// Set the children specification.
90    pub fn with_children(mut self, children: ChildrenSpec) -> Self {
91        self.children = children;
92        self
93    }
94
95    /// Add a data attribute.
96    pub fn with_data(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
97        self.data_attrs.push((name.into(), value.into()));
98        self
99    }
100
101    /// Add an inline style.
102    ///
103    /// Validates the value to prevent CSS injection. Unsafe values
104    /// (containing `expression()`, `url()`, `javascript:`, etc.) are
105    /// silently dropped.
106    pub fn with_style(mut self, property: impl Into<String>, value: impl Into<String>) -> Self {
107        let v = value.into();
108        if crate::security::is_safe_css_value(&v) {
109            self.styles.push((property.into(), v));
110        }
111        self
112    }
113
114    /// Get the effective tag name (defaults to "div").
115    pub fn effective_tag(&self) -> &str {
116        self.tag.as_deref().unwrap_or("div")
117    }
118
119    /// Get all classes as a single space-separated string.
120    pub fn class_string(&self) -> String {
121        self.classes.join(" ")
122    }
123
124    /// Get all inline styles as a CSS string.
125    pub fn style_string(&self) -> String {
126        self.styles
127            .iter()
128            .map(|(prop, val)| format!("{}: {};", prop, val))
129            .collect::<Vec<_>>()
130            .join(" ")
131    }
132
133    /// Merge another RenderOutput into this one.
134    ///
135    /// Attributes, classes, data attributes, and styles are appended.
136    /// ARIA attributes from `other` overwrite `self` where set.
137    /// Tag and children from `other` take precedence if set.
138    pub fn merge(mut self, other: RenderOutput) -> Self {
139        self.attrs.extend(other.attrs);
140        self.classes.extend(other.classes);
141        self.data_attrs.extend(other.data_attrs);
142        self.styles.extend(other.styles);
143
144        if other.tag.is_some() {
145            self.tag = other.tag;
146        }
147        // Always take other's children if they differ from self's.
148        // This avoids the edge case where ChildrenSpec::Children (the default)
149        // would be silently skipped.
150        if other.children != self.children {
151            self.children = other.children;
152        }
153
154        // Merge ARIA: other's Some values overwrite self's
155        macro_rules! merge_aria_field {
156            ($field:ident) => {
157                if other.aria.$field.is_some() {
158                    self.aria.$field = other.aria.$field;
159                }
160            };
161        }
162        merge_aria_field!(role);
163        merge_aria_field!(label);
164        merge_aria_field!(labelledby);
165        merge_aria_field!(describedby);
166        merge_aria_field!(expanded);
167        merge_aria_field!(selected);
168        merge_aria_field!(checked);
169        merge_aria_field!(disabled);
170        merge_aria_field!(required);
171        merge_aria_field!(invalid);
172        merge_aria_field!(live);
173        merge_aria_field!(atomic);
174        merge_aria_field!(controls);
175        merge_aria_field!(owns);
176        merge_aria_field!(haspopup);
177        merge_aria_field!(level);
178        merge_aria_field!(orientation);
179        merge_aria_field!(readonly);
180        merge_aria_field!(multiselectable);
181        merge_aria_field!(valuemin);
182        merge_aria_field!(valuemax);
183        merge_aria_field!(valuenow);
184        merge_aria_field!(valuetext);
185        merge_aria_field!(hidden);
186        merge_aria_field!(activedescendant);
187        merge_aria_field!(busy);
188        merge_aria_field!(modal);
189        merge_aria_field!(posinset);
190        merge_aria_field!(setsize);
191        merge_aria_field!(colcount);
192        merge_aria_field!(colindex);
193        merge_aria_field!(colspan);
194        merge_aria_field!(rowcount);
195        merge_aria_field!(rowindex);
196        merge_aria_field!(rowspan);
197        merge_aria_field!(sort);
198        merge_aria_field!(autocomplete);
199        merge_aria_field!(current);
200        merge_aria_field!(errormessage);
201        merge_aria_field!(keyshortcuts);
202        merge_aria_field!(roledescription);
203        merge_aria_field!(placeholder);
204
205        self
206    }
207}
208
209/// An HTML attribute value.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub enum AttrValue {
212    /// A string value.
213    String(String),
214    /// A boolean attribute (present or absent).
215    Bool(bool),
216    /// A numeric value.
217    Number(f64),
218    /// Attribute is not set.
219    None,
220}
221
222impl AttrValue {
223    /// Convert to a string representation for HTML rendering.
224    pub fn to_html_value(&self) -> Option<String> {
225        match self {
226            Self::String(s) => Some(s.clone()),
227            Self::Bool(true) => Some(String::new()),
228            Self::Bool(false) => None,
229            Self::Number(n) => Some(n.to_string()),
230            Self::None => None,
231        }
232    }
233}
234
235impl From<String> for AttrValue {
236    fn from(s: String) -> Self {
237        Self::String(s)
238    }
239}
240
241impl From<&str> for AttrValue {
242    fn from(s: &str) -> Self {
243        Self::String(s.to_string())
244    }
245}
246
247impl From<bool> for AttrValue {
248    fn from(b: bool) -> Self {
249        Self::Bool(b)
250    }
251}
252
253impl From<f64> for AttrValue {
254    fn from(n: f64) -> Self {
255        Self::Number(n)
256    }
257}
258
259/// Specification for component children.
260///
261/// Components can render different types of children — slots, text,
262/// or delegate to the consumer.
263#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
264pub enum ChildrenSpec {
265    /// No children.
266    Empty,
267    /// Static text content.
268    Text(String),
269    /// Named slot — the consumer provides content for this slot.
270    Slot(String),
271    /// Multiple named slots.
272    Slots(Vec<String>),
273    /// Consumer-provided children (the default for most components).
274    #[default]
275    Children,
276    /// Multiple child render outputs (compound components).
277    Elements(Vec<RenderOutput>),
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::aria::AriaRole;
284
285    #[test]
286    fn render_output_builder() {
287        let output = RenderOutput::new()
288            .with_tag("button")
289            .with_class("btn")
290            .with_class("btn-primary")
291            .with_attr("type", AttrValue::String("button".to_string()))
292            .with_data("testid", "save-btn");
293
294        assert_eq!(output.effective_tag(), "button");
295        assert_eq!(output.class_string(), "btn btn-primary");
296        assert_eq!(output.data_attrs.len(), 1);
297        assert_eq!(output.attrs.len(), 1);
298    }
299
300    #[test]
301    fn render_output_default_tag() {
302        let output = RenderOutput::new();
303        assert_eq!(output.effective_tag(), "div");
304    }
305
306    #[test]
307    fn render_output_style_string() {
308        let output = RenderOutput::new()
309            .with_style("display", "flex")
310            .with_style("gap", "8px");
311        assert_eq!(output.style_string(), "display: flex; gap: 8px;");
312    }
313
314    #[test]
315    fn render_output_merge() {
316        let base = RenderOutput::new()
317            .with_class("base")
318            .with_aria(AriaAttributes::new().with_role(AriaRole::Button));
319
320        let overlay = RenderOutput::new()
321            .with_class("overlay")
322            .with_aria(AriaAttributes::new().with_label("Save"));
323
324        let merged = base.merge(overlay);
325        assert_eq!(merged.classes, vec!["base", "overlay"]);
326        assert_eq!(merged.aria.role, Some(AriaRole::Button));
327        assert_eq!(merged.aria.label, Some("Save".to_string()));
328    }
329
330    #[test]
331    fn attr_value_to_html() {
332        assert_eq!(
333            AttrValue::String("hello".to_string()).to_html_value(),
334            Some("hello".to_string())
335        );
336        assert_eq!(AttrValue::Bool(true).to_html_value(), Some(String::new()));
337        assert_eq!(AttrValue::Bool(false).to_html_value(), None);
338        assert_eq!(
339            AttrValue::Number(42.0).to_html_value(),
340            Some("42".to_string())
341        );
342        assert_eq!(AttrValue::None.to_html_value(), None);
343    }
344
345    #[test]
346    fn children_spec_default() {
347        assert_eq!(ChildrenSpec::default(), ChildrenSpec::Children);
348    }
349
350    #[test]
351    fn attr_value_from_impls() {
352        let _: AttrValue = "hello".into();
353        let _: AttrValue = String::from("hello").into();
354        let _: AttrValue = true.into();
355        let _: AttrValue = 3.14.into();
356    }
357}