1use leptix_core::compose_refs::use_composed_refs;
2use leptix_core::primitive::{Primitive, compose_callbacks};
3use leptix_core::use_controllable_state::{UseControllableStateParams, use_controllable_state};
4use leptix_core::use_previous::use_previous;
5use leptix_core::use_size::use_size;
6use leptos::{context::Provider, ev::MouseEvent, html, prelude::*};
7use leptos_node_ref::AnyNodeRef;
8
9#[derive(Clone, Debug)]
10struct SwitchContextValue {
11 checked: Signal<bool>,
12 disabled: Signal<bool>,
13}
14
15#[component]
16pub fn Switch(
17 #[prop(into, optional)] name: MaybeProp<String>,
18 #[prop(into, optional)] checked: MaybeProp<bool>,
19 #[prop(into, optional)] default_checked: MaybeProp<bool>,
20 #[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
21 #[prop(into, optional)] required: MaybeProp<bool>,
22 #[prop(into, optional)] disabled: MaybeProp<bool>,
23 #[prop(into, optional)] value: MaybeProp<String>,
24 #[prop(into, optional)] on_click: Option<Callback<MouseEvent>>,
25 #[prop(into, optional)] as_child: MaybeProp<bool>,
26 #[prop(into, optional)] node_ref: AnyNodeRef,
27 children: TypedChildrenFn<impl IntoView + 'static>,
28) -> impl IntoView {
29 let children = StoredValue::new(children.into_inner());
30
31 let name = Signal::derive(move || name.get());
32 let required = Signal::derive(move || required.get().unwrap_or(false));
33 let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
34 let value = Signal::derive(move || value.get().unwrap_or("on".into()));
35
36 let button_ref = AnyNodeRef::new();
37 let composed_refs = use_composed_refs(vec![node_ref, button_ref]);
38
39 let is_form_control = Signal::derive(move || {
40 button_ref
41 .get()
42 .and_then(|button| button.closest("form").ok())
43 .flatten()
44 .is_some()
45 });
46 let (checked, set_checked) = use_controllable_state(UseControllableStateParams {
47 prop: checked,
48 on_change: on_checked_change.map(|on_checked_change| {
49 Callback::new(move |value| {
50 if let Some(value) = value {
51 on_checked_change.run(value);
52 }
53 })
54 }),
55 default_prop: default_checked,
56 });
57 let checked = Signal::derive(move || checked.get().unwrap_or(false));
58
59 let context_value = SwitchContextValue { checked, disabled };
60
61 view! {
62 <Provider value=context_value>
63 <Primitive
64 element=html::button
65 as_child=as_child
66 node_ref=composed_refs
67 attr:r#type="button"
68 attr:role="switch"
69 attr:aria-checked=move || checked.get().to_string()
70 attr:aria-required=move || required.get().to_string()
71 attr:data-state=move || get_state(checked.get())
72 attr:data-disabled=move || disabled.get().then_some("")
73 attr:disabled=move || disabled.get().then_some("")
74 attr:value=move || value.get()
75 on:click=compose_callbacks(on_click, Some(Callback::new(move |event: MouseEvent| {
76 set_checked.run(Some(!checked.get()));
77
78 if is_form_control.get() {
79 event.stop_propagation();
80 }
81 })), None)
82 >
83 {children.with_value(|children| children())}
84 </Primitive>
85 <Show when=move || is_form_control.get()>
86 <BubbleInput
87 control_ref=button_ref
88 bubbles=Signal::derive(|| true)
89 name=name
90 value=value
91 checked=checked
92 required=required
93 disabled=disabled
94 />
95 </Show>
96 </Provider>
97 }
98}
99
100#[component]
101pub fn SwitchThumb(
102 #[prop(into, optional)] as_child: MaybeProp<bool>,
103 #[prop(into, optional)] node_ref: AnyNodeRef,
104 #[prop(optional)] children: Option<ChildrenFn>,
105) -> impl IntoView {
106 let children = StoredValue::new(children);
107
108 let context = expect_context::<SwitchContextValue>();
109
110 view! {
111 <Primitive
112 element=html::span
113 as_child=as_child
114 node_ref=node_ref
115 attr:data-state=move || get_state(context.checked.get())
116 attr:data-disabled=move || context.disabled.get().then_some("")
117 >
118 {children.with_value(|children| children.as_ref().map(|children| children()))}
119 </Primitive>
120 }
121}
122
123#[component]
124fn BubbleInput(
125 #[prop(into)] control_ref: AnyNodeRef,
126 #[prop(into)] checked: Signal<bool>,
127 #[prop(into)] bubbles: Signal<bool>,
128 #[prop(into)] required: Signal<bool>,
129 #[prop(into)] disabled: Signal<bool>,
130 #[prop(into)] name: Signal<Option<String>>,
131 #[prop(into)] value: Signal<String>,
132) -> impl IntoView {
133 let node_ref: NodeRef<html::Input> = NodeRef::new();
134 let prev_checked = use_previous(checked);
135 let control_size = use_size(control_ref);
136
137 Effect::new(move |_| {
139 if let Some(input) = node_ref.get()
140 && prev_checked.get() != checked.get()
141 {
142 let init = web_sys::EventInit::new();
143 init.set_bubbles(bubbles.get());
144
145 let event = web_sys::Event::new_with_event_init_dict("click", &init)
146 .expect("Click event should be instantiated.");
147
148 input.set_checked(checked.get());
149
150 input
151 .dispatch_event(&event)
152 .expect("Click event should be dispatched.");
153 }
154 });
155
156 view! {
157 <input
158 node_ref=node_ref
159 type="checkbox"
160 aria-hidden="true"
161 checked=move || checked.get().then_some("")
162 required=move || required.get().then_some("")
163 disabled=move || disabled.get().then_some("")
164 name=move || name.get()
165 value=move || value.get()
166 tab-index="-1"
167 style:transform="translateX(-100%)"
168 style:width=move || control_size.get().map(|size| format!("{}px", size.width))
169 style:height=move || control_size.get().map(|size| format!("{}px", size.height))
170 style:position="absolute"
171 style:pointer-events="none"
172 style:opacity="0"
173 style:margin="0px"
174 />
175 }
176}
177
178fn get_state(checked: bool) -> String {
179 (match checked {
180 true => "checked",
181 false => "unchecked",
182 })
183 .into()
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn switch_state_values() {
192 assert_eq!(get_state(true), "checked");
193 assert_eq!(get_state(false), "unchecked");
194 }
195}