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#[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 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#[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 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 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 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 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 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 let checkbox = checkbox.click().await;
823
824 assert_eq!(
825 checkbox.attribute("aria-checked"),
826 "true".to_string().into()
827 );
828
829 let checkbox = checkbox.click().await;
831 assert_eq!(
832 checkbox.attribute("aria-checked"),
833 "false".to_string().into()
834 );
835 }
836}