adui_dioxus/components/
space.rs

1use super::layout_utils::{GapPreset, compose_gap_style, push_gap_preset_class};
2use dioxus::core::DynamicNode;
3use dioxus::prelude::*;
4
5/// Layout direction for spaced items.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum SpaceDirection {
8    #[default]
9    Horizontal,
10    Vertical,
11}
12
13/// Cross-axis alignment strategy for space items.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum SpaceAlign {
16    #[default]
17    Start,
18    End,
19    Center,
20    Baseline,
21}
22
23/// Preset sizes that map to theme spacing tokens.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum SpaceSize {
26    Small,
27    #[default]
28    Middle,
29    Large,
30}
31
32impl From<SpaceSize> for GapPreset {
33    fn from(value: SpaceSize) -> Self {
34        match value {
35            SpaceSize::Small => GapPreset::Small,
36            SpaceSize::Middle => GapPreset::Middle,
37            SpaceSize::Large => GapPreset::Large,
38        }
39    }
40}
41
42/// Props for the spacing wrapper.
43#[derive(Props, Clone, PartialEq)]
44pub struct SpaceProps {
45    #[props(default)]
46    pub direction: SpaceDirection,
47    #[props(default)]
48    pub size: SpaceSize,
49    #[props(optional)]
50    pub gap: Option<f32>,
51    #[props(optional)]
52    pub gap_cross: Option<f32>,
53    #[props(optional)]
54    pub wrap: Option<bool>,
55    #[props(default)]
56    pub align: SpaceAlign,
57    #[props(default)]
58    pub compact: bool,
59    #[props(optional)]
60    pub split: Option<Element>,
61    #[props(optional)]
62    pub class: Option<String>,
63    #[props(optional)]
64    pub style: Option<String>,
65    pub children: Element,
66}
67
68/// Flex-based spacing wrapper with optional split separators.
69/// For custom split content, prefer passing children from an iterator or fragment so they can be interleaved.
70#[component]
71pub fn Space(props: SpaceProps) -> Element {
72    let SpaceProps {
73        direction,
74        size,
75        gap,
76        gap_cross,
77        wrap,
78        align,
79        compact,
80        split,
81        class,
82        style,
83        children,
84    } = props;
85
86    let nodes = collect_children(children)?;
87
88    let should_wrap = wrap.unwrap_or(matches!(direction, SpaceDirection::Horizontal));
89
90    let mut class_list = vec!["adui-space".to_string()];
91    class_list.push(match direction {
92        SpaceDirection::Horizontal => "adui-space-horizontal".into(),
93        SpaceDirection::Vertical => "adui-space-vertical".into(),
94    });
95    if should_wrap && matches!(direction, SpaceDirection::Horizontal) {
96        class_list.push("adui-space-wrap".into());
97    }
98    class_list.push(match align {
99        SpaceAlign::Start => "adui-space-align-start".into(),
100        SpaceAlign::End => "adui-space-align-end".into(),
101        SpaceAlign::Center => "adui-space-align-center".into(),
102        SpaceAlign::Baseline => "adui-space-align-baseline".into(),
103    });
104    if compact {
105        class_list.push("adui-space-compact".into());
106    } else if gap.is_none() && gap_cross.is_none() {
107        push_gap_preset_class(&mut class_list, "adui-space-size", Some(size.into()));
108    }
109    if let Some(extra) = class.as_ref() {
110        class_list.push(extra.clone());
111    }
112    let class_attr = class_list.join(" ");
113    let row_gap_override = if matches!(direction, SpaceDirection::Horizontal) {
114        gap_cross
115    } else {
116        None
117    };
118    let column_gap_override = if matches!(direction, SpaceDirection::Vertical) {
119        gap_cross
120    } else {
121        None
122    };
123    let style_attr = compose_gap_style(style, gap, row_gap_override, column_gap_override);
124
125    if let Some(separator) = split {
126        let sep_node: VNode = separator?;
127        let total = nodes.len();
128        return rsx! {
129            div {
130                class: "{class_attr}",
131                style: "{style_attr}",
132                {
133                    nodes
134                        .into_iter()
135                        .enumerate()
136                        .flat_map(move |(idx, child)| {
137                            let mut group = vec![child];
138                            if idx + 1 != total {
139                                group.push(sep_node.clone());
140                            }
141                            group
142                        })
143                        .map(|node| rsx!({node}))
144                }
145            }
146        };
147    }
148
149    rsx! {
150        div {
151            class: "{class_attr}",
152            style: "{style_attr}",
153            {nodes.into_iter().map(|node| rsx!({node}))}
154        }
155    }
156}
157
158fn collect_children(children: Element) -> Result<Vec<VNode>, RenderError> {
159    let vnode = children?;
160    if let Some(fragment) = vnode.template.roots.iter().find_map(|root| {
161        root.dynamic_id()
162            .and_then(|id| match &vnode.dynamic_nodes[id] {
163                DynamicNode::Fragment(list) => Some(list.clone()),
164                _ => None,
165            })
166    }) {
167        return Ok(fragment);
168    }
169
170    Ok(vec![vnode])
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn collect_children_handles_static_roots_with_fallback() {
179        let nodes = collect_children(rsx! {
180            div { "one" }
181            div { "two" }
182        })
183        .unwrap();
184        assert_eq!(nodes.len(), 1);
185    }
186
187    #[test]
188    fn collect_children_handles_single_node() {
189        let nodes = collect_children(rsx!(div { "one" })).unwrap();
190        assert_eq!(nodes.len(), 1);
191    }
192
193    #[test]
194    fn collect_children_extracts_dynamic_fragment() {
195        let nodes = collect_children(rsx! {
196            {(0..2).map(|idx| rsx!(div { "{idx}" }))}
197        })
198        .unwrap();
199        assert_eq!(nodes.len(), 2);
200    }
201
202    #[test]
203    fn space_direction_default() {
204        assert_eq!(SpaceDirection::default(), SpaceDirection::Horizontal);
205    }
206
207    #[test]
208    fn space_direction_variants() {
209        assert_eq!(SpaceDirection::Horizontal, SpaceDirection::Horizontal);
210        assert_eq!(SpaceDirection::Vertical, SpaceDirection::Vertical);
211        assert_ne!(SpaceDirection::Horizontal, SpaceDirection::Vertical);
212    }
213
214    #[test]
215    fn space_direction_clone_and_copy() {
216        let dir1 = SpaceDirection::Horizontal;
217        let dir2 = dir1; // Copy
218        assert_eq!(dir1, dir2);
219    }
220
221    #[test]
222    fn space_direction_debug() {
223        let debug_str = format!("{:?}", SpaceDirection::Horizontal);
224        assert!(debug_str.contains("Horizontal"));
225
226        let debug_str2 = format!("{:?}", SpaceDirection::Vertical);
227        assert!(debug_str2.contains("Vertical"));
228    }
229
230    #[test]
231    fn space_align_default() {
232        assert_eq!(SpaceAlign::default(), SpaceAlign::Start);
233    }
234
235    #[test]
236    fn space_align_variants() {
237        assert_eq!(SpaceAlign::Start, SpaceAlign::Start);
238        assert_eq!(SpaceAlign::End, SpaceAlign::End);
239        assert_eq!(SpaceAlign::Center, SpaceAlign::Center);
240        assert_eq!(SpaceAlign::Baseline, SpaceAlign::Baseline);
241        assert_ne!(SpaceAlign::Start, SpaceAlign::End);
242        assert_ne!(SpaceAlign::Start, SpaceAlign::Center);
243        assert_ne!(SpaceAlign::Start, SpaceAlign::Baseline);
244    }
245
246    #[test]
247    fn space_align_clone_and_copy() {
248        let align1 = SpaceAlign::Center;
249        let align2 = align1; // Copy
250        assert_eq!(align1, align2);
251    }
252
253    #[test]
254    fn space_align_debug() {
255        let debug_str = format!("{:?}", SpaceAlign::Start);
256        assert!(debug_str.contains("Start"));
257
258        let debug_str2 = format!("{:?}", SpaceAlign::Baseline);
259        assert!(debug_str2.contains("Baseline"));
260    }
261
262    #[test]
263    fn space_size_default() {
264        assert_eq!(SpaceSize::default(), SpaceSize::Middle);
265    }
266
267    #[test]
268    fn space_size_variants() {
269        assert_eq!(SpaceSize::Small, SpaceSize::Small);
270        assert_eq!(SpaceSize::Middle, SpaceSize::Middle);
271        assert_eq!(SpaceSize::Large, SpaceSize::Large);
272        assert_ne!(SpaceSize::Small, SpaceSize::Middle);
273        assert_ne!(SpaceSize::Middle, SpaceSize::Large);
274        assert_ne!(SpaceSize::Small, SpaceSize::Large);
275    }
276
277    #[test]
278    fn space_size_clone_and_copy() {
279        let size1 = SpaceSize::Large;
280        let size2 = size1; // Copy
281        assert_eq!(size1, size2);
282    }
283
284    #[test]
285    fn space_size_debug() {
286        let debug_str = format!("{:?}", SpaceSize::Small);
287        assert!(debug_str.contains("Small"));
288
289        let debug_str2 = format!("{:?}", SpaceSize::Large);
290        assert!(debug_str2.contains("Large"));
291    }
292
293    #[test]
294    fn space_size_to_gap_preset_conversion() {
295        // Test conversion works without panicking
296        // Note: GapPreset doesn't implement PartialEq, so we test the conversion exists
297        let _gap1: GapPreset = SpaceSize::Small.into();
298        let _gap2: GapPreset = SpaceSize::Middle.into();
299        let _gap3: GapPreset = SpaceSize::Large.into();
300        // Conversion succeeds
301        assert!(true);
302    }
303
304    #[test]
305    fn space_size_into_gap_preset_all_variants() {
306        // Test all SpaceSize variants can be converted to GapPreset
307        let _gap1: GapPreset = SpaceSize::Small.into();
308        let _gap2: GapPreset = SpaceSize::Middle.into();
309        let _gap3: GapPreset = SpaceSize::Large.into();
310        // All conversions succeed
311        assert!(true);
312    }
313
314    #[test]
315    fn space_props_defaults() {
316        // Test that default values are correct
317        // Note: SpaceProps requires children, so we can't create a full instance
318        // But we can verify the default values used in the component
319        let direction_default = SpaceDirection::default();
320        assert_eq!(direction_default, SpaceDirection::Horizontal);
321
322        let size_default = SpaceSize::default();
323        assert_eq!(size_default, SpaceSize::Middle);
324
325        let align_default = SpaceAlign::default();
326        assert_eq!(align_default, SpaceAlign::Start);
327
328        let compact_default = false;
329        assert_eq!(compact_default, false);
330    }
331
332    #[test]
333    fn space_props_optional_fields() {
334        // Test that optional fields can be None
335        let _gap: Option<f32> = None;
336        let _gap_cross: Option<f32> = None;
337        let _wrap: Option<bool> = None;
338        let _split: Option<Element> = None;
339        let _class: Option<String> = None;
340        let _style: Option<String> = None;
341        // All optional fields can be None
342        assert!(true);
343    }
344}