1use leptos::{ev::MouseEvent, prelude::*};
2use leptos_style::Style;
3
4const SWITCH_CLASS: &str = "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent 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";
5const SWITCH_THUMB_CLASS: &str = "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0";
6
7#[derive(Clone, Copy, PartialEq, Debug)]
8pub enum SwitchVariant {
9 Default,
10 Success,
11 Warning,
12 Destructive,
13 Info,
14}
15
16impl Default for SwitchVariant {
17 fn default() -> Self {
18 SwitchVariant::Default
19 }
20}
21
22impl From<String> for SwitchVariant {
23 fn from(s: String) -> Self {
24 match s.as_str() {
25 "success" => SwitchVariant::Success,
26 "warning" => SwitchVariant::Warning,
27 "destructive" => SwitchVariant::Destructive,
28 "info" => SwitchVariant::Info,
29 _ => SwitchVariant::Default,
30 }
31 }
32}
33
34impl SwitchVariant {
35 fn checked_class(&self) -> &'static str {
36 match self {
37 SwitchVariant::Default => "data-[state=checked]:bg-primary",
38 SwitchVariant::Success => "data-[state=checked]:bg-green-500",
39 SwitchVariant::Warning => "data-[state=checked]:bg-yellow-500",
40 SwitchVariant::Destructive => "data-[state=checked]:bg-red-500",
41 SwitchVariant::Info => "data-[state=checked]:bg-blue-500",
42 }
43 }
44}
45
46#[derive(Clone, Copy, PartialEq, Debug)]
47pub enum SwitchSize {
48 Sm,
49 Md,
50 Lg,
51}
52
53impl Default for SwitchSize {
54 fn default() -> Self {
55 SwitchSize::Md
56 }
57}
58
59impl From<String> for SwitchSize {
60 fn from(s: String) -> Self {
61 match s.as_str() {
62 "sm" => SwitchSize::Sm,
63 "lg" => SwitchSize::Lg,
64 _ => SwitchSize::Md,
65 }
66 }
67}
68
69impl SwitchSize {
70 fn switch_class(&self) -> &'static str {
71 match self {
72 SwitchSize::Sm => "h-4 w-7",
73 SwitchSize::Md => "h-6 w-11",
74 SwitchSize::Lg => "h-8 w-14",
75 }
76 }
77
78 fn thumb_class(&self) -> &'static str {
79 match self {
80 SwitchSize::Sm => "h-3 w-3 data-[state=checked]:translate-x-3",
81 SwitchSize::Md => "h-5 w-5 data-[state=checked]:translate-x-5",
82 SwitchSize::Lg => "h-6 w-6 data-[state=checked]:translate-x-6",
83 }
84 }
85}
86
87#[component]
88pub fn Switch(
89 #[prop(into, optional)] checked: Signal<bool>,
90 #[prop(into, optional)] on_change: Option<Callback<bool>>,
91 #[prop(into, optional)] variant: MaybeProp<SwitchVariant>,
92 #[prop(into, optional)] size: MaybeProp<SwitchSize>,
93 #[prop(into, optional)] disabled: Signal<bool>,
94 #[prop(into, optional)] animated: Signal<bool>,
95 #[prop(into, optional)] class: MaybeProp<String>,
96 #[prop(into, optional)] id: MaybeProp<String>,
97 #[prop(into, optional)] style: Signal<Style>,
98) -> impl IntoView {
99 let switch_variant = variant.get().unwrap_or_default();
100 let switch_size = size.get().unwrap_or_default();
101
102 let handle_change = {
103 let on_change = on_change.clone();
104 move |_event: MouseEvent| {
105 if let Some(callback) = &on_change {
106 let new_value = !checked.get();
107 callback.run(new_value);
108 }
109 }
110 };
111
112 let switch_class = switch_size.switch_class();
113 let thumb_class = switch_size.thumb_class();
114 let variant_class = switch_variant.checked_class();
115 let animation_class = if animated.get() { "transition-all duration-200" } else { "transition-colors" };
116
117 let computed_class = Signal::derive(move || {
118 format!("{} {} {} {} {}", SWITCH_CLASS, switch_class, variant_class, animation_class, class.get().unwrap_or_default())
119 });
120
121 let computed_thumb_class = Signal::derive(move || {
122 format!("{} {} {}", SWITCH_THUMB_CLASS, thumb_class, animation_class)
123 });
124
125 let state_attr = Signal::derive(move || {
126 if checked.get() { "checked" } else { "unchecked" }
127 });
128
129 view! {
130 <button
131 r#type="button"
132 role="switch"
133 aria-checked=move || checked.get()
134 data-state=move || state_attr.get()
135 disabled=move || disabled.get()
136 class=move || computed_class.get()
137 id=move || id.get().unwrap_or_default()
138 style=move || style.get().to_string()
139 on:click=handle_change
140 >
141 <span class=move || computed_thumb_class.get() data-state=move || state_attr.get() />
142 </button>
143 }
144}
145
146#[derive(Clone, Copy)]
148pub struct SwitchContextValue {
149 pub checked: RwSignal<bool>,
150 pub disabled: RwSignal<bool>,
151 pub variant: RwSignal<SwitchVariant>,
152 pub size: RwSignal<SwitchSize>,
153 pub animated: RwSignal<bool>,
154}
155
156#[component]
157pub fn SwitchRoot(
158 #[prop(into, optional)] checked: Signal<bool>,
159 #[prop(into, optional)] disabled: Signal<bool>,
160 #[prop(into, optional)] variant: MaybeProp<SwitchVariant>,
161 #[prop(into, optional)] size: MaybeProp<SwitchSize>,
162 #[prop(into, optional)] animated: Signal<bool>,
163 #[prop(optional)] children: Option<Children>,
164) -> impl IntoView {
165 let checked_signal = RwSignal::new(checked.get());
166 let disabled_signal = RwSignal::new(disabled.get());
167 let variant_signal = RwSignal::new(variant.get().unwrap_or_default());
168 let size_signal = RwSignal::new(size.get().unwrap_or_default());
169 let animated_signal = RwSignal::new(animated.get());
170
171 Effect::new(move |_| {
173 checked_signal.set(checked.get());
174 });
175 Effect::new(move |_| {
176 disabled_signal.set(disabled.get());
177 });
178 Effect::new(move |_| {
179 variant_signal.set(variant.get().unwrap_or_default());
180 });
181 Effect::new(move |_| {
182 size_signal.set(size.get().unwrap_or_default());
183 });
184 Effect::new(move |_| {
185 animated_signal.set(animated.get());
186 });
187
188 let context_value = SwitchContextValue {
189 checked: checked_signal,
190 disabled: disabled_signal,
191 variant: variant_signal,
192 size: size_signal,
193 animated: animated_signal,
194 };
195
196 provide_context(context_value);
197
198 view! {
199 <div class="flex items-center space-x-2">
200 {children.map(|c| c())}
201 </div>
202 }
203}
204
205#[component]
207pub fn SwitchThumb(
208 #[prop(into, optional)] class: MaybeProp<String>,
209 #[prop(into, optional)] id: MaybeProp<String>,
210 #[prop(into, optional)] style: Signal<Style>,
211) -> impl IntoView {
212 let ctx = expect_context::<SwitchContextValue>();
213
214 let thumb_class = ctx.size.get().thumb_class();
215 let animation_class = if ctx.animated.get() { "transition-all duration-200" } else { "transition-transform" };
216 let state_attr = Signal::derive(move || {
217 if ctx.checked.get() { "checked" } else { "unchecked" }
218 });
219
220 let computed_class = Signal::derive(move || {
221 format!("{} {} {} {}", SWITCH_THUMB_CLASS, thumb_class, animation_class, class.get().unwrap_or_default())
222 });
223
224 view! {
225 <span
226 class=move || computed_class.get()
227 id=move || id.get().unwrap_or_default()
228 style=move || style.get().to_string()
229 data-state={state_attr}
230 />
231 }
232}
233
234#[component]
236pub fn SwitchLabel(
237 #[prop(into, optional)] class: MaybeProp<String>,
238 #[prop(into, optional)] id: MaybeProp<String>,
239 #[prop(into, optional)] style: Signal<Style>,
240 #[prop(optional)] children: Option<Children>,
241) -> impl IntoView {
242 let computed_class = Signal::derive(move || {
243 format!("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 {}", class.get().unwrap_or_default())
244 });
245
246 view! {
247 <label
248 class=move || computed_class.get()
249 id=move || id.get().unwrap_or_default()
250 style=move || style.get().to_string()
251 >
252 {children.map(|c| c())}
253 </label>
254 }
255}