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#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
89pub enum RowAlign {
90 #[default]
91 Top,
92 Middle,
93 Bottom,
94 Stretch,
95}
96
97#[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#[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#[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#[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}