1use dioxus::prelude::*;
2use web_sys::wasm_bindgen::JsCast;
3
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5pub enum SplitterOrientation {
6 #[default]
7 Horizontal,
8 Vertical,
9}
10
11#[derive(Props, Clone, PartialEq)]
13pub struct SplitterProps {
14 #[props(default)]
15 pub orientation: SplitterOrientation,
16 #[props(optional)]
17 pub split: Option<f32>,
18 #[props(default = 0.5)]
19 pub default_split: f32,
20 #[props(optional)]
21 pub on_change: Option<EventHandler<f32>>,
22 #[props(optional)]
23 pub on_moving: Option<EventHandler<f32>>,
24 #[props(optional)]
25 pub on_release: Option<EventHandler<f32>>,
26 #[props(optional)]
27 pub min_primary: Option<f32>,
28 #[props(optional)]
29 pub min_secondary: Option<f32>,
30 #[props(optional)]
31 pub class: Option<String>,
32 #[props(optional)]
33 pub style: Option<String>,
34 #[props(optional)]
35 pub gutter_aria_label: Option<String>,
36 pub first: Element,
37 pub second: Element,
38}
39
40#[component]
42pub fn Splitter(props: SplitterProps) -> Element {
43 let SplitterProps {
44 orientation,
45 split,
46 default_split,
47 on_change,
48 on_moving,
49 on_release,
50 min_primary,
51 min_secondary,
52 class,
53 style,
54 gutter_aria_label,
55 first,
56 second,
57 } = props;
58
59 let initial = split.unwrap_or(default_split).clamp(0.05, 0.95);
60 let mut ratio = use_signal(|| initial);
61 if let Some(controlled) = split {
62 ratio.set(controlled.clamp(0.05, 0.95));
63 }
64 let dragging = use_signal(|| false);
65 let active_pointer = use_signal(|| None::<i32>);
66
67 let min_primary = min_primary.unwrap_or(80.0);
68 let min_secondary = min_secondary.unwrap_or(80.0);
69
70 let orientation_class = match orientation {
71 SplitterOrientation::Horizontal => "adui-splitter-horizontal",
72 SplitterOrientation::Vertical => "adui-splitter-vertical",
73 };
74 let class_attr = format!(
75 "adui-splitter {orientation_class} {}",
76 class.unwrap_or_default()
77 );
78 let style_attr = format!(
79 "display:flex;flex-direction:{};gap:8px;{}",
80 match orientation {
81 SplitterOrientation::Horizontal => "row",
82 SplitterOrientation::Vertical => "column",
83 },
84 style.unwrap_or_default()
85 );
86
87 let mut set_ratio_with_constraints = {
88 let mut ratio_signal = ratio;
89 move |next: f32, size: f64| {
90 let primary_px = (next as f64) * size;
91 let secondary_px = (1.0 - next as f64) * size;
92 let mut adjusted = next;
93 if primary_px < min_primary as f64 {
94 adjusted = (min_primary as f64 / size) as f32;
95 }
96 if secondary_px < min_secondary as f64 {
97 adjusted = 1.0 - (min_secondary as f64 / size) as f32;
98 }
99 ratio_signal.set(adjusted.clamp(0.05, 0.95));
100 adjusted
101 }
102 };
103
104 let handle_move = {
105 let mut ratio_signal = ratio;
106 let dragging_signal = dragging;
107 let on_change_cb = on_change;
108 let on_moving_cb = on_moving;
109 move |evt: Event<PointerData>| {
110 if !*dragging_signal.read() {
111 return;
112 }
113 let binding = evt.data();
114 let Some(web_evt) = binding.downcast::<web_sys::PointerEvent>() else {
115 return;
116 };
117 let Some(el) = web_evt
118 .current_target()
119 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
120 else {
121 return;
122 };
123 let rect = el.get_bounding_client_rect();
124 let (size, origin) = match orientation {
125 SplitterOrientation::Horizontal => (rect.width(), rect.x()),
126 SplitterOrientation::Vertical => (rect.height(), rect.y()),
127 };
128 if size <= 0.0 {
129 return;
130 }
131 let pointer_pos = match orientation {
132 SplitterOrientation::Horizontal => web_evt.client_x() as f64,
133 SplitterOrientation::Vertical => web_evt.client_y() as f64,
134 };
135 let mut next = ((pointer_pos - origin) / size).clamp(0.05, 0.95) as f32;
136 next = set_ratio_with_constraints(next, size);
137 ratio_signal.set(next);
138 if let Some(cb) = on_moving_cb.as_ref() {
139 cb.call(next);
140 }
141 if let Some(cb) = on_change_cb.as_ref() {
142 cb.call(next);
143 }
144 }
145 };
146
147 let handle_pointer_down = {
148 let mut dragging_signal = dragging;
149 let mut active_pointer = active_pointer;
150 move |evt: Event<PointerData>| {
151 if let Some(web_evt) = evt.data().downcast::<web_sys::PointerEvent>() {
152 active_pointer.set(Some(web_evt.pointer_id()));
153 dragging_signal.set(true);
154 if let Some(target) = web_evt
155 .target()
156 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
157 {
158 let _ = target.set_pointer_capture(web_evt.pointer_id());
159 }
160 }
161 }
162 };
163
164 let handle_pointer_up = {
165 let mut dragging_signal = dragging;
166 let mut active_pointer = active_pointer;
167 let on_release_cb = on_release;
168 move |evt: Event<PointerData>| {
169 if let Some(web_evt) = evt.data().downcast::<web_sys::PointerEvent>()
170 && Some(web_evt.pointer_id()) == *active_pointer.read()
171 {
172 active_pointer.set(None);
173 dragging_signal.set(false);
174 if let Some(cb) = on_release_cb.as_ref() {
175 cb.call(*ratio.read());
176 }
177 if let Some(target) = web_evt
178 .target()
179 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
180 {
181 let _ = target.release_pointer_capture(web_evt.pointer_id());
182 }
183 }
184 }
185 };
186
187 let pane_primary_style = format!(
188 "flex:0 0 {pct}%;min-{}:{}px;",
189 match orientation {
190 SplitterOrientation::Horizontal => "width",
191 SplitterOrientation::Vertical => "height",
192 },
193 min_primary,
194 pct = (*ratio.read() * 100.0)
195 );
196 let pane_secondary_style = format!(
197 "flex:1 1 auto;min-{}:{}px;",
198 match orientation {
199 SplitterOrientation::Horizontal => "width",
200 SplitterOrientation::Vertical => "height",
201 },
202 min_secondary
203 );
204
205 rsx! {
206 div {
207 class: "{class_attr}",
208 style: "{style_attr}",
209 onpointermove: handle_move,
210 onpointerup: handle_pointer_up,
211 onpointerleave: handle_pointer_up,
212 div {
213 class: "adui-splitter-pane",
214 style: "{pane_primary_style}",
215 {first}
216 }
217 div {
218 class: "adui-splitter-gutter",
219 role: "separator",
220 tabindex: "0",
221 "aria-label": gutter_aria_label.unwrap_or_else(|| "Resize panels".into()),
222 onpointerdown: handle_pointer_down,
223 onpointerup: handle_pointer_up,
224 }
225 div {
226 class: "adui-splitter-pane",
227 style: "{pane_secondary_style}",
228 {second}
229 }
230 }
231 }
232}
233
234#[derive(Props, Clone, PartialEq)]
236pub struct SplitterPaneProps {
237 pub children: Element,
238}
239
240#[component]
241pub fn SplitterPane(props: SplitterPaneProps) -> Element {
242 rsx! { {props.children} }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn splitter_orientation_default() {
251 assert_eq!(
252 SplitterOrientation::default(),
253 SplitterOrientation::Horizontal
254 );
255 }
256
257 #[test]
258 fn splitter_orientation_all_variants() {
259 assert_eq!(
260 SplitterOrientation::Horizontal,
261 SplitterOrientation::Horizontal
262 );
263 assert_eq!(SplitterOrientation::Vertical, SplitterOrientation::Vertical);
264 assert_ne!(
265 SplitterOrientation::Horizontal,
266 SplitterOrientation::Vertical
267 );
268 }
269
270 #[test]
271 fn splitter_orientation_clone() {
272 let original = SplitterOrientation::Vertical;
273 let cloned = original;
274 assert_eq!(original, cloned);
275 }
276
277 #[test]
278 fn splitter_orientation_equality() {
279 assert_eq!(
280 SplitterOrientation::Horizontal,
281 SplitterOrientation::Horizontal
282 );
283 assert_eq!(SplitterOrientation::Vertical, SplitterOrientation::Vertical);
284 assert_ne!(
285 SplitterOrientation::Horizontal,
286 SplitterOrientation::Vertical
287 );
288 }
289
290 #[test]
291 fn splitter_orientation_debug() {
292 let horizontal = SplitterOrientation::Horizontal;
293 let vertical = SplitterOrientation::Vertical;
294 let debug_h = format!("{:?}", horizontal);
295 let debug_v = format!("{:?}", vertical);
296 assert!(debug_h.contains("Horizontal"));
297 assert!(debug_v.contains("Vertical"));
298 }
299
300 #[test]
301 fn splitter_pane_props_structure() {
302 fn assert_splitter_pane_props_structure() {
306 }
309 assert_splitter_pane_props_structure();
310 }
311
312 #[test]
313 fn splitter_props_defaults() {
314 fn assert_splitter_props_defaults() {
318 }
325 assert_splitter_props_defaults();
326 }
327}