Skip to main content

stratum_components/typography/
heading.rs

1//! Styled Heading component (h1-h6) with size mapping.
2
3use crate::common::merge_classes;
4use stratum_core::aria::AriaAttributes;
5use stratum_core::render::{AttrValue, RenderOutput};
6
7/// Heading level (h1 through h6).
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum HeadingLevel {
10    H1,
11    #[default]
12    H2,
13    H3,
14    H4,
15    H5,
16    H6,
17}
18
19impl HeadingLevel {
20    pub fn tag(&self) -> &'static str {
21        match self {
22            HeadingLevel::H1 => "h1",
23            HeadingLevel::H2 => "h2",
24            HeadingLevel::H3 => "h3",
25            HeadingLevel::H4 => "h4",
26            HeadingLevel::H5 => "h5",
27            HeadingLevel::H6 => "h6",
28        }
29    }
30
31    pub fn aria_level(&self) -> u8 {
32        match self {
33            HeadingLevel::H1 => 1,
34            HeadingLevel::H2 => 2,
35            HeadingLevel::H3 => 3,
36            HeadingLevel::H4 => 4,
37            HeadingLevel::H5 => 5,
38            HeadingLevel::H6 => 6,
39        }
40    }
41}
42
43/// Properties for the Heading component.
44#[derive(Debug, Clone, PartialEq, Default)]
45pub struct HeadingProps {
46    pub level: HeadingLevel,
47    pub class: Option<String>,
48    pub id: Option<String>,
49}
50
51pub struct Heading;
52
53impl Heading {
54    pub fn classes(props: &HeadingProps) -> String {
55        let size_cls = match props.level {
56            HeadingLevel::H1 => "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
57            HeadingLevel::H2 => "scroll-m-20 text-3xl font-semibold tracking-tight",
58            HeadingLevel::H3 => "scroll-m-20 text-2xl font-semibold tracking-tight",
59            HeadingLevel::H4 => "scroll-m-20 text-xl font-semibold tracking-tight",
60            HeadingLevel::H5 => "scroll-m-20 text-lg font-semibold tracking-tight",
61            HeadingLevel::H6 => "scroll-m-20 text-base font-semibold tracking-tight",
62        };
63        merge_classes(size_cls, &props.class)
64    }
65
66    pub fn render(props: &HeadingProps) -> RenderOutput {
67        let classes = Self::classes(props);
68        let mut aria = AriaAttributes::new();
69        aria.level = Some(props.level.aria_level());
70
71        let mut output = RenderOutput::new()
72            .with_tag(props.level.tag())
73            .with_class(classes)
74            .with_aria(aria);
75
76        if let Some(ref id) = props.id {
77            output = output.with_attr("id", AttrValue::String(id.clone()));
78        }
79
80        output
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn heading_h1_classes() {
90        let props = HeadingProps {
91            level: HeadingLevel::H1,
92            ..Default::default()
93        };
94        let classes = Heading::classes(&props);
95        assert!(classes.contains("text-4xl"));
96        assert!(classes.contains("font-extrabold"));
97    }
98
99    #[test]
100    fn heading_h3_tag() {
101        let props = HeadingProps {
102            level: HeadingLevel::H3,
103            ..Default::default()
104        };
105        let output = Heading::render(&props);
106        assert_eq!(output.effective_tag(), "h3");
107        assert_eq!(output.aria.level, Some(3));
108    }
109
110    #[test]
111    fn heading_default_is_h2() {
112        let props = HeadingProps::default();
113        let output = Heading::render(&props);
114        assert_eq!(output.effective_tag(), "h2");
115        assert_eq!(output.aria.level, Some(2));
116    }
117
118    #[test]
119    fn heading_all_levels() {
120        let levels = [
121            HeadingLevel::H1,
122            HeadingLevel::H2,
123            HeadingLevel::H3,
124            HeadingLevel::H4,
125            HeadingLevel::H5,
126            HeadingLevel::H6,
127        ];
128        for level in levels {
129            let props = HeadingProps {
130                level,
131                ..Default::default()
132            };
133            let output = Heading::render(&props);
134            assert_eq!(output.aria.level, Some(level.aria_level()));
135        }
136    }
137}