use crate::aria::AriaAttributes;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RenderOutput {
pub attrs: Vec<(String, AttrValue)>,
pub classes: Vec<String>,
pub aria: AriaAttributes,
pub children: ChildrenSpec,
pub data_attrs: Vec<(String, String)>,
pub tag: Option<String>,
pub styles: Vec<(String, String)>,
}
impl RenderOutput {
pub fn new() -> Self {
Self::default()
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tag = Some(tag.into());
self
}
pub fn with_attr(mut self, name: impl Into<String>, value: AttrValue) -> Self {
self.attrs.push((name.into(), value));
self
}
pub fn with_class(mut self, class: impl Into<String>) -> Self {
let c = class.into();
for part in c.split_whitespace() {
if crate::security::is_safe_class_name(part) {
self.classes.push(part.to_string());
}
}
self
}
pub fn with_classes(mut self, classes: impl IntoIterator<Item = impl Into<String>>) -> Self {
for class in classes {
let c = class.into();
for part in c.split_whitespace() {
if crate::security::is_safe_class_name(part) {
self.classes.push(part.to_string());
}
}
}
self
}
pub fn with_aria(mut self, aria: AriaAttributes) -> Self {
self.aria = aria;
self
}
pub fn with_children(mut self, children: ChildrenSpec) -> Self {
self.children = children;
self
}
pub fn with_data(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.data_attrs.push((name.into(), value.into()));
self
}
pub fn with_style(mut self, property: impl Into<String>, value: impl Into<String>) -> Self {
let v = value.into();
if crate::security::is_safe_css_value(&v) {
self.styles.push((property.into(), v));
}
self
}
pub fn effective_tag(&self) -> &str {
self.tag.as_deref().unwrap_or("div")
}
pub fn class_string(&self) -> String {
self.classes.join(" ")
}
pub fn style_string(&self) -> String {
self.styles
.iter()
.map(|(prop, val)| format!("{}: {};", prop, val))
.collect::<Vec<_>>()
.join(" ")
}
pub fn merge(mut self, other: RenderOutput) -> Self {
self.attrs.extend(other.attrs);
self.classes.extend(other.classes);
self.data_attrs.extend(other.data_attrs);
self.styles.extend(other.styles);
if other.tag.is_some() {
self.tag = other.tag;
}
if other.children != self.children {
self.children = other.children;
}
macro_rules! merge_aria_field {
($field:ident) => {
if other.aria.$field.is_some() {
self.aria.$field = other.aria.$field;
}
};
}
merge_aria_field!(role);
merge_aria_field!(label);
merge_aria_field!(labelledby);
merge_aria_field!(describedby);
merge_aria_field!(expanded);
merge_aria_field!(selected);
merge_aria_field!(checked);
merge_aria_field!(disabled);
merge_aria_field!(required);
merge_aria_field!(invalid);
merge_aria_field!(live);
merge_aria_field!(atomic);
merge_aria_field!(controls);
merge_aria_field!(owns);
merge_aria_field!(haspopup);
merge_aria_field!(level);
merge_aria_field!(orientation);
merge_aria_field!(readonly);
merge_aria_field!(multiselectable);
merge_aria_field!(valuemin);
merge_aria_field!(valuemax);
merge_aria_field!(valuenow);
merge_aria_field!(valuetext);
merge_aria_field!(hidden);
merge_aria_field!(activedescendant);
merge_aria_field!(busy);
merge_aria_field!(modal);
merge_aria_field!(posinset);
merge_aria_field!(setsize);
merge_aria_field!(colcount);
merge_aria_field!(colindex);
merge_aria_field!(colspan);
merge_aria_field!(rowcount);
merge_aria_field!(rowindex);
merge_aria_field!(rowspan);
merge_aria_field!(sort);
merge_aria_field!(autocomplete);
merge_aria_field!(current);
merge_aria_field!(errormessage);
merge_aria_field!(keyshortcuts);
merge_aria_field!(roledescription);
merge_aria_field!(placeholder);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttrValue {
String(String),
Bool(bool),
Number(f64),
None,
}
impl AttrValue {
pub fn to_html_value(&self) -> Option<String> {
match self {
Self::String(s) => Some(s.clone()),
Self::Bool(true) => Some(String::new()),
Self::Bool(false) => None,
Self::Number(n) => Some(n.to_string()),
Self::None => None,
}
}
}
impl From<String> for AttrValue {
fn from(s: String) -> Self {
Self::String(s)
}
}
impl From<&str> for AttrValue {
fn from(s: &str) -> Self {
Self::String(s.to_string())
}
}
impl From<bool> for AttrValue {
fn from(b: bool) -> Self {
Self::Bool(b)
}
}
impl From<f64> for AttrValue {
fn from(n: f64) -> Self {
Self::Number(n)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub enum ChildrenSpec {
Empty,
Text(String),
Slot(String),
Slots(Vec<String>),
#[default]
Children,
Elements(Vec<RenderOutput>),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aria::AriaRole;
#[test]
fn render_output_builder() {
let output = RenderOutput::new()
.with_tag("button")
.with_class("btn")
.with_class("btn-primary")
.with_attr("type", AttrValue::String("button".to_string()))
.with_data("testid", "save-btn");
assert_eq!(output.effective_tag(), "button");
assert_eq!(output.class_string(), "btn btn-primary");
assert_eq!(output.data_attrs.len(), 1);
assert_eq!(output.attrs.len(), 1);
}
#[test]
fn render_output_default_tag() {
let output = RenderOutput::new();
assert_eq!(output.effective_tag(), "div");
}
#[test]
fn render_output_style_string() {
let output = RenderOutput::new()
.with_style("display", "flex")
.with_style("gap", "8px");
assert_eq!(output.style_string(), "display: flex; gap: 8px;");
}
#[test]
fn render_output_merge() {
let base = RenderOutput::new()
.with_class("base")
.with_aria(AriaAttributes::new().with_role(AriaRole::Button));
let overlay = RenderOutput::new()
.with_class("overlay")
.with_aria(AriaAttributes::new().with_label("Save"));
let merged = base.merge(overlay);
assert_eq!(merged.classes, vec!["base", "overlay"]);
assert_eq!(merged.aria.role, Some(AriaRole::Button));
assert_eq!(merged.aria.label, Some("Save".to_string()));
}
#[test]
fn attr_value_to_html() {
assert_eq!(
AttrValue::String("hello".to_string()).to_html_value(),
Some("hello".to_string())
);
assert_eq!(AttrValue::Bool(true).to_html_value(), Some(String::new()));
assert_eq!(AttrValue::Bool(false).to_html_value(), None);
assert_eq!(
AttrValue::Number(42.0).to_html_value(),
Some("42".to_string())
);
assert_eq!(AttrValue::None.to_html_value(), None);
}
#[test]
fn children_spec_default() {
assert_eq!(ChildrenSpec::default(), ChildrenSpec::Children);
}
#[test]
fn attr_value_from_impls() {
let _: AttrValue = "hello".into();
let _: AttrValue = String::from("hello").into();
let _: AttrValue = true.into();
let _: AttrValue = 3.14.into();
}
}