adui_dioxus/components/
grid.rs

1use dioxus::prelude::*;
2use std::fmt::Write;
3use std::sync::atomic::{AtomicUsize, Ordering};
4
5static COL_ID: AtomicUsize = AtomicUsize::new(0);
6static ROW_ID: AtomicUsize = AtomicUsize::new(0);
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub enum GridBreakpoint {
10    Xs,
11    Sm,
12    Md,
13    Lg,
14    Xl,
15    Xxl,
16}
17
18const BREAKPOINT_RULES: &[(GridBreakpoint, u32)] = &[
19    (GridBreakpoint::Xs, 0),
20    (GridBreakpoint::Sm, 576),
21    (GridBreakpoint::Md, 768),
22    (GridBreakpoint::Lg, 992),
23    (GridBreakpoint::Xl, 1200),
24    (GridBreakpoint::Xxl, 1600),
25];
26
27#[derive(Clone, Debug, Default, PartialEq)]
28pub struct ResponsiveValue {
29    pub xs: Option<f32>,
30    pub sm: Option<f32>,
31    pub md: Option<f32>,
32    pub lg: Option<f32>,
33    pub xl: Option<f32>,
34    pub xxl: Option<f32>,
35}
36
37impl ResponsiveValue {
38    fn iter(&self) -> Vec<(GridBreakpoint, f32)> {
39        let mut entries = Vec::new();
40        if let Some(v) = self.xs {
41            entries.push((GridBreakpoint::Xs, v));
42        }
43        if let Some(v) = self.sm {
44            entries.push((GridBreakpoint::Sm, v));
45        }
46        if let Some(v) = self.md {
47            entries.push((GridBreakpoint::Md, v));
48        }
49        if let Some(v) = self.lg {
50            entries.push((GridBreakpoint::Lg, v));
51        }
52        if let Some(v) = self.xl {
53            entries.push((GridBreakpoint::Xl, v));
54        }
55        if let Some(v) = self.xxl {
56            entries.push((GridBreakpoint::Xxl, v));
57        }
58        entries
59    }
60}
61
62#[derive(Clone, Debug, Default, PartialEq)]
63pub struct ResponsiveGutter {
64    pub horizontal: ResponsiveValue,
65    pub vertical: Option<ResponsiveValue>,
66}
67
68#[derive(Clone, Debug, PartialEq)]
69pub enum RowGutter {
70    Uniform(f32),
71    Pair(f32, f32),
72    Responsive(ResponsiveGutter),
73}
74
75/// Horizontal justification for a grid row.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub enum RowJustify {
78    #[default]
79    Start,
80    End,
81    Center,
82    SpaceAround,
83    SpaceBetween,
84    SpaceEvenly,
85}
86
87/// Cross-axis alignment for row items.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
89pub enum RowAlign {
90    #[default]
91    Top,
92    Middle,
93    Bottom,
94    Stretch,
95}
96
97/// Layout props for a row container.
98#[derive(Props, Clone, PartialEq)]
99pub struct RowProps {
100    #[props(optional)]
101    pub gutter: Option<f32>,
102    #[props(optional)]
103    pub gutter_vertical: Option<f32>,
104    #[props(optional)]
105    pub responsive_gutter: Option<ResponsiveGutter>,
106    #[props(optional)]
107    pub gutter_spec: Option<RowGutter>,
108    #[props(default)]
109    pub justify: RowJustify,
110    #[props(default)]
111    pub align: RowAlign,
112    #[props(optional)]
113    pub class: Option<String>,
114    #[props(optional)]
115    pub style: Option<String>,
116    pub children: Element,
117}
118
119/// Flex-based grid row with 24-column gutter system.
120#[component]
121pub fn Row(props: RowProps) -> Element {
122    let RowProps {
123        gutter,
124        gutter_vertical,
125        responsive_gutter,
126        gutter_spec,
127        justify,
128        align,
129        class,
130        style,
131        children,
132    } = props;
133
134    let row_id = ROW_ID.fetch_add(1, Ordering::Relaxed);
135    let mut class_list = vec!["adui-row".to_string()];
136    class_list.push(format!("adui-row-{row_id}"));
137    if let Some(extra) = class.as_ref() {
138        class_list.push(extra.clone());
139    }
140    let class_attr = class_list.join(" ");
141    let mut base_x = gutter.unwrap_or(0.0);
142    let mut base_y = gutter_vertical.unwrap_or(0.0);
143    let mut responsive_cfg = responsive_gutter.clone();
144
145    if let Some(spec) = gutter_spec {
146        match spec {
147            RowGutter::Uniform(v) => {
148                base_x = v;
149                base_y = 0.0;
150                responsive_cfg = None;
151            }
152            RowGutter::Pair(h, v) => {
153                base_x = h;
154                base_y = v;
155                responsive_cfg = None;
156            }
157            RowGutter::Responsive(cfg) => {
158                responsive_cfg = Some(cfg);
159            }
160        }
161    }
162    let mut style_buffer = String::new();
163    if let Some(extra) = style.as_ref() {
164        style_buffer.push_str(extra);
165    }
166    let style_attr = format!(
167        "display:flex;flex-wrap:wrap;margin-left:calc(var(--adui-row-gutter-x,{base_x}px)/-2);margin-right:calc(var(--adui-row-gutter-x,{base_x}px)/-2);row-gap:var(--adui-row-gutter-y,{base_y}px);column-gap:var(--adui-row-gutter-x,{base_x}px);justify-content:{};align-items:{};{}",
168        match justify {
169            RowJustify::Start => "flex-start",
170            RowJustify::End => "flex-end",
171            RowJustify::Center => "center",
172            RowJustify::SpaceAround => "space-around",
173            RowJustify::SpaceBetween => "space-between",
174            RowJustify::SpaceEvenly => "space-evenly",
175        },
176        match align {
177            RowAlign::Top => "flex-start",
178            RowAlign::Middle => "center",
179            RowAlign::Bottom => "flex-end",
180            RowAlign::Stretch => "stretch",
181        },
182        style_buffer
183    );
184    let responsive_rules = responsive_row_rules(row_id, base_x, base_y, responsive_cfg.as_ref());
185
186    rsx! {
187        div {
188            class: "{class_attr}",
189            style: "{style_attr}",
190            if let Some(rules) = responsive_rules {
191                style { {rules} }
192            }
193            {children}
194        }
195    }
196}
197
198/// Column sizing options within a row.
199#[derive(Clone, Debug, Default, PartialEq)]
200pub struct ColSize {
201    pub span: Option<u16>,
202    pub offset: Option<u16>,
203    pub push: Option<i16>,
204    pub pull: Option<i16>,
205    pub order: Option<i16>,
206    pub flex: Option<String>,
207}
208
209#[derive(Clone, Debug, Default, PartialEq)]
210pub struct ColResponsive {
211    pub xs: Option<ColSize>,
212    pub sm: Option<ColSize>,
213    pub md: Option<ColSize>,
214    pub lg: Option<ColSize>,
215    pub xl: Option<ColSize>,
216    pub xxl: Option<ColSize>,
217}
218
219impl ColSize {
220    pub fn is_empty(&self) -> bool {
221        self.span.is_none()
222            && self.offset.is_none()
223            && self.push.is_none()
224            && self.pull.is_none()
225            && self.order.is_none()
226            && self.flex.is_none()
227    }
228}
229
230#[derive(Props, Clone, PartialEq)]
231pub struct ColProps {
232    #[props(default = 24)]
233    pub span: u16,
234    #[props(default)]
235    pub offset: u16,
236    #[props(optional)]
237    pub push: Option<i16>,
238    #[props(optional)]
239    pub pull: Option<i16>,
240    #[props(optional)]
241    pub order: Option<i16>,
242    #[props(optional)]
243    pub flex: Option<String>,
244    #[props(optional)]
245    pub responsive: Option<ColResponsive>,
246    #[props(optional)]
247    pub class: Option<String>,
248    #[props(optional)]
249    pub style: Option<String>,
250    pub children: Element,
251}
252
253/// Grid column in a 24-part system with optional flex sizing.
254#[component]
255pub fn Col(props: ColProps) -> Element {
256    let ColProps {
257        span,
258        offset,
259        push,
260        pull,
261        order,
262        flex,
263        class,
264        style,
265        children,
266        responsive,
267    } = props;
268
269    let mut class_list = vec!["adui-col".to_string()];
270    let id = COL_ID.fetch_add(1, Ordering::Relaxed);
271    class_list.push(format!("adui-col-{id}"));
272    if let Some(extra) = class.as_ref() {
273        class_list.push(extra.clone());
274    }
275    let class_attr = class_list.join(" ");
276
277    let width_percent = (span as f32 / 24.0) * 100.0;
278    let offset_percent = (offset as f32 / 24.0) * 100.0;
279
280    let mut style_buf = String::new();
281    if let Some(flex_val) = flex {
282        let _ = write!(style_buf, "flex:{flex_val};max-width:100%;");
283    } else {
284        let _ = write!(
285            style_buf,
286            "flex:0 0 {width_percent}%;max-width:{width_percent}%;"
287        );
288    }
289    if offset > 0 {
290        let _ = write!(style_buf, "margin-left:{offset_percent}%;");
291    }
292    if let Some(val) = push {
293        let shift = column_percent(val);
294        let _ = write!(style_buf, "position:relative;left:{shift}%;");
295    }
296    if let Some(val) = pull {
297        let shift = column_percent(val);
298        let _ = write!(style_buf, "position:relative;right:{shift}%;");
299    }
300    if let Some(ord) = order {
301        let _ = write!(style_buf, "order:{ord};");
302    }
303    let _ = write!(
304        style_buf,
305        "padding:0 calc(var(--adui-row-gutter-x, 0px)/2);padding-bottom:var(--adui-row-gutter-y, 0px);box-sizing:border-box;{}",
306        style.unwrap_or_default()
307    );
308    let style_attr = style_buf;
309
310    let responsive_rules = responsive_col_rules(id, responsive.as_ref());
311
312    rsx! {
313        div {
314            class: "{class_attr}",
315            style: "{style_attr}",
316            if let Some(rules) = responsive_rules {
317                style { {rules} }
318            }
319            {children}
320        }
321    }
322}
323
324fn append_responsive_axis(
325    buffer: &mut String,
326    row_id: usize,
327    axis: &str,
328    base: f32,
329    values: &ResponsiveValue,
330) {
331    let _ = writeln!(
332        buffer,
333        ".adui-row-{row_id} {{ --adui-row-gutter-{axis}:{base}px; }}"
334    );
335    for (bp, value) in values.iter() {
336        let rule = if let Some((_, min_width)) = BREAKPOINT_RULES
337            .iter()
338            .find(|(breakpoint, _)| *breakpoint == bp)
339        {
340            if *min_width == 0 {
341                format!(".adui-row-{row_id} {{ --adui-row-gutter-{axis}:{value}px; }}")
342            } else {
343                format!(
344                    "@media (min-width: {min_width}px) {{ .adui-row-{row_id} {{ --adui-row-gutter-{axis}:{value}px; }} }}"
345                )
346            }
347        } else {
348            format!(".adui-row-{row_id} {{ --adui-row-gutter-{axis}:{value}px; }}")
349        };
350        let _ = writeln!(buffer, "{rule}");
351    }
352}
353
354fn responsive_row_rules(
355    row_id: usize,
356    base_x: f32,
357    base_y: f32,
358    responsive: Option<&ResponsiveGutter>,
359) -> Option<String> {
360    let responsive = responsive?;
361    let mut buffer = String::new();
362
363    append_responsive_axis(&mut buffer, row_id, "x", base_x, &responsive.horizontal);
364    if let Some(vertical) = responsive.vertical.as_ref() {
365        append_responsive_axis(&mut buffer, row_id, "y", base_y, vertical);
366    } else {
367        let _ = writeln!(
368            buffer,
369            ".adui-row-{row_id} {{ --adui-row-gutter-y:{base_y}px; }}"
370        );
371    }
372
373    Some(buffer)
374}
375
376fn responsive_col_rules(id: usize, responsive: Option<&ColResponsive>) -> Option<String> {
377    let responsive = responsive?;
378    let mut buffer = String::new();
379
380    for (bp, min_width) in BREAKPOINT_RULES {
381        let size = match bp {
382            GridBreakpoint::Xs => responsive.xs.as_ref(),
383            GridBreakpoint::Sm => responsive.sm.as_ref(),
384            GridBreakpoint::Md => responsive.md.as_ref(),
385            GridBreakpoint::Lg => responsive.lg.as_ref(),
386            GridBreakpoint::Xl => responsive.xl.as_ref(),
387            GridBreakpoint::Xxl => responsive.xxl.as_ref(),
388        };
389        if let Some(size) = size {
390            if size.is_empty() {
391                continue;
392            }
393            let mut declarations = String::new();
394            if let Some(span) = size.span {
395                let pct = (span as f32 / 24.0) * 100.0;
396                let _ = write!(declarations, "flex:0 0 {pct}%;max-width:{pct}%;");
397            }
398            if let Some(offset) = size.offset {
399                let pct = (offset as f32 / 24.0) * 100.0;
400                let _ = write!(declarations, "margin-left:{pct}%;");
401            }
402            if let Some(push) = size.push {
403                let shift = column_percent(push);
404                let _ = write!(declarations, "position:relative;left:{shift}%;");
405            }
406            if let Some(pull) = size.pull {
407                let shift = column_percent(pull);
408                let _ = write!(declarations, "position:relative;right:{shift}%;");
409            }
410            if let Some(order) = size.order {
411                let _ = write!(declarations, "order:{order};");
412            }
413            if let Some(flex_val) = size.flex.as_ref() {
414                let _ = write!(declarations, "flex:{flex_val};max-width:100%;");
415            }
416
417            if declarations.is_empty() {
418                continue;
419            }
420
421            if *min_width == 0 {
422                let _ = write!(buffer, ".adui-col-{id} {{{declarations}}}");
423            } else {
424                let _ = write!(
425                    buffer,
426                    "@media (min-width: {min_width}px) {{ .adui-col-{id} {{{declarations}}} }}"
427                );
428            }
429        }
430    }
431
432    if buffer.is_empty() {
433        None
434    } else {
435        Some(buffer)
436    }
437}
438
439fn column_percent(value: i16) -> f32 {
440    (value as f32 / 24.0) * 100.0
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn responsive_row_rules_emits_media_queries() {
449        let horizontal = ResponsiveValue {
450            sm: Some(16.0),
451            xl: Some(24.0),
452            ..Default::default()
453        };
454        let vertical = ResponsiveValue {
455            xs: Some(8.0),
456            ..Default::default()
457        };
458        let responsive = ResponsiveGutter {
459            horizontal,
460            vertical: Some(vertical),
461        };
462        let rules = responsive_row_rules(1, 12.0, 4.0, Some(&responsive)).unwrap();
463        assert!(rules.contains("--adui-row-gutter-x:12"));
464        assert!(rules.contains("@media (min-width: 576px)"));
465        assert!(rules.contains("--adui-row-gutter-x:16"));
466        assert!(rules.contains("--adui-row-gutter-y:8"));
467    }
468
469    #[test]
470    fn responsive_col_rules_emits_breakpoints() {
471        let col = ColResponsive {
472            sm: Some(ColSize {
473                span: Some(12),
474                offset: Some(6),
475                ..Default::default()
476            }),
477            xl: Some(ColSize {
478                span: Some(8),
479                flex: Some("1 1 auto".into()),
480                ..Default::default()
481            }),
482            ..Default::default()
483        };
484        let rules = responsive_col_rules(7, Some(&col)).unwrap();
485        assert!(rules.contains("@media (min-width: 576px)"));
486        let offset_pct = format!("margin-left:{}%;", column_percent(6));
487        assert!(rules.contains(&offset_pct));
488        let span_pct = format!("flex:0 0 {}%;", column_percent(8));
489        assert!(rules.contains(&span_pct));
490        assert!(rules.contains("flex:1 1 auto"));
491    }
492
493    #[test]
494    fn row_component_renders_expected_class() {
495        let vnode = Row(RowProps {
496            gutter: Some(24.0),
497            gutter_vertical: None,
498            responsive_gutter: None,
499            gutter_spec: None,
500            justify: RowJustify::Start,
501            align: RowAlign::Top,
502            class: None,
503            style: None,
504            children: rsx! {
505                Col { span: 12, "Left" }
506                Col { span: 12, "Right" }
507            },
508        })
509        .expect("node");
510        let debug = format!("{vnode:?}");
511        assert!(debug.contains("adui-row"));
512    }
513}