yew-duskmoon 0.9.4

DuskMoon, a Yew component libaray.
Documentation
use yew::prelude::*;
use yew::virtual_dom::AttrValue;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TableDensity {
    #[default]
    Default,
    Compact,
    Comfortable,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TableBorders {
    #[default]
    Default,
    Bordered,
    Borderless,
}

#[derive(Properties, Clone, PartialEq)]
pub struct TableProps {
    #[prop_or_default]
    pub class: Classes,
    #[prop_or_default]
    pub children: Children,
    #[prop_or_default]
    pub variant: Option<String>,
    #[prop_or_default]
    pub hoverable: bool,
    #[prop_or_default]
    pub striped: bool,
    #[prop_or_default]
    pub density: TableDensity,
    #[prop_or_default]
    pub borders: TableBorders,
    #[prop_or_default]
    pub sticky_header: bool,
    #[prop_or_default]
    pub selectable: bool,
    #[prop_or_default]
    pub surface: bool,
    #[prop_or_default]
    pub responsive: bool,
    #[prop_or_default]
    pub wrapper_class: Classes,
    #[prop_or_default]
    pub aria_label: Option<AttrValue>,
    #[prop_or_default]
    pub aria_describedby: Option<AttrValue>,
}

#[function_component(Table)]
pub fn table(props: &TableProps) -> Html {
    table_view(props)
}

fn table_view(props: &TableProps) -> Html {
    let table = html! {
        <table
            class={table_classes(props)}
            aria-label={props.aria_label.clone()}
            aria-describedby={props.aria_describedby.clone()}
        >
            { for props.children.iter() }
        </table>
    };

    if props.responsive {
        html! {
            <div class={classes!("table-responsive", props.wrapper_class.clone())}>
                { table }
            </div>
        }
    } else {
        table
    }
}

fn table_classes(props: &TableProps) -> Classes {
    let mut classes = classes!("table");

    if let Some(variant) = &props.variant {
        classes.push(format!("table-{variant}"));
    }
    if props.hoverable {
        classes.push("table-hover");
    }
    if props.striped {
        classes.push("table-striped");
    }
    match props.density {
        TableDensity::Default => {}
        TableDensity::Compact => classes.push("table-compact"),
        TableDensity::Comfortable => classes.push("table-comfortable"),
    }
    match props.borders {
        TableBorders::Default => {}
        TableBorders::Bordered => classes.push("table-bordered"),
        TableBorders::Borderless => classes.push("table-borderless"),
    }
    if props.sticky_header {
        classes.push("table-sticky");
    }
    if props.selectable {
        classes.push("table-selectable");
    }
    if props.surface {
        classes.push("table-surface");
    }

    classes.push(props.class.clone());
    classes
}

#[cfg(test)]
mod tests {
    use super::*;
    use yew::virtual_dom::{VNode, VTag};

    fn default_props() -> TableProps {
        TableProps {
            class: Classes::new(),
            children: Children::default(),
            variant: None,
            hoverable: false,
            striped: false,
            density: TableDensity::Default,
            borders: TableBorders::Default,
            sticky_header: false,
            selectable: false,
            surface: false,
            responsive: false,
            wrapper_class: Classes::new(),
            aria_label: None,
            aria_describedby: None,
        }
    }

    fn tag(node: &VNode) -> &VTag {
        match node {
            VNode::VTag(tag) => tag,
            other => panic!("expected an element, got {other:?}"),
        }
    }

    fn attribute<'a>(tag: &'a VTag, name: &str) -> Option<&'a str> {
        tag.attributes
            .iter()
            .find_map(|(key, value)| (key == name).then_some(value))
    }

    #[test]
    fn maps_supported_modifiers_to_core_classes() {
        let props = TableProps {
            hoverable: true,
            striped: true,
            density: TableDensity::Compact,
            borders: TableBorders::Bordered,
            sticky_header: true,
            selectable: true,
            surface: true,
            ..default_props()
        };

        assert_eq!(
            table_classes(&props).to_string(),
            "table table-hover table-striped table-compact table-bordered table-sticky table-selectable table-surface"
        );

        let props = TableProps {
            density: TableDensity::Comfortable,
            ..default_props()
        };

        assert_eq!(table_classes(&props).to_string(), "table table-comfortable");
    }

    #[test]
    fn border_treatment_is_mutually_exclusive() {
        let default = TableProps {
            borders: TableBorders::Default,
            ..default_props()
        };
        let bordered = TableProps {
            borders: TableBorders::Bordered,
            ..default_props()
        };
        let borderless = TableProps {
            borders: TableBorders::Borderless,
            ..default_props()
        };

        assert_eq!(table_classes(&default).to_string(), "table");
        assert_eq!(table_classes(&bordered).to_string(), "table table-bordered");
        assert_eq!(
            table_classes(&borderless).to_string(),
            "table table-borderless"
        );
    }

    #[test]
    fn preserves_default_variant_and_custom_class_behavior() {
        assert_eq!(table_classes(&default_props()).to_string(), "table");

        let props = TableProps {
            class: classes!("custom-table"),
            variant: Some("legacy".to_owned()),
            ..default_props()
        };

        assert_eq!(
            table_classes(&props).to_string(),
            "table table-legacy custom-table"
        );
    }

    #[test]
    fn renders_a_semantic_table_root() {
        let view = table_view(&default_props());

        assert_eq!(tag(&view).tag(), "table");
    }

    #[test]
    fn responsive_table_uses_wrapper_and_preserves_table_attributes() {
        let props = TableProps {
            class: classes!("custom-table"),
            responsive: true,
            wrapper_class: classes!("custom-wrapper"),
            aria_label: Some("Accounts".into()),
            aria_describedby: Some("accounts-help".into()),
            ..default_props()
        };

        let view = table_view(&props);
        let wrapper = tag(&view);
        assert_eq!(wrapper.tag(), "div");
        assert_eq!(
            attribute(wrapper, "class"),
            Some("table-responsive custom-wrapper")
        );

        let table = tag(wrapper
            .children()
            .expect("wrapper should contain the table"));
        assert_eq!(table.tag(), "table");
        assert_eq!(attribute(table, "class"), Some("table custom-table"));
        assert_eq!(attribute(table, "aria-label"), Some("Accounts"));
        assert_eq!(attribute(table, "aria-describedby"), Some("accounts-help"));
    }
}