1use crate::aria::AriaAttributes;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
10pub struct RenderOutput {
11 pub attrs: Vec<(String, AttrValue)>,
13
14 pub classes: Vec<String>,
16
17 pub aria: AriaAttributes,
19
20 pub children: ChildrenSpec,
22
23 pub data_attrs: Vec<(String, String)>,
25
26 pub tag: Option<String>,
28
29 pub styles: Vec<(String, String)>,
31}
32
33impl RenderOutput {
34 pub fn new() -> Self {
36 Self::default()
37 }
38
39 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
41 self.tag = Some(tag.into());
42 self
43 }
44
45 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 pub fn with_class(mut self, class: impl Into<String>) -> Self {
57 let c = class.into();
58 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 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 pub fn with_aria(mut self, aria: AriaAttributes) -> Self {
85 self.aria = aria;
86 self
87 }
88
89 pub fn with_children(mut self, children: ChildrenSpec) -> Self {
91 self.children = children;
92 self
93 }
94
95 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 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 pub fn effective_tag(&self) -> &str {
116 self.tag.as_deref().unwrap_or("div")
117 }
118
119 pub fn class_string(&self) -> String {
121 self.classes.join(" ")
122 }
123
124 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 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 if other.children != self.children {
151 self.children = other.children;
152 }
153
154 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub enum AttrValue {
212 String(String),
214 Bool(bool),
216 Number(f64),
218 None,
220}
221
222impl AttrValue {
223 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
264pub enum ChildrenSpec {
265 Empty,
267 Text(String),
269 Slot(String),
271 Slots(Vec<String>),
273 #[default]
275 Children,
276 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}