leptix_toggle_group/
toggle_group.rs1use 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
183fn 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}