yewlish_checkbox/
lib.rs

1use html::IntoPropValue;
2use std::default::Default;
3use std::rc::Rc;
4use web_sys::HtmlButtonElement;
5use yew::prelude::*;
6use yewlish_attr_passer::*;
7use yewlish_presence::*;
8use yewlish_utils::hooks::{use_conditional_attr, use_controllable_state};
9
10#[derive(Clone, Default, Debug, PartialEq)]
11pub enum CheckedState {
12    Checked,
13    #[default]
14    Unchecked,
15    Indeterminate,
16}
17
18impl IntoPropValue<Option<AttrValue>> for CheckedState {
19    fn into_prop_value(self) -> Option<AttrValue> {
20        match self {
21            CheckedState::Checked => Some("checked".into()),
22            CheckedState::Unchecked => Some("unchecked".into()),
23            CheckedState::Indeterminate => Some("indeterminate".into()),
24        }
25    }
26}
27
28impl std::fmt::Display for CheckedState {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            CheckedState::Checked => write!(f, "checked"),
32            CheckedState::Unchecked => write!(f, "unchecked"),
33            CheckedState::Indeterminate => write!(f, "indeterminate"),
34        }
35    }
36}
37
38#[derive(Clone, Debug, PartialEq)]
39pub struct CheckboxContext {
40    pub(crate) checked: CheckedState,
41    pub(crate) disabled: bool,
42}
43
44pub enum CheckboxAction {
45    Toggle,
46}
47
48impl Reducible for CheckboxContext {
49    type Action = CheckboxAction;
50
51    fn reduce(self: Rc<CheckboxContext>, action: Self::Action) -> Rc<CheckboxContext> {
52        match action {
53            CheckboxAction::Toggle => CheckboxContext {
54                checked: match self.checked {
55                    CheckedState::Checked => CheckedState::Unchecked,
56                    CheckedState::Unchecked | CheckedState::Indeterminate => CheckedState::Checked,
57                },
58                ..(*self).clone()
59            }
60            .into(),
61        }
62    }
63}
64
65type ReducibleCheckboxContext = UseReducerHandle<CheckboxContext>;
66
67#[derive(Clone, Debug, PartialEq, Properties)]
68pub struct CheckboxRenderAsProps {
69    #[prop_or_default]
70    pub children: Children,
71    #[prop_or_default]
72    pub r#ref: NodeRef,
73    #[prop_or_default]
74    pub id: Option<AttrValue>,
75    #[prop_or_default]
76    pub class: Option<AttrValue>,
77    #[prop_or_default]
78    pub checked: CheckedState,
79    #[prop_or_default]
80    pub toggle: Callback<()>,
81    #[prop_or_default]
82    pub disabled: bool,
83    #[prop_or_default]
84    pub required: bool,
85    #[prop_or_default]
86    pub name: Option<AttrValue>,
87    #[prop_or_default]
88    pub value: Option<AttrValue>,
89    #[prop_or_default]
90    pub readonly: bool,
91}
92
93#[derive(Clone, Debug, PartialEq, Properties)]
94pub struct CheckboxProps {
95    #[prop_or_default]
96    pub children: Children,
97    #[prop_or_default]
98    pub r#ref: NodeRef,
99    #[prop_or_default]
100    pub id: Option<AttrValue>,
101    #[prop_or_default]
102    pub class: Option<AttrValue>,
103    #[prop_or_default]
104    pub default_checked: Option<CheckedState>,
105    #[prop_or_default]
106    pub checked: Option<CheckedState>,
107    #[prop_or_default]
108    pub disabled: bool,
109    #[prop_or_default]
110    pub on_checked_change: Callback<CheckedState>,
111    #[prop_or_default]
112    pub required: bool,
113    #[prop_or_default]
114    pub name: Option<AttrValue>,
115    #[prop_or_default]
116    pub value: Option<AttrValue>,
117    #[prop_or_default]
118    pub readonly: bool,
119    #[prop_or_default]
120    pub render_as: Option<Callback<CheckboxRenderAsProps, Html>>,
121}
122
123/// A customizable checkbox component.
124///
125/// # Example
126///
127/// ```rust
128/// use yew::prelude::*;
129/// use yewlish_checkbox::{Checkbox, CheckboxIndicator, CheckedState};
130///
131/// #[function_component(App)]
132/// fn app() -> Html {
133///     html! {
134///         <Checkbox>
135///             <CheckboxIndicator show_when={CheckedState::Checked}>{"✔"}</CheckboxIndicator>
136///         </Checkbox>
137///     }
138/// }
139/// ```
140#[function_component(Checkbox)]
141pub fn checkbox(props: &CheckboxProps) -> Html {
142    let (checked, dispatch) = use_controllable_state(
143        props.default_checked.clone(),
144        props.checked.clone(),
145        props.on_checked_change.clone(),
146    );
147
148    let context_value = use_reducer(|| CheckboxContext {
149        checked: checked.borrow().clone(),
150        disabled: props.disabled,
151    });
152
153    use_effect_with(
154        ((*checked).clone().borrow().clone(), context_value.clone()),
155        |(checked, context_value)| {
156            if *checked != context_value.checked {
157                context_value.dispatch(CheckboxAction::Toggle);
158            }
159        },
160    );
161
162    let toggle = use_callback(
163        (dispatch.clone(), context_value.clone(), props.readonly),
164        move |(), (dispatch, context_value, readonly)| {
165            if *readonly {
166                return;
167            }
168
169            dispatch.emit(Box::new(|prev_state| match prev_state {
170                CheckedState::Checked => CheckedState::Unchecked,
171                CheckedState::Unchecked | CheckedState::Indeterminate => CheckedState::Checked,
172            }));
173
174            context_value.dispatch(CheckboxAction::Toggle);
175        },
176    );
177
178    let toggle_on_click = use_callback(toggle.clone(), move |_: MouseEvent, toggle| {
179        toggle.emit(());
180    });
181
182    let prevent_checked_by_enter = use_callback((), |event: KeyboardEvent, ()| {
183        if event.key() == "Enter" {
184            event.prevent_default();
185        }
186    });
187
188    // TODO: BubbleInput
189    let _is_form_control = props
190        .r#ref
191        .cast::<HtmlButtonElement>()
192        .map(|element| element.closest("form").is_ok())
193        .is_some();
194
195    use_conditional_attr(props.r#ref.clone(), "data-disabled", None, props.disabled);
196
197    let element = if let Some(render_as) = &props.render_as {
198        html! {
199            render_as.emit(CheckboxRenderAsProps {
200                children: props.children.clone(),
201                r#ref: props.r#ref.clone(),
202                id: props.id.clone(),
203                class: props.class.clone(),
204                checked: checked.borrow().clone(),
205                toggle: toggle.clone(),
206                disabled: props.disabled,
207                required: props.required,
208                name: props.name.clone(),
209                value: props.value.clone(),
210                readonly: props.readonly,
211            })
212        }
213    } else {
214        html! {
215            <AttrReceiver name="checkbox">
216                <button
217                    ref={props.r#ref.clone()}
218                    id={props.id.clone()}
219                    class={&props.class}
220                    type="button"
221                    role="checkbox"
222                    disabled={props.disabled}
223                    name={props.name.clone()}
224                    value={props.value.clone()}
225                    readonly={props.readonly}
226                    onkeydown={prevent_checked_by_enter}
227                    onclick={&toggle_on_click}
228                >
229                    {for props.children.iter()}
230                </button>
231            </AttrReceiver>
232        }
233    };
234
235    html! {
236        <ContextProvider<ReducibleCheckboxContext> context={context_value}>
237            <AttrPasser name="checkbox" ..attributify! {
238                "aria-checked" => match *checked.borrow() {
239                    CheckedState::Checked => "true",
240                    CheckedState::Unchecked => "false",
241                    CheckedState::Indeterminate => "mixed",
242                },
243                "aria-required" => props.required.to_string(),
244                "data-state" => checked.borrow().to_string(),
245            }>
246                {element}
247            </AttrPasser>
248        </ContextProvider<ReducibleCheckboxContext>>
249    }
250}
251
252#[derive(Clone, Debug, PartialEq, Properties)]
253pub struct CheckboxIndicatorRenderAsProps {
254    #[prop_or_default]
255    pub r#ref: NodeRef,
256    #[prop_or_default]
257    pub class: Option<AttrValue>,
258    #[prop_or_default]
259    pub children: Children,
260    #[prop_or_default]
261    pub checked: CheckedState,
262}
263
264#[derive(Clone, Debug, PartialEq, Properties)]
265pub struct CheckboxIndicatorProps {
266    #[prop_or_default]
267    pub r#ref: NodeRef,
268    #[prop_or_default]
269    pub class: Option<AttrValue>,
270    #[prop_or_default]
271    pub children: Children,
272    #[prop_or(CheckedState::Checked)]
273    pub show_when: CheckedState,
274    #[prop_or_default]
275    pub render_as: Option<Callback<CheckboxIndicatorRenderAsProps, Html>>,
276}
277
278/// A component that displays an indicator based on the checkbox state.
279///
280/// # Example
281///
282/// ```rust
283/// use yew::prelude::*;
284/// use yewlish_checkbox::{Checkbox, CheckboxIndicator, CheckedState};
285///
286/// #[function_component(App)]
287/// fn app() -> Html {
288///     html! {
289///         <Checkbox>
290///             <CheckboxIndicator show_when={CheckedState::Checked}>{"✔"}</CheckboxIndicator>
291///             <CheckboxIndicator show_when={CheckedState::Indeterminate}>{"-"}</CheckboxIndicator>
292///         </Checkbox>
293///     }
294/// }
295/// ```
296#[function_component(CheckboxIndicator)]
297pub fn checkbox_indicator(props: &CheckboxIndicatorProps) -> Html {
298    let context = use_context::<ReducibleCheckboxContext>()
299        .expect("CheckboxIndicator must be a child of Checkbox");
300
301    use_conditional_attr(props.r#ref.clone(), "data-disabled", None, context.disabled);
302
303    let element = if let Some(render_as) = &props.render_as {
304        html! {
305            render_as.emit(CheckboxIndicatorRenderAsProps {
306                r#ref: props.r#ref.clone(),
307                class: props.class.clone(),
308                children: props.children.clone(),
309                checked: context.checked.clone(),
310            })
311        }
312    } else {
313        html! {
314            <Presence
315                name="checkbox-indicator"
316                r#ref={props.r#ref.clone()}
317                class={&props.class}
318                present={context.checked == props.show_when}
319                render_as={
320                    Callback::from(|PresenceRenderAsProps { r#ref, class, presence, children }| {
321                        html! {
322                            <span
323                                ref={r#ref.clone()}
324                                class={&class}
325                            >
326                                { if presence {
327                                    html! { {for children.iter()} }
328                                } else {
329                                    html! {}
330                                } }
331                            </span>
332                        }
333                    })
334                }
335            >
336                {for props.children.iter()}
337            </Presence>
338        }
339    };
340
341    html! {
342        <AttrPasser name="checkbox-indicator" ..attributify! {
343            "data-state" => context.checked.to_string(),
344        }>
345            {element}
346        </AttrPasser>
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use wasm_bindgen_test::*;
354    use yewlish_testing_tools::TesterEvent;
355    use yewlish_testing_tools::*;
356
357    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
358
359    #[wasm_bindgen_test]
360    async fn test_checkbox_should_toggle() {
361        let t = render! {
362            html! {
363                <Checkbox>
364                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
365                </Checkbox>
366            }
367        }
368        .await;
369
370        // The checkbox should be unchecked by default
371        let checkbox = t.query_by_role("checkbox");
372        assert!(checkbox.exists());
373
374        assert_eq!(checkbox.attribute("disabled"), None);
375        assert_eq!(checkbox.attribute("data-disabled"), None);
376
377        assert_eq!(
378            checkbox.attribute("aria-checked"),
379            "false".to_string().into()
380        );
381
382        assert_eq!(
383            checkbox.attribute("data-state"),
384            "unchecked".to_string().into()
385        );
386
387        assert!(!t.query_by_text("X").exists());
388
389        // After clicking, the state should be checked
390        let checkbox = checkbox.click().await;
391
392        assert_eq!(
393            checkbox.attribute("aria-checked"),
394            "true".to_string().into()
395        );
396
397        assert_eq!(
398            checkbox.attribute("data-state"),
399            "checked".to_string().into()
400        );
401
402        assert!(t.query_by_text("X").exists());
403
404        // After clicking again, the state should be unchecked
405        let checkbox = checkbox.click().await;
406
407        assert_eq!(
408            checkbox.attribute("aria-checked"),
409            "false".to_string().into()
410        );
411
412        assert_eq!(
413            checkbox.attribute("data-state"),
414            "unchecked".to_string().into()
415        );
416
417        assert!(!t.query_by_text("X").exists());
418    }
419
420    #[wasm_bindgen_test]
421    async fn test_checkbox_default_checked() {
422        let t = render! {
423            html! {
424                <Checkbox default_checked={CheckedState::Checked}>
425                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
426                </Checkbox>
427            }
428        }
429        .await;
430
431        let checkbox = t.query_by_role("checkbox");
432
433        assert_eq!(
434            checkbox.attribute("aria-checked"),
435            "true".to_string().into()
436        );
437
438        assert_eq!(
439            checkbox.attribute("data-state"),
440            "checked".to_string().into()
441        );
442
443        assert!(t.query_by_text("X").exists());
444    }
445
446    #[wasm_bindgen_test]
447    async fn test_checkbox_default_unchecked() {
448        let t = render! {
449            html! {
450                <Checkbox checked={CheckedState::Unchecked}>
451                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
452                </Checkbox>
453            }
454        }
455        .await;
456
457        let checkbox = t.query_by_role("checkbox");
458
459        assert_eq!(
460            checkbox.attribute("aria-checked"),
461            "false".to_string().into()
462        );
463
464        assert_eq!(
465            checkbox.attribute("data-state"),
466            "unchecked".to_string().into()
467        );
468
469        assert!(!t.query_by_text("X").exists());
470    }
471
472    #[wasm_bindgen_test]
473    async fn test_checkbox_is_disabled() {
474        let t = render! {
475            html! {
476                <Checkbox disabled={true}>
477                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
478                </Checkbox>
479            }
480        }
481        .await;
482
483        let checkbox = t.query_by_role("checkbox");
484
485        assert_eq!(
486            checkbox.attribute("disabled"),
487            "disabled".to_string().into()
488        );
489
490        assert_eq!(checkbox.attribute("data-disabled"), String::new().into());
491
492        assert_eq!(
493            checkbox.attribute("aria-checked"),
494            "false".to_string().into()
495        );
496
497        assert_eq!(
498            checkbox.attribute("data-state"),
499            "unchecked".to_string().into()
500        );
501
502        assert!(!t.query_by_text("X").exists());
503
504        // The checkbox should not toggle when disabled
505        let checkbox = checkbox.click().await;
506
507        assert_eq!(
508            checkbox.attribute("disabled"),
509            "disabled".to_string().into()
510        );
511
512        assert_eq!(checkbox.attribute("data-disabled"), String::new().into());
513
514        assert_eq!(
515            checkbox.attribute("aria-checked"),
516            "false".to_string().into()
517        );
518
519        assert_eq!(
520            checkbox.attribute("data-state"),
521            "unchecked".to_string().into()
522        );
523
524        assert!(!t.query_by_text("X").exists());
525    }
526
527    #[wasm_bindgen_test]
528    async fn test_checkbox_attr_passer() {
529        let t = render! {
530            html! {
531                <AttrPasser name="checkbox" ..attributify!{
532                    "data-testid" => "checkbox-id",
533                }>
534                    <Checkbox>
535                        <AttrPasser name="checkbox-indicator" ..attributify!{
536                            "data-testid" => "checkbox-indicator-id",
537                        }>
538                            <CheckboxIndicator></CheckboxIndicator>
539                        </AttrPasser>
540                    </Checkbox>
541                </AttrPasser>
542            }
543        }
544        .await;
545
546        assert!(t.query_by_testid("checkbox-id").exists());
547        assert!(t.query_by_testid("checkbox-indicator-id").exists());
548    }
549
550    #[wasm_bindgen_test]
551    async fn test_checkbox_accept_id() {
552        let t = render! {
553            html! {
554                <Checkbox id={"id"}>
555                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
556                </Checkbox>
557            }
558        }
559        .await;
560
561        let checkbox = t.query_by_role("checkbox");
562        assert!(checkbox.exists());
563        assert_eq!(checkbox.attribute("id"), "id".to_string().into());
564    }
565
566    #[wasm_bindgen_test]
567    async fn test_checkbox_accept_class() {
568        let t = render! {
569            html! {
570                <Checkbox class={"class"}>
571                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
572                </Checkbox>
573            }
574        }
575        .await;
576
577        let checkbox = t.query_by_role("checkbox");
578        assert!(checkbox.exists());
579        assert_eq!(checkbox.attribute("class"), "class".to_string().into());
580    }
581
582    #[wasm_bindgen_test]
583    async fn test_checkbox_is_required() {
584        let t = render! {
585            html! {
586                <Checkbox required={true}>
587                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
588                </Checkbox>
589            }
590        }
591        .await;
592
593        let checkbox = t.query_by_role("checkbox");
594
595        assert!(checkbox.exists());
596        assert_eq!(
597            checkbox.attribute("aria-required"),
598            "true".to_string().into()
599        );
600    }
601
602    #[wasm_bindgen_test]
603    async fn test_checkbox_have_name() {
604        let t = render! {
605            html! {
606                <Checkbox name={"name"}>
607                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
608                </Checkbox>
609            }
610        }
611        .await;
612
613        let checkbox = t.query_by_role("checkbox");
614        assert!(checkbox.exists());
615        assert_eq!(checkbox.attribute("name"), "name".to_string().into());
616    }
617
618    #[wasm_bindgen_test]
619    async fn test_checkbox_have_value() {
620        let t = render! {
621            html! {
622                <Checkbox value={"value"}>
623                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
624                </Checkbox>
625            }
626        }
627        .await;
628
629        let checkbox = t.query_by_role("checkbox");
630
631        assert!(checkbox.exists());
632        assert_eq!(checkbox.attribute("value"), "value".to_string().into());
633    }
634
635    #[wasm_bindgen_test]
636    async fn test_checkbox_does_not_toggle_on_enter() {
637        let t = render! {
638            html! {
639                <Checkbox>
640                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
641                </Checkbox>
642            }
643        }
644        .await;
645
646        let checkbox = t.query_by_role("checkbox");
647
648        assert!(checkbox.exists());
649        assert_eq!(
650            checkbox.attribute("aria-checked"),
651            "false".to_string().into()
652        );
653
654        let checkbox = checkbox.keydown("Enter").await;
655
656        assert_eq!(
657            checkbox.attribute("aria-checked"),
658            "false".to_string().into()
659        );
660    }
661
662    #[wasm_bindgen_test]
663    async fn test_checkbox_toggles_on_space() {
664        let t = render! {
665            html! {
666                <Checkbox>
667                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
668                </Checkbox>
669            }
670        }
671        .await;
672
673        let checkbox = t.query_by_role("checkbox");
674
675        assert!(checkbox.exists());
676        assert_eq!(
677            checkbox.attribute("aria-checked"),
678            "false".to_string().into()
679        );
680
681        let checkbox = checkbox.keydown(" ").await;
682
683        assert_eq!(
684            checkbox.attribute("aria-checked"),
685            "false".to_string().into()
686        );
687    }
688
689    #[wasm_bindgen_test]
690    async fn test_checkbox_accept_ref() {
691        let t = render!({
692            let node_ref = use_node_ref();
693            use_remember_value(node_ref.clone());
694
695            html! {
696                <Checkbox r#ref={node_ref}>
697                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
698                </Checkbox>
699            }
700        })
701        .await;
702
703        assert!(t.query_by_role("checkbox").exists());
704    }
705
706    #[wasm_bindgen_test]
707    async fn test_checkbox_on_checked_change() {
708        let t = render!({
709            let checked = use_state(|| CheckedState::Unchecked);
710
711            let on_checked_change = use_callback((), {
712                let checked = checked.clone();
713
714                move |next_state: CheckedState, ()| {
715                    checked.set(next_state);
716                }
717            });
718
719            use_remember_value(checked.clone());
720
721            html! {
722                <Checkbox checked={(*checked).clone()} on_checked_change={&on_checked_change}>
723                    <CheckboxIndicator show_when={CheckedState::Checked}>{"X"}</CheckboxIndicator>
724                </Checkbox>
725            }
726        })
727        .await;
728
729        let checkbox = t.query_by_role("checkbox");
730
731        assert!(checkbox.exists());
732        assert_eq!(
733            checkbox.attribute("aria-checked"),
734            "false".to_string().into()
735        );
736
737        assert_eq!(
738            *t.get_remembered_value::<UseStateHandle<CheckedState>>(),
739            CheckedState::Unchecked
740        );
741
742        let checkbox = checkbox.click().await;
743
744        assert_eq!(
745            checkbox.attribute("aria-checked"),
746            "true".to_string().into()
747        );
748
749        assert_eq!(
750            *t.get_remembered_value::<UseStateHandle<CheckedState>>(),
751            CheckedState::Checked
752        );
753
754        let checkbox = checkbox.click().await;
755
756        assert_eq!(
757            checkbox.attribute("aria-checked"),
758            "false".to_string().into()
759        );
760
761        assert_eq!(
762            *t.get_remembered_value::<UseStateHandle<CheckedState>>(),
763            CheckedState::Unchecked
764        );
765    }
766
767    #[wasm_bindgen_test]
768    async fn test_checkbox_render_as_input_checkbox() {
769        let t = render!({
770            let checked = use_state(|| CheckedState::Unchecked);
771
772            let render_as = Callback::from(|props: CheckboxRenderAsProps| {
773                let checked = props.checked == CheckedState::Checked;
774
775                let onchange = {
776                    let toggle = props.toggle.clone();
777
778                    Callback::from(move |_event: Event| {
779                        toggle.emit(());
780                    })
781                };
782
783                html! {
784                    <input
785                        ref={props.r#ref.clone()}
786                        id={props.id.clone()}
787                        class={props.class.clone()}
788                        type="checkbox"
789                        checked={checked}
790                        disabled={props.disabled}
791                        required={props.required}
792                        name={props.name.clone()}
793                        aria-checked={if checked { "true" } else { "false" }}
794                        value={props.value.clone()}
795                        onchange={onchange}
796                    />
797                }
798            });
799
800            html! {
801                <Checkbox
802                    {render_as}
803                    checked={(*checked).clone()}
804                    on_checked_change={Callback::from(move |next_state| checked.set(next_state))}
805                />
806            }
807        })
808        .await;
809
810        // The checkbox should be unchecked by default
811        let checkbox = t.query_by_role("checkbox");
812        assert!(checkbox.exists());
813
814        assert_eq!(
815            checkbox.attribute("aria-checked"),
816            "false".to_string().into()
817        );
818
819        assert_eq!(checkbox.attribute("disabled"), None);
820
821        // After clicking, the checkbox should be checked
822        let checkbox = checkbox.click().await;
823
824        assert_eq!(
825            checkbox.attribute("aria-checked"),
826            "true".to_string().into()
827        );
828
829        // After clicking again, the checkbox should be unchecked
830        let checkbox = checkbox.click().await;
831        assert_eq!(
832            checkbox.attribute("aria-checked"),
833            "false".to_string().into()
834        );
835    }
836}