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 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 let switch = switch.click().await;
237
238 assert_eq!(switch.attribute("aria-checked"), "true".to_string().into());
240
241 let switch = switch.click().await;
243
244 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 let switch = switch.click().await;
318
319 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 assert_eq!(switch.attribute("aria-checked"), "false".to_string().into());
430 assert!(!*t.get_remembered_value::<UseStateHandle<bool>>());
431
432 let switch = switch.click().await;
434
435 assert_eq!(switch.attribute("aria-checked"), "true".to_string().into());
437 assert!(*t.get_remembered_value::<UseStateHandle<bool>>());
438
439 let switch = switch.click().await;
441
442 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 assert_eq!(input.attribute("aria-checked"), "false".to_string().into());
489
490 let input = input.click().await;
492
493 assert_eq!(input.attribute("aria-checked"), "true".to_string().into());
495
496 let input = input.click().await;
498
499 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 let switch = switch.click().await;
519
520 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 assert_eq!(
540 thumb.attribute("data-state"),
541 "unchecked".to_string().into()
542 );
543
544 t.query_by_role("switch").click().await;
546
547 assert_eq!(thumb.attribute("data-state"), "checked".to_string().into());
549 }
550}