stratum_components/typography/
link.rs1use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum LinkVariant {
10 #[default]
11 Default,
12 Muted,
13 Destructive,
14}
15
16#[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}