adui_dioxus/components/
layout.rs

1use crate::components::icon::{Icon, IconKind};
2use crate::theme::{ThemeTokens, use_theme};
3use dioxus::prelude::*;
4
5/// Shared layout props for container sections.
6#[derive(Props, Clone, PartialEq)]
7pub struct LayoutProps {
8    #[props(optional)]
9    pub class: Option<String>,
10    #[props(optional)]
11    pub style: Option<String>,
12    /// 标记该 Layout 是否包含 Sider,未设置则默认为 `false`。
13    #[props(optional)]
14    pub has_sider: Option<bool>,
15    pub children: Element,
16}
17
18/// Root layout container with optional sider awareness.
19#[component]
20pub fn Layout(props: LayoutProps) -> Element {
21    let LayoutProps {
22        class,
23        style,
24        has_sider,
25        children,
26    } = props;
27
28    let mut class_list = vec!["adui-layout".to_string()];
29    if has_sider.unwrap_or(false) {
30        class_list.push("adui-layout-has-sider".into());
31    }
32    if let Some(extra) = class {
33        class_list.push(extra);
34    }
35    let class_attr = class_list.join(" ");
36
37    rsx! {
38        div {
39            class: "{class_attr}",
40            style: style.unwrap_or_default(),
41            {children}
42        }
43    }
44}
45
46/// Top navigation/header area.
47#[component]
48pub fn Header(props: LayoutProps) -> Element {
49    let LayoutProps { class, style, .. } = props.clone();
50    let theme = use_theme();
51    let tokens = theme.tokens();
52    let class_attr = format!("adui-layout-header {}", class.unwrap_or_default());
53    let style_attr = format!(
54        "background:{};color:{};{}",
55        tokens.color_bg_container,
56        tokens.color_text,
57        style.unwrap_or_default()
58    );
59    rsx! {
60        header {
61            class: "{class_attr}",
62            style: "{style_attr}",
63            {props.children}
64        }
65    }
66}
67
68/// Main content area.
69#[component]
70pub fn Content(props: LayoutProps) -> Element {
71    let LayoutProps {
72        class,
73        style,
74        children,
75        ..
76    } = props;
77    let class_attr = format!("adui-layout-content {}", class.unwrap_or_default());
78    rsx! {
79        main {
80            class: "{class_attr}",
81            style: style.unwrap_or_default(),
82            {children}
83        }
84    }
85}
86
87/// Footer/extra information bar.
88#[component]
89pub fn Footer(props: LayoutProps) -> Element {
90    let LayoutProps {
91        class,
92        style,
93        children,
94        ..
95    } = props;
96    let theme = use_theme();
97    let tokens = theme.tokens();
98    let class_attr = format!("adui-layout-footer {}", class.unwrap_or_default());
99    let style_attr = format!(
100        "color:{};{}",
101        tokens.color_text_secondary,
102        style.unwrap_or_default()
103    );
104    rsx! {
105        footer {
106            class: "{class_attr}",
107            style: "{style_attr}",
108            {children}
109        }
110    }
111}
112
113/// Theme variants for the side navigation.
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
115pub enum SiderTheme {
116    Light,
117    #[default]
118    Dark,
119}
120
121/// Properties for the side navigation container.
122#[derive(Props, Clone, PartialEq)]
123pub struct SiderProps {
124    #[props(optional)]
125    pub width: Option<f32>,
126    #[props(optional)]
127    pub collapsed_width: Option<f32>,
128    #[props(optional)]
129    pub collapsed: Option<bool>,
130    #[props(default)]
131    pub default_collapsed: bool,
132    #[props(default)]
133    pub collapsible: bool,
134    #[props(default)]
135    pub reverse_arrow: bool,
136    #[props(optional)]
137    pub trigger: Option<Element>,
138    #[props(optional)]
139    pub zero_width_trigger_style: Option<String>,
140    #[props(default = SiderTheme::Dark)]
141    pub theme: SiderTheme,
142    #[props(default = true)]
143    pub has_border: bool,
144    #[props(optional)]
145    pub on_collapse: Option<EventHandler<bool>>,
146    #[props(optional)]
147    pub class: Option<String>,
148    #[props(optional)]
149    pub style: Option<String>,
150    pub children: Element,
151}
152
153/// Side navigation panel with optional collapse control.
154#[component]
155pub fn Sider(props: SiderProps) -> Element {
156    let SiderProps {
157        width,
158        collapsed_width,
159        collapsed,
160        default_collapsed,
161        collapsible,
162        reverse_arrow,
163        trigger,
164        zero_width_trigger_style,
165        theme,
166        has_border,
167        on_collapse,
168        class,
169        style,
170        children,
171    } = props;
172
173    let width_value = width.unwrap_or(200.0).max(0.0);
174    let collapsed_value = collapsed_width.unwrap_or(80.0).max(0.0);
175
176    let mut collapsed_state = use_signal(|| collapsed.unwrap_or(default_collapsed));
177    if let Some(external) = collapsed {
178        collapsed_state.set(external);
179    }
180
181    let theme_handle = use_theme();
182    let tokens = theme_handle.tokens();
183    let (bg_color, text_color) = sider_palette(&tokens, theme);
184    let border_color = if has_border {
185        format!("1px solid {}", tokens.color_border)
186    } else {
187        "none".into()
188    };
189
190    let is_collapsed = *collapsed_state.read();
191    let current_width = if is_collapsed {
192        collapsed_value
193    } else {
194        width_value
195    };
196    let width_str = format!("{}px", current_width);
197
198    let mut class_list = vec!["adui-layout-sider".to_string()];
199    class_list.push(match theme {
200        SiderTheme::Light => "adui-layout-sider-light".into(),
201        SiderTheme::Dark => "adui-layout-sider-dark".into(),
202    });
203    if is_collapsed {
204        class_list.push("adui-layout-sider-collapsed".into());
205    }
206    if collapsible {
207        class_list.push("adui-layout-sider-collapsible".into());
208    }
209    if collapsed_value == 0.0 {
210        class_list.push("adui-layout-sider-zero-width".into());
211    }
212    if let Some(extra) = class.as_ref() {
213        class_list.push(extra.clone());
214    }
215    let class_attr = class_list.join(" ");
216
217    let trigger_content = trigger
218        .clone()
219        .unwrap_or_else(|| default_trigger_icon(is_collapsed, reverse_arrow));
220
221    let mut toggle = {
222        let mut collapsed_signal = collapsed_state;
223        let collapsible_flag = collapsible;
224        let handler = on_collapse;
225        move || {
226            if !collapsible_flag {
227                return;
228            }
229            let next = !*collapsed_signal.read();
230            collapsed_signal.set(next);
231            if let Some(cb) = handler.as_ref() {
232                cb.call(next);
233            }
234        }
235    };
236
237    let zero_width_trigger = if collapsible && collapsed_value == 0.0 {
238        let trigger_style = zero_width_trigger_style.unwrap_or_default();
239        let trigger_icon = trigger_content.clone();
240        Some(rsx! {
241            span {
242                class: format_args!(
243                    "{} {}",
244                    "adui-layout-sider-zero-trigger",
245                    if reverse_arrow { "adui-layout-sider-zero-trigger-right" } else { "adui-layout-sider-zero-trigger-left" }
246                ),
247                style: trigger_style,
248                onclick: move |_| toggle(),
249                {trigger_icon}
250            }
251        })
252    } else {
253        None
254    };
255
256    let inline_trigger = if collapsible && collapsed_value > 0.0 {
257        let trigger_icon = trigger_content;
258        Some(rsx! {
259            div {
260                class: "adui-layout-sider-trigger",
261                style: format!("width:{width_str};"),
262                onclick: move |_| toggle(),
263                {trigger_icon}
264            }
265        })
266    } else {
267        None
268    };
269
270    let mut style_buffer = format!(
271        "flex:0 0 {w};max-width:{w};min-width:{w};width:{w};background:{};color:{};border-right:{};",
272        bg_color,
273        text_color,
274        border_color,
275        w = width_str
276    );
277    if let Some(extra) = style.as_ref() {
278        style_buffer.push_str(extra);
279    }
280
281    rsx! {
282        aside {
283            class: "{class_attr}",
284            style: "{style_buffer}",
285            role: "complementary",
286            "aria-expanded": (!is_collapsed).to_string(),
287            div {
288                class: "adui-layout-sider-children",
289                {children}
290            }
291            if let Some(trigger) = zero_width_trigger {
292                {trigger}
293            } else if let Some(trigger) = inline_trigger {
294                {trigger}
295            }
296        }
297    }
298}
299
300fn default_trigger_icon(collapsed: bool, reverse_arrow: bool) -> Element {
301    let should_point_right = if reverse_arrow { !collapsed } else { collapsed };
302    let icon_kind = if should_point_right {
303        IconKind::ArrowRight
304    } else {
305        IconKind::ArrowLeft
306    };
307    rsx!(Icon {
308        kind: icon_kind,
309        size: 16.0
310    })
311}
312
313fn sider_palette(tokens: &ThemeTokens, theme: SiderTheme) -> (String, String) {
314    match theme {
315        SiderTheme::Light => (tokens.color_bg_container.clone(), tokens.color_text.clone()),
316        SiderTheme::Dark => (tokens.color_bg_layout.clone(), "#fafafa".into()),
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn sider_theme_default() {
326        assert_eq!(SiderTheme::default(), SiderTheme::Dark);
327    }
328
329    #[test]
330    fn sider_theme_variants() {
331        assert_ne!(SiderTheme::Light, SiderTheme::Dark);
332    }
333
334    #[test]
335    fn sider_theme_equality() {
336        assert_eq!(SiderTheme::Light, SiderTheme::Light);
337        assert_eq!(SiderTheme::Dark, SiderTheme::Dark);
338    }
339
340    #[test]
341    fn layout_props_defaults() {
342        let props = LayoutProps {
343            class: None,
344            style: None,
345            has_sider: None,
346            children: rsx!(div {}),
347        };
348        assert!(props.class.is_none());
349        assert!(props.style.is_none());
350        assert!(props.has_sider.is_none());
351    }
352
353    #[test]
354    fn layout_props_has_sider() {
355        let props_with_sider = LayoutProps {
356            class: None,
357            style: None,
358            has_sider: Some(true),
359            children: rsx!(div {}),
360        };
361        assert_eq!(props_with_sider.has_sider, Some(true));
362
363        let props_without_sider = LayoutProps {
364            class: None,
365            style: None,
366            has_sider: Some(false),
367            children: rsx!(div {}),
368        };
369        assert_eq!(props_without_sider.has_sider, Some(false));
370    }
371
372    #[test]
373    fn sider_props_defaults() {
374        let props = SiderProps {
375            width: None,
376            collapsed_width: None,
377            collapsed: None,
378            default_collapsed: false,
379            collapsible: false,
380            reverse_arrow: false,
381            trigger: None,
382            zero_width_trigger_style: None,
383            theme: SiderTheme::default(),
384            has_border: true,
385            on_collapse: None,
386            class: None,
387            style: None,
388            children: rsx!(div {}),
389        };
390        assert_eq!(props.default_collapsed, false);
391        assert_eq!(props.collapsible, false);
392        assert_eq!(props.reverse_arrow, false);
393        assert_eq!(props.theme, SiderTheme::Dark);
394        assert_eq!(props.has_border, true);
395    }
396
397    #[test]
398    fn sider_theme_clone() {
399        let original = SiderTheme::Light;
400        let cloned = original;
401        assert_eq!(original, cloned);
402    }
403}