stratum_components/forms/
switch.rs1use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole, TriState};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7#[derive(Debug, Clone, PartialEq, Default)]
9pub struct SwitchProps {
10 pub size: Size,
11 pub checked: bool,
12 pub disabled: bool,
13 pub class: Option<String>,
14 pub aria_label: Option<String>,
15 pub id: Option<String>,
16}
17
18pub struct Switch;
19
20impl Switch {
21 const BASE: &'static str = "peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input";
22
23 pub fn classes(props: &SwitchProps) -> String {
24 let size_cls = match props.size {
25 Size::Xs => "h-4 w-7",
26 Size::Sm => "h-4 w-8",
27 Size::Md => "h-5 w-9",
28 Size::Lg => "h-6 w-11",
29 Size::Xl => "h-7 w-14",
30 };
31
32 let computed = format!("{} {}", Self::BASE, size_cls);
33 merge_classes(&computed, &props.class)
34 }
35
36 pub fn render(props: &SwitchProps) -> RenderOutput {
37 let classes = Self::classes(props);
38 let mut aria = AriaAttributes::new()
39 .with_role(AriaRole::Switch)
40 .with_checked(if props.checked {
41 TriState::True
42 } else {
43 TriState::False
44 });
45
46 if let Some(ref label) = props.aria_label {
47 aria = aria.with_label(label.clone());
48 }
49 if props.disabled {
50 aria = aria.with_disabled(true);
51 }
52
53 let mut output = RenderOutput::new()
54 .with_tag("button")
55 .with_class(classes)
56 .with_aria(aria)
57 .with_attr("type", AttrValue::String("button".to_string()))
58 .with_data(
59 "state",
60 if props.checked {
61 "checked"
62 } else {
63 "unchecked"
64 },
65 );
66
67 if props.disabled {
68 output = output.with_attr("disabled", AttrValue::Bool(true));
69 }
70 if let Some(ref id) = props.id {
71 output = output.with_attr("id", AttrValue::String(id.clone()));
72 }
73
74 output
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn default_switch_classes() {
84 let props = SwitchProps::default();
85 let classes = Switch::classes(&props);
86 assert!(classes.contains("rounded-full"));
87 assert!(classes.contains("h-5 w-9"));
88 }
89
90 #[test]
91 fn render_checked_switch() {
92 let props = SwitchProps {
93 checked: true,
94 ..Default::default()
95 };
96 let output = Switch::render(&props);
97 assert_eq!(output.aria.checked, Some(TriState::True));
98 assert!(
99 output
100 .data_attrs
101 .iter()
102 .any(|(k, v)| k == "state" && v == "checked")
103 );
104 }
105
106 #[test]
107 fn render_unchecked_switch() {
108 let props = SwitchProps::default();
109 let output = Switch::render(&props);
110 assert_eq!(output.aria.checked, Some(TriState::False));
111 }
112
113 #[test]
114 fn render_tag_is_button() {
115 let props = SwitchProps::default();
116 let output = Switch::render(&props);
117 assert_eq!(output.effective_tag(), "button");
118 }
119}