Skip to main content

yewlish_switch/
lib.rs

1use std::rc::Rc;
2
3use yew::prelude::*;
4use yewlish_attr_passer::{attributify, AttrPasser, AttrReceiver};
5use yewlish_utils::{
6    helpers::combine_handlers::combine_handlers,
7    hooks::{use_conditional_attr, use_controllable_state},
8};
9
10#[derive(Clone, Debug, PartialEq, Properties)]
11pub struct SwitchRenderAsProps {
12    #[prop_or_default]
13    pub r#ref: NodeRef,
14    #[prop_or_default]
15    pub children: ChildrenWithProps<SwitchThumb>,
16    #[prop_or_default]
17    pub id: Option<AttrValue>,
18    #[prop_or_default]
19    pub class: Option<AttrValue>,
20    #[prop_or_default]
21    pub checked: bool,
22    #[prop_or_default]
23    pub disabled: bool,
24    #[prop_or_default]
25    pub required: bool,
26    #[prop_or_default]
27    pub name: Option<AttrValue>,
28    #[prop_or_default]
29    pub value: Option<AttrValue>,
30    #[prop_or_default]
31    pub readonly: bool,
32    #[prop_or_default]
33    pub toggle: Callback<()>,
34}
35
36#[derive(Clone, Debug, PartialEq, Properties)]
37pub struct SwitchProps {
38    #[prop_or_default]
39    pub r#ref: NodeRef,
40    #[prop_or_default]
41    pub children: ChildrenWithProps<SwitchThumb>,
42    #[prop_or_default]
43    pub id: Option<AttrValue>,
44    #[prop_or_default]
45    pub class: Option<AttrValue>,
46    #[prop_or_default]
47    pub default_checked: Option<bool>,
48    #[prop_or_default]
49    pub checked: Option<bool>,
50    #[prop_or_default]
51    pub disabled: bool,
52    #[prop_or_default]
53    pub on_checked_change: Callback<bool>,
54    #[prop_or_default]
55    pub required: bool,
56    #[prop_or_default]
57    pub name: Option<AttrValue>,
58    #[prop_or_default]
59    pub value: Option<AttrValue>,
60    #[prop_or_default]
61    pub onclick: Option<Callback<MouseEvent>>,
62    #[prop_or_default]
63    pub readonly: bool,
64    #[prop_or_default]
65    pub render_as: Option<Callback<SwitchRenderAsProps, Html>>,
66}
67
68#[derive(Clone, Debug, PartialEq)]
69pub struct SwitchContext {
70    pub(crate) checked: bool,
71    pub(crate) disabled: bool,
72}
73
74pub enum SwitchAction {
75    Toggle,
76}
77
78impl Reducible for SwitchContext {
79    type Action = SwitchAction;
80
81    fn reduce(self: Rc<SwitchContext>, action: Self::Action) -> Rc<SwitchContext> {
82        match action {
83            SwitchAction::Toggle => SwitchContext {
84                checked: !self.checked,
85                ..(*self).clone()
86            }
87            .into(),
88        }
89    }
90}
91
92type ReducibleSwitchContext = UseReducerHandle<SwitchContext>;
93
94#[function_component(Switch)]
95pub fn switch(props: &SwitchProps) -> Html {
96    let (checked, dispatch) = use_controllable_state(
97        props.default_checked,
98        props.checked,
99        props.on_checked_change.clone(),
100    );
101
102    let context_value = use_reducer(|| SwitchContext {
103        checked: *checked.borrow(),
104        disabled: props.disabled,
105    });
106
107    use_effect_with(
108        (*checked.borrow(), context_value.clone()),
109        |(checked, context_value)| {
110            if *checked != context_value.checked {
111                context_value.dispatch(SwitchAction::Toggle);
112            }
113        },
114    );
115
116    let toggle = use_callback(
117        (dispatch.clone(), context_value.clone(), props.readonly),
118        move |(), (dispatch, context_value, readonly)| {
119            if *readonly {
120                return;
121            }
122
123            dispatch.emit(Box::new(|prev_state| !prev_state));
124            context_value.dispatch(SwitchAction::Toggle);
125        },
126    );
127
128    let toggle_on_click = use_callback(toggle.clone(), move |_: MouseEvent, toggle| {
129        toggle.emit(());
130    });
131
132    use_conditional_attr(props.r#ref.clone(), "data-disabled", None, props.disabled);
133
134    let element = if let Some(render_as) = &props.render_as {
135        render_as.emit(SwitchRenderAsProps {
136            r#ref: props.r#ref.clone(),
137            children: props.children.clone(),
138            id: props.id.clone(),
139            class: props.class.clone(),
140            checked: *checked.borrow(),
141            disabled: props.disabled,
142            required: props.required,
143            name: props.name.clone(),
144            value: props.value.clone(),
145            readonly: props.readonly,
146            toggle: toggle.clone(),
147        })
148    } else {
149        html! {
150            <AttrReceiver name="switch">
151                <button
152                    id={&props.id}
153                    class={&props.class}
154                    type="button"
155                    role="switch"
156                    disabled={props.disabled}
157                    name={&props.name}
158                    value={&props.value}
159                    onclick={&combine_handlers(props.onclick.clone(), toggle_on_click.into())}
160                >
161                    {for props.children.iter()}
162                </button>
163            </AttrReceiver>
164        }
165    };
166
167    html! {
168        <ContextProvider<ReducibleSwitchContext> context={context_value}>
169            <AttrPasser name="switch" ..attributify! {
170                "aria-checked" => checked.borrow().to_string(),
171                "aria-required" => props.required.then_some("true").unwrap_or_default(),
172                "data-state" => if *checked.borrow() { "checked" } else { "unchecked" },
173                "data-disabled" => props.disabled.to_string(),
174            }>
175                {element}
176            </AttrPasser>
177        </ContextProvider<ReducibleSwitchContext>>
178    }
179}
180
181#[derive(Clone, Debug, PartialEq, Properties)]
182pub struct SwitchThumbProps {
183    #[prop_or_default]
184    pub class: Option<AttrValue>,
185}
186
187#[function_component(SwitchThumb)]
188pub fn switch_thumb(props: &SwitchThumbProps) -> Html {
189    let context =
190        use_context::<ReducibleSwitchContext>().expect("SwitchThumb must be a child of Switch");
191
192    let data_state = use_memo(context.checked, |checked| {
193        if *checked {
194            "checked"
195        } else {
196            "unchecked"
197        }
198    });
199
200    html! {
201        <div
202            class={&props.class}
203            data-state={*data_state}
204            data-disabled={context.disabled.to_string()}
205        ></div>
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use wasm_bindgen_test::*;
213    use yewlish_testing_tools::TesterEvent;
214    use yewlish_testing_tools::*;
215
216    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
217
218    #[wasm_bindgen_test]
219    async fn test_switch_should_toggle() {
220        let t = render!({
221            html! {
222                <Switch>
223                    <SwitchThumb />
224                </Switch>
225            }
226        })
227        .await;
228
229        // The switch should be unchecked by default
230        let switch = t.query_by_role("switch");
231        assert!(switch.exists());
232
233        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
234
235        // Click on the switch
236        let switch = switch.click().await;
237
238        // Now the switch should be checked
239        assert_eq!(switch.attribute("aria-checked"), "true".to_string().into());
240
241        // Click again
242        let switch = switch.click().await;
243
244        // The switch should be unchecked again
245        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
246    }
247
248    #[wasm_bindgen_test]
249    async fn test_switch_default_checked() {
250        let t = render!({
251            html! {
252                <Switch default_checked={Some(true)}>
253                    <SwitchThumb />
254                </Switch>
255            }
256        })
257        .await;
258
259        let switch = t.query_by_role("switch");
260        assert!(switch.exists());
261
262        assert_eq!(switch.attribute("aria-checked"), "true".to_string().into());
263    }
264
265    #[wasm_bindgen_test]
266    async fn test_switch_default_unchecked() {
267        let t = render!({
268            html! {
269                <Switch default_checked={false}>
270                    <SwitchThumb />
271                </Switch>
272            }
273        })
274        .await;
275
276        let switch = t.query_by_role("switch");
277        assert!(switch.exists());
278
279        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
280    }
281
282    #[wasm_bindgen_test]
283    async fn test_switch_checked_prop() {
284        let t = render!({
285            html! {
286                <Switch checked={true}>
287                    <SwitchThumb />
288                </Switch>
289            }
290        })
291        .await;
292
293        let switch = t.query_by_role("switch");
294        assert!(switch.exists());
295
296        assert_eq!(switch.attribute("aria-checked"), "true".to_string().into());
297    }
298
299    #[wasm_bindgen_test]
300    async fn test_switch_is_disabled() {
301        let t = render!({
302            html! {
303                <Switch disabled={true}>
304                    <SwitchThumb />
305                </Switch>
306            }
307        })
308        .await;
309
310        let switch = t.query_by_role("switch");
311        assert!(switch.exists());
312
313        assert_eq!(switch.attribute("disabled"), Some("disabled".into()));
314        assert_eq!(switch.attribute("data-disabled"), "true".to_string().into());
315
316        // Try clicking the disabled switch
317        let switch = switch.click().await;
318
319        // The state should not have changed
320        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
321    }
322
323    #[wasm_bindgen_test]
324    async fn test_switch_accept_id() {
325        let t = render!({
326            html! {
327                <Switch id={"switch-id"}>
328                    <SwitchThumb />
329                </Switch>
330            }
331        })
332        .await;
333
334        let switch = t.query_by_role("switch");
335        assert!(switch.exists());
336        assert_eq!(switch.attribute("id"), Some("switch-id".into()));
337    }
338
339    #[wasm_bindgen_test]
340    async fn test_switch_accept_class() {
341        let t = render!({
342            html! {
343                <Switch class={"switch-class"}>
344                    <SwitchThumb />
345                </Switch>
346            }
347        })
348        .await;
349
350        let switch = t.query_by_role("switch");
351        assert!(switch.exists());
352        assert_eq!(switch.attribute("class"), Some("switch-class".into()));
353    }
354
355    #[wasm_bindgen_test]
356    async fn test_switch_is_required() {
357        let t = render!({
358            html! {
359                <Switch required={true}>
360                    <SwitchThumb />
361                </Switch>
362            }
363        })
364        .await;
365
366        let switch = t.query_by_role("switch");
367        assert!(switch.exists());
368        assert_eq!(switch.attribute("aria-required"), "true".to_string().into());
369    }
370
371    #[wasm_bindgen_test]
372    async fn test_switch_have_name() {
373        let t = render!({
374            html! {
375                <Switch name={"switch-name"}>
376                    <SwitchThumb />
377                </Switch>
378            }
379        })
380        .await;
381
382        let switch = t.query_by_role("switch");
383        assert!(switch.exists());
384        assert_eq!(switch.attribute("name"), Some("switch-name".into()));
385    }
386
387    #[wasm_bindgen_test]
388    async fn test_switch_have_value() {
389        let t = render!({
390            html! {
391                <Switch value={"switch-value"}>
392                    <SwitchThumb />
393                </Switch>
394            }
395        })
396        .await;
397
398        let switch = t.query_by_role("switch");
399        assert!(switch.exists());
400        assert_eq!(switch.attribute("value"), Some("switch-value".into()));
401    }
402
403    #[wasm_bindgen_test]
404    async fn test_switch_on_checked_change() {
405        let t = render!({
406            let checked = use_state(|| false);
407
408            let on_checked_change = {
409                let checked = checked.clone();
410                Callback::from(move |new_checked: bool| {
411                    checked.set(new_checked);
412                })
413            };
414
415            use_remember_value(checked.clone());
416
417            html! {
418                <Switch checked={Some(*checked)} on_checked_change={on_checked_change.clone()}>
419                    <SwitchThumb />
420                </Switch>
421            }
422        })
423        .await;
424
425        let switch = t.query_by_role("switch");
426        assert!(switch.exists());
427
428        // Initially unchecked
429        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
430        assert!(!*t.get_remembered_value::<UseStateHandle<bool>>());
431
432        // Click the switch
433        let switch = switch.click().await;
434
435        // Should now be checked
436        assert_eq!(switch.attribute("aria-checked"), "true".to_string().into());
437        assert!(*t.get_remembered_value::<UseStateHandle<bool>>());
438
439        // Click again
440        let switch = switch.click().await;
441
442        // Should be unchecked again
443        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
444        assert!(!*t.get_remembered_value::<UseStateHandle<bool>>());
445    }
446
447    #[wasm_bindgen_test]
448    async fn test_switch_render_as_input() {
449        let t = render!({
450            let render_as = Callback::from(|props: SwitchRenderAsProps| {
451                let onchange = {
452                    let toggle = props.toggle.clone();
453                    Callback::from(move |_| {
454                        toggle.emit(());
455                    })
456                };
457
458                html! {
459                    <AttrReceiver name="switch">
460                        <input
461                            ref={props.r#ref.clone()}
462                            id={props.id.clone()}
463                            class={props.class.clone()}
464                            type="checkbox"
465                            checked={props.checked}
466                            disabled={props.disabled}
467                            required={props.required}
468                            name={props.name.clone()}
469                            value={props.value.clone()}
470                            onchange={onchange}
471                        />
472                    </AttrReceiver>
473                }
474            });
475
476            html! {
477                <Switch {render_as}>
478                    <SwitchThumb />
479                </Switch>
480            }
481        })
482        .await;
483
484        let input = t.query_by_role("checkbox");
485        assert!(input.exists());
486
487        // Initially unchecked
488        assert_eq!(input.attribute("aria-checked"), "false".to_string().into());
489
490        // Click the input
491        let input = input.click().await;
492
493        // Should now be checked
494        assert_eq!(input.attribute("aria-checked"), "true".to_string().into());
495
496        // Click again
497        let input = input.click().await;
498
499        // Should be unchecked again
500        assert_eq!(input.attribute("aria-checked"), "false".to_string().into());
501    }
502
503    #[wasm_bindgen_test]
504    async fn test_switch_readonly() {
505        let t = render!({
506            html! {
507                <Switch readonly={true}>
508                    <SwitchThumb />
509                </Switch>
510            }
511        })
512        .await;
513
514        let switch = t.query_by_role("switch");
515        assert!(switch.exists());
516
517        // Try clicking the readonly switch
518        let switch = switch.click().await;
519
520        // The state should not have changed
521        assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
522    }
523
524    #[wasm_bindgen_test]
525    async fn test_switch_thumb_data_state() {
526        let t = render!({
527            html! {
528                <Switch>
529                    <SwitchThumb class={"thumb-class"} />
530                </Switch>
531            }
532        })
533        .await;
534
535        let thumb = t.query_by_selector(".thumb-class");
536        assert!(thumb.exists());
537
538        // Initially unchecked
539        assert_eq!(
540            thumb.attribute("data-state"),
541            "unchecked".to_string().into()
542        );
543
544        // Click the switch
545        t.query_by_role("switch").click().await;
546
547        // Thumb should now reflect the checked state
548        assert_eq!(thumb.attribute("data-state"), "checked".to_string().into());
549    }
550}