radix_leptos_checkbox/
checkbox.rs

1use std::{
2    fmt::{Display, Formatter},
3    rc::Rc,
4};
5
6use leptos::{
7    ev::{Event, KeyboardEvent, MouseEvent},
8    html::{AnyElement, Input},
9    *,
10};
11use radix_leptos_compose_refs::use_composed_refs;
12use radix_leptos_presence::Presence;
13use radix_leptos_primitive::{compose_callbacks, Primitive};
14use radix_leptos_use_controllable_state::{use_controllable_state, UseControllableStateParams};
15use radix_leptos_use_previous::use_previous;
16use radix_leptos_use_size::use_size;
17use web_sys::wasm_bindgen::{closure::Closure, JsCast};
18
19#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
20pub enum CheckedState {
21    False,
22    True,
23    Indeterminate,
24}
25
26impl Display for CheckedState {
27    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
28        write!(
29            f,
30            "{}",
31            match self {
32                CheckedState::False => "false",
33                CheckedState::True => "true",
34                CheckedState::Indeterminate => "indeterminate",
35            }
36        )
37    }
38}
39
40impl IntoAttribute for CheckedState {
41    fn into_attribute(self) -> Attribute {
42        Attribute::String(self.to_string().into())
43    }
44
45    fn into_attribute_boxed(self: Box<Self>) -> Attribute {
46        self.into_attribute()
47    }
48}
49
50#[derive(Clone, Debug)]
51struct CheckboxContextValue {
52    state: Signal<CheckedState>,
53    disabled: Signal<bool>,
54}
55
56#[component]
57pub fn Checkbox(
58    #[prop(into, optional)] name: MaybeProp<String>,
59    #[prop(into, optional)] checked: MaybeProp<CheckedState>,
60    #[prop(into, optional)] default_checked: MaybeProp<CheckedState>,
61    #[prop(into, optional)] on_checked_change: Option<Callback<CheckedState>>,
62    #[prop(into, optional)] required: MaybeProp<bool>,
63    #[prop(into, optional)] disabled: MaybeProp<bool>,
64    #[prop(into, optional)] value: MaybeProp<String>,
65    #[prop(into, optional)] on_keydown: Option<Callback<KeyboardEvent>>,
66    #[prop(into, optional)] on_click: Option<Callback<MouseEvent>>,
67    #[prop(into, optional)] as_child: MaybeProp<bool>,
68    #[prop(optional)] node_ref: NodeRef<AnyElement>,
69    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
70    children: ChildrenFn,
71) -> impl IntoView {
72    let name = Signal::derive(move || name.get());
73    let required = Signal::derive(move || required.get().unwrap_or(false));
74    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
75    let value = Signal::derive(move || value.get().unwrap_or("on".into()));
76
77    let button_ref = NodeRef::new();
78    let composed_refs = use_composed_refs(vec![node_ref, button_ref]);
79
80    let is_form_control = Signal::derive(move || {
81        button_ref
82            .get()
83            .and_then(|button| button.closest("form").ok())
84            .flatten()
85            .is_some()
86    });
87    let (checked, set_checked) = use_controllable_state(UseControllableStateParams {
88        prop: checked,
89        on_change: on_checked_change.map(|on_checked_change| {
90            Callback::new(move |value| {
91                if let Some(value) = value {
92                    on_checked_change.call(value);
93                }
94            })
95        }),
96        default_prop: default_checked,
97    });
98    let checked = Signal::derive(move || checked.get().unwrap_or(CheckedState::False));
99
100    let initial_checked_state = RwSignal::new(checked.get_untracked());
101    let handle_reset: Rc<Closure<dyn Fn(Event)>> = Rc::new(Closure::new(move |_| {
102        set_checked.call(Some(initial_checked_state.get_untracked()));
103    }));
104
105    Effect::new({
106        let handle_reset = handle_reset.clone();
107
108        move |_| {
109            if let Some(form) = button_ref
110                .get()
111                .and_then(|button| button.closest("form").ok())
112                .flatten()
113            {
114                form.add_event_listener_with_callback(
115                    "reset",
116                    (*handle_reset).as_ref().unchecked_ref(),
117                )
118                .expect("Reset event listener should be added.");
119            }
120        }
121    });
122
123    on_cleanup(move || {
124        if let Some(form) = button_ref
125            .get()
126            .and_then(|button| button.closest("form").ok())
127            .flatten()
128        {
129            form.remove_event_listener_with_callback(
130                "reset",
131                (*handle_reset).as_ref().unchecked_ref(),
132            )
133            .expect("Reset event listener should be removed.");
134        }
135    });
136
137    let context_value = CheckboxContextValue {
138        state: checked,
139        disabled,
140    };
141
142    let mut attrs = attrs.clone();
143    attrs.extend([
144        ("type", "button".into_attribute()),
145        ("role", "checkbox".into_attribute()),
146        ("aria-checked", checked.into_attribute()),
147        (
148            "aria-required",
149            (move || match required.get() {
150                true => "true",
151                false => "false",
152            })
153            .into_attribute(),
154        ),
155        (
156            "data-state",
157            (move || get_state(checked.get())).into_attribute(),
158        ),
159        (
160            "data-disabled",
161            (move || disabled.get().then_some("")).into_attribute(),
162        ),
163        (
164            "disabled",
165            (move || disabled.get().then_some("")).into_attribute(),
166        ),
167        ("value", value.into_attribute()),
168    ]);
169
170    view! {
171        <Provider value=context_value>
172            <Primitive
173                element=html::button
174                as_child=as_child
175                node_ref=composed_refs
176                attrs=attrs
177                on:keydown=compose_callbacks(on_keydown, Some(Callback::new(move |event: KeyboardEvent| {
178                    // According to WAI ARIA, checkboxes don't activate on enter keypress.
179                    if event.key() == "Enter" {
180                        event.prevent_default();
181                    }
182                })), None)
183                on:click=compose_callbacks(on_click, Some(Callback::new(move |event: MouseEvent| {
184                    set_checked.call(Some(match checked.get() {
185                        CheckedState::False => CheckedState::True,
186                        CheckedState::True => CheckedState::False,
187                        CheckedState::Indeterminate => CheckedState::True
188                    }));
189
190                    if is_form_control.get() {
191                        // If checkbox is in a form, stop propagation from the button, so that we only propagate
192                        // one click event (from the input). We propagate changes from an input so that native
193                        // form validation works and form events reflect checkbox updates.
194                        event.stop_propagation();
195                    }
196                })), None)
197            >
198                {children()}
199            </Primitive>
200            <Show when=move || is_form_control.get()>
201                <BubbleInput
202                    attr:name=name
203                    control_ref=button_ref
204                    bubbles=Signal::derive(|| true)
205                    value=value
206                    checked=checked
207                    required=required
208                    disabled=disabled
209                />
210            </Show>
211        </Provider>
212    }
213}
214
215#[component]
216pub fn CheckboxIndicator(
217    /// Used to force mounting when more control is needed. Useful when controlling animation with animation libraries.
218    #[prop(into, optional)]
219    force_mount: MaybeProp<bool>,
220    #[prop(into, optional)] as_child: MaybeProp<bool>,
221    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
222    #[prop(optional)] children: Option<ChildrenFn>,
223) -> impl IntoView {
224    let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
225
226    let context = expect_context::<CheckboxContextValue>();
227
228    let present = Signal::derive(move || {
229        force_mount.get()
230            || context.state.get() == CheckedState::Indeterminate
231            || context.state.get() == CheckedState::True
232    });
233
234    let mut attrs = attrs.clone();
235    attrs.extend([
236        (
237            "data-state",
238            (move || get_state(context.state.get())).into_attribute(),
239        ),
240        (
241            "data-disabled",
242            (move || context.disabled.get().then_some("")).into_attribute(),
243        ),
244        ("style", "pointer-events: none;".into_attribute()),
245    ]);
246
247    let attrs = StoredValue::new(attrs);
248    let children = StoredValue::new(children);
249
250    view! {
251        <Presence present=present>
252            <Primitive
253                element=html::span
254                as_child=as_child
255                attrs=attrs.get_value()
256            >
257                {children.with_value(|children| children.as_ref().map(|children| children()))}
258            </Primitive>
259        </Presence>
260    }
261}
262
263#[component]
264fn BubbleInput(
265    #[prop(into)] control_ref: NodeRef<AnyElement>,
266    #[prop(into)] checked: Signal<CheckedState>,
267    #[prop(into)] bubbles: Signal<bool>,
268    #[prop(into)] required: Signal<bool>,
269    #[prop(into)] disabled: Signal<bool>,
270    #[prop(into)] value: Signal<String>,
271    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
272) -> impl IntoView {
273    let node_ref: NodeRef<Input> = NodeRef::new();
274    let prev_checked = use_previous(checked);
275    let control_size = use_size(control_ref);
276
277    // Bubble checked change to parents
278    Effect::new(move |_| {
279        if let Some(input) = node_ref.get() {
280            if prev_checked.get() != checked.get() {
281                let init = web_sys::EventInit::new();
282                init.set_bubbles(bubbles.get());
283
284                let event = web_sys::Event::new_with_event_init_dict("click", &init)
285                    .expect("Click event should be instantiated.");
286
287                input.set_indeterminate(is_indeterminiate(checked.get()));
288                input.set_checked(match checked.get() {
289                    CheckedState::False => false,
290                    CheckedState::True => true,
291                    CheckedState::Indeterminate => false,
292                });
293
294                input
295                    .dispatch_event(&event)
296                    .expect("Click event should be dispatched.");
297            }
298        }
299    });
300
301    view! {
302        <input
303            node_ref=node_ref
304            type="checkbox"
305            aria-hidden="true"
306            checked=move || (match checked.get() {
307                CheckedState::False => false,
308                CheckedState::True => true,
309                CheckedState::Indeterminate => false,
310            }).then_some("")
311            required=move || required.get().then_some("")
312            disabled=move || disabled.get().then_some("")
313            value=value
314            tab-index="-1"
315            // We transform because the input is absolutely positioned, but we have
316            // rendered it **after** the button. This pulls it back to sit on top
317            // of the button.
318            style:transform="translateX(-100%)"
319            style:width=move || control_size.get().map(|size| format!("{}px", size.width))
320            style:height=move || control_size.get().map(|size| format!("{}px", size.height))
321            style:position="absolute"
322            style:pointer-events="none"
323            style:opacity="0"
324            style:margin="0px"
325            {..attrs}
326        />
327    }
328}
329
330fn is_indeterminiate(checked: CheckedState) -> bool {
331    checked == CheckedState::Indeterminate
332}
333
334fn get_state(checked: CheckedState) -> String {
335    (match checked {
336        CheckedState::True => "checked",
337        CheckedState::False => "unchecked",
338        CheckedState::Indeterminate => "indeterminate",
339    })
340    .into()
341}