Skip to main content

leptix_switch/
switch.rs

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    // Bubble checked change to parents
138    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}