Skip to main content

stratum_components/typography/
link.rs

1//! Styled Link component.
2
3use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7/// Link variant.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum LinkVariant {
10    #[default]
11    Default,
12    Muted,
13    Destructive,
14}
15
16/// Properties for the styled Link component.
17#[derive(Debug, Clone, PartialEq, Default)]
18pub struct LinkProps {
19    pub variant: LinkVariant,
20    pub size: Size,
21    pub href: Option<String>,
22    pub external: bool,
23    pub disabled: bool,
24    pub class: Option<String>,
25    pub aria_label: Option<String>,
26    pub id: Option<String>,
27}
28
29pub struct Link;
30
31impl Link {
32    const BASE: &'static str = "underline-offset-4 hover:underline transition-colors";
33
34    pub fn classes(props: &LinkProps) -> String {
35        let variant_cls = match props.variant {
36            LinkVariant::Default => "text-primary",
37            LinkVariant::Muted => "text-muted-foreground",
38            LinkVariant::Destructive => "text-destructive",
39        };
40
41        let size_cls = match props.size {
42            Size::Xs => "text-xs",
43            Size::Sm => "text-sm",
44            Size::Md => "text-base",
45            Size::Lg => "text-lg",
46            Size::Xl => "text-xl",
47        };
48
49        let disabled_cls = if props.disabled {
50            "pointer-events-none opacity-50"
51        } else {
52            ""
53        };
54
55        let computed = format!(
56            "{} {} {} {}",
57            Self::BASE,
58            variant_cls,
59            size_cls,
60            disabled_cls
61        );
62        merge_classes(&computed, &props.class)
63    }
64
65    pub fn render(props: &LinkProps) -> RenderOutput {
66        let classes = Self::classes(props);
67        let mut aria = AriaAttributes::new().with_role(AriaRole::Link);
68
69        if let Some(ref label) = props.aria_label {
70            aria = aria.with_label(label.clone());
71        }
72        if props.disabled {
73            aria = aria.with_disabled(true);
74        }
75
76        let mut output = RenderOutput::new()
77            .with_tag("a")
78            .with_class(classes)
79            .with_aria(aria);
80
81        if let Some(ref href) = props.href {
82            output = output.with_attr("href", AttrValue::String(href.clone()));
83        }
84        if props.external {
85            output = output
86                .with_attr("target", AttrValue::String("_blank".to_string()))
87                .with_attr("rel", AttrValue::String("noopener noreferrer".to_string()));
88        }
89        if let Some(ref id) = props.id {
90            output = output.with_attr("id", AttrValue::String(id.clone()));
91        }
92
93        output
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn link_default_classes() {
103        let props = LinkProps::default();
104        let classes = Link::classes(&props);
105        assert!(classes.contains("text-primary"));
106        assert!(classes.contains("hover:underline"));
107    }
108
109    #[test]
110    fn link_render_tag_is_a() {
111        let props = LinkProps::default();
112        let output = Link::render(&props);
113        assert_eq!(output.effective_tag(), "a");
114    }
115
116    #[test]
117    fn link_external_attrs() {
118        let props = LinkProps {
119            href: Some("https://example.com".to_string()),
120            external: true,
121            ..Default::default()
122        };
123        let output = Link::render(&props);
124        assert!(output.attrs.iter().any(|(k, _)| k == "target"));
125        assert!(output.attrs.iter().any(|(k, _)| k == "rel"));
126    }
127
128    #[test]
129    fn link_disabled() {
130        let props = LinkProps {
131            disabled: true,
132            ..Default::default()
133        };
134        let classes = Link::classes(&props);
135        assert!(classes.contains("pointer-events-none"));
136        assert!(classes.contains("opacity-50"));
137    }
138}