Skip to main content

leptix_toggle_group/
toggle_group.rs

1use leptix_core::direction::{Direction, use_direction};
2use leptix_core::primitive::Primitive;
3use leptix_core::use_controllable_state::{UseControllableStateParams, use_controllable_state};
4use leptos::{context::Provider, ev::KeyboardEvent, html, prelude::*};
5use leptos_node_ref::AnyNodeRef;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum ToggleGroupType {
9    Single,
10    Multiple,
11}
12
13#[derive(Clone, Debug)]
14struct ToggleGroupContextValue {
15    group_type: ToggleGroupType,
16    value: Signal<Vec<String>>,
17    on_item_activate: Callback<String>,
18    disabled: Signal<bool>,
19    orientation: Signal<String>,
20}
21
22#[component]
23pub fn ToggleGroup(
24    #[prop(into)] r#type: ToggleGroupType,
25    #[prop(into, optional)] value: MaybeProp<Vec<String>>,
26    #[prop(into, optional)] default_value: MaybeProp<Vec<String>>,
27    #[prop(into, optional)] on_value_change: Option<Callback<Vec<String>>>,
28    #[prop(into, optional)] disabled: MaybeProp<bool>,
29    #[prop(into, optional)] orientation: MaybeProp<String>,
30    #[prop(into, optional)] dir: MaybeProp<Direction>,
31    #[prop(into, optional)] r#loop: MaybeProp<bool>,
32    #[prop(into, optional)] as_child: MaybeProp<bool>,
33    #[prop(into, optional)] node_ref: AnyNodeRef,
34    children: TypedChildrenFn<impl IntoView + 'static>,
35) -> impl IntoView {
36    let children = StoredValue::new(children.into_inner());
37    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
38    let orientation = Signal::derive(move || orientation.get().unwrap_or("horizontal".into()));
39    let direction = use_direction(dir);
40    let do_loop = Signal::derive(move || r#loop.get().unwrap_or(true));
41
42    let (value, set_value) = use_controllable_state(UseControllableStateParams {
43        prop: value,
44        on_change: on_value_change.map(|cb| {
45            Callback::new(move |value: Option<Vec<String>>| {
46                if let Some(value) = value {
47                    cb.run(value);
48                }
49            })
50        }),
51        default_prop: default_value,
52    });
53    let value = Signal::derive(move || value.get().unwrap_or_default());
54
55    let group_type = r#type;
56    let context_value = ToggleGroupContextValue {
57        group_type,
58        value,
59        orientation,
60        on_item_activate: Callback::new(move |item_value: String| {
61            let current = value.get();
62            let next = match group_type {
63                ToggleGroupType::Single => {
64                    if current.contains(&item_value) {
65                        vec![]
66                    } else {
67                        vec![item_value]
68                    }
69                }
70                ToggleGroupType::Multiple => {
71                    if current.contains(&item_value) {
72                        current.into_iter().filter(|v| *v != item_value).collect()
73                    } else {
74                        let mut next = current;
75                        next.push(item_value);
76                        next
77                    }
78                }
79            };
80            set_value.run(Some(next));
81        }),
82        disabled,
83    };
84
85    view! {
86        <Provider value=context_value>
87            <Primitive
88                element=html::div
89                as_child=as_child
90                node_ref=node_ref
91                attr:role="group"
92                attr:data-orientation=move || orientation.get()
93                attr:data-disabled=move || disabled.get().then_some("")
94                attr:dir=move || direction.get().to_string()
95                on:keydown=move |event: KeyboardEvent| {
96                    let is_vertical = orientation.get() != "horizontal";
97                    let is_horizontal = orientation.get() != "vertical";
98                    let is_rtl = direction.get() == Direction::Rtl;
99
100                    let next = match event.key().as_str() {
101                        "ArrowUp" if is_vertical => Some(false),
102                        "ArrowDown" if is_vertical => Some(true),
103                        "ArrowLeft" if is_horizontal => Some(is_rtl),
104                        "ArrowRight" if is_horizontal => Some(!is_rtl),
105                        "Home" => {
106                            event.prevent_default();
107                            roving_focus_items(&event, true, true);
108                            None
109                        }
110                        "End" => {
111                            event.prevent_default();
112                            roving_focus_items(&event, false, true);
113                            None
114                        }
115                        _ => None,
116                    };
117
118                    if let Some(forward) = next {
119                        event.prevent_default();
120                        roving_focus_items(&event, forward, do_loop.get());
121                    }
122                }
123            >
124                {children.with_value(|children| children())}
125            </Primitive>
126        </Provider>
127    }
128}
129
130#[component]
131pub fn ToggleGroupItem(
132    #[prop(into)] value: String,
133    #[prop(into, optional)] disabled: MaybeProp<bool>,
134    #[prop(into, optional)] as_child: MaybeProp<bool>,
135    #[prop(into, optional)] node_ref: AnyNodeRef,
136    children: TypedChildrenFn<impl IntoView + 'static>,
137) -> impl IntoView {
138    let children = StoredValue::new(children.into_inner());
139    let context = expect_context::<ToggleGroupContextValue>();
140    let item_value = value.clone();
141    let item_value_click = value.clone();
142
143    let disabled =
144        Signal::derive(move || context.disabled.get() || disabled.get().unwrap_or(false));
145    let is_pressed = Signal::derive(move || context.value.get().contains(&item_value));
146
147    view! {
148        <Primitive
149            element=html::button
150            as_child=as_child
151            node_ref=node_ref
152            attr:r#type="button"
153            attr:role=match context.group_type {
154                ToggleGroupType::Single => "radio",
155                ToggleGroupType::Multiple => "button",
156            }
157            attr:aria-pressed=move || match context.group_type {
158                ToggleGroupType::Single => None,
159                ToggleGroupType::Multiple => Some(is_pressed.get().to_string()),
160            }
161            attr:aria-checked=move || match context.group_type {
162                ToggleGroupType::Single => Some(is_pressed.get().to_string()),
163                ToggleGroupType::Multiple => None,
164            }
165            attr:data-state=move || if is_pressed.get() { "on" } else { "off" }
166            attr:data-orientation=move || context.orientation.get()
167            attr:data-disabled=move || disabled.get().then_some("")
168            attr:disabled=move || disabled.get().then_some("")
169            attr:tabindex=move || {
170                if is_pressed.get() { "0" } else { "-1" }
171            }
172            on:click=move |_| {
173                if !disabled.get() {
174                    context.on_item_activate.run(item_value_click.clone());
175                }
176            }
177        >
178            {children.with_value(|children| children())}
179        </Primitive>
180    }
181}
182
183/// Helper: move focus between sibling toggle-group items using DOM queries.
184fn roving_focus_items(event: &KeyboardEvent, forward: bool, do_loop: bool) {
185    let target = event.current_target();
186    let Some(group) = target.and_then(|t| {
187        use web_sys::wasm_bindgen::JsCast;
188        t.dyn_into::<web_sys::Element>().ok()
189    }) else {
190        return;
191    };
192
193    let Ok(items) = group.query_selector_all("button:not([disabled])") else {
194        return;
195    };
196
197    let mut nodes = vec![];
198    for i in 0..items.length() {
199        if let Some(node) = items.item(i) {
200            nodes.push(node);
201        }
202    }
203
204    let active = web_sys::window()
205        .and_then(|w| w.document())
206        .and_then(|d| d.active_element());
207
208    let current_index = active
209        .as_ref()
210        .and_then(|a| {
211            use web_sys::wasm_bindgen::JsCast;
212            let a_node: &web_sys::Node = a.unchecked_ref();
213            nodes.iter().position(|n| n == a_node)
214        })
215        .unwrap_or(0);
216
217    let next_index = if forward {
218        if current_index + 1 < nodes.len() {
219            Some(current_index + 1)
220        } else if do_loop {
221            Some(0)
222        } else {
223            None
224        }
225    } else if current_index > 0 {
226        Some(current_index - 1)
227    } else if do_loop {
228        Some(nodes.len().saturating_sub(1))
229    } else {
230        None
231    };
232
233    if let Some(idx) = next_index
234        && let Some(node) = nodes.get(idx)
235    {
236        use web_sys::wasm_bindgen::JsCast;
237        if let Ok(el) = node.clone().dyn_into::<web_sys::HtmlElement>() {
238            let _ = el.focus();
239        }
240    }
241}