Skip to main content

dioxus_ui_system/organisms/
layout.rs

1//! Layout organism component
2//!
3//! Provides flexible page layouts including sidebar, drawer, and top navigation variants.
4
5use dioxus::prelude::*;
6use crate::theme::use_style;
7use crate::styles::Style;
8use crate::atoms::{Button, ButtonVariant, Icon, IconSize, IconColor};
9
10/// Layout type variants
11#[derive(Clone, PartialEq, Default)]
12pub enum LayoutType {
13    /// Sidebar layout (default)
14    #[default]
15    Sidebar,
16    /// Top navigation layout
17    TopNav,
18    /// Drawer layout (mobile-friendly)
19    Drawer,
20    /// Full-width layout (no navigation)
21    FullWidth,
22}
23
24/// Main layout properties
25#[derive(Props, Clone, PartialEq)]
26pub struct LayoutProps {
27    /// Layout type
28    #[props(default)]
29pub layout_type: LayoutType,
30    /// Navigation items
31    #[props(default)]
32    pub nav_items: Vec<LayoutNavItem>,
33    /// Brand element (logo/title)
34    #[props(default)]
35    pub brand: Option<Element>,
36    /// Page title
37    #[props(default)]
38    pub title: Option<String>,
39    /// Main content
40    pub children: Element,
41    /// Right-side actions
42    #[props(default)]
43    pub actions: Option<Element>,
44    /// Whether sidebar is collapsible
45    #[props(default = true)]
46    pub collapsible: bool,
47    /// Initial sidebar collapsed state
48    #[props(default)]
49    pub sidebar_collapsed: bool,
50    /// Sidebar width (default: 260px)
51    #[props(default = 260)]
52    pub sidebar_width: u16,
53    /// Header height (default: 64px)
54    #[props(default = 64)]
55    pub header_height: u16,
56    /// Custom class name
57    #[props(default)]
58    pub class: Option<String>,
59}
60
61/// Navigation item for layouts
62#[derive(Clone, PartialEq)]
63pub struct LayoutNavItem {
64    pub id: String,
65    pub label: String,
66    pub href: String,
67    pub icon: Option<String>,
68    pub active: bool,
69    pub children: Vec<LayoutNavItem>,
70}
71
72impl LayoutNavItem {
73    pub fn new(id: impl Into<String>, label: impl Into<String>, href: impl Into<String>) -> Self {
74        Self {
75            id: id.into(),
76            label: label.into(),
77            href: href.into(),
78            icon: None,
79            active: false,
80            children: vec![],
81        }
82    }
83
84    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
85        self.icon = Some(icon.into());
86        self
87    }
88
89    pub fn active(mut self, active: bool) -> Self {
90        self.active = active;
91        self
92    }
93
94    pub fn with_children(mut self, children: Vec<LayoutNavItem>) -> Self {
95        self.children = children;
96        self
97    }
98}
99
100/// Main layout component
101#[component]
102pub fn Layout(props: LayoutProps) -> Element {
103    let layout_type = props.layout_type.clone();
104    
105    match layout_type {
106        LayoutType::Sidebar => rsx! {
107            SidebarLayoutRenderer {
108                nav_items: props.nav_items.clone(),
109                brand: props.brand.clone(),
110                title: props.title.clone(),
111                children: props.children.clone(),
112                actions: props.actions.clone(),
113                collapsible: props.collapsible,
114                sidebar_collapsed: props.sidebar_collapsed,
115                sidebar_width: props.sidebar_width,
116                header_height: props.header_height,
117                class: props.class.clone(),
118            }
119        },
120        LayoutType::TopNav => rsx! {
121            TopNavLayoutRenderer {
122                nav_items: props.nav_items.clone(),
123                brand: props.brand.clone(),
124                title: props.title.clone(),
125                children: props.children.clone(),
126                actions: props.actions.clone(),
127                header_height: props.header_height,
128                class: props.class.clone(),
129            }
130        },
131        LayoutType::Drawer => rsx! {
132            DrawerLayoutRenderer {
133                nav_items: props.nav_items.clone(),
134                brand: props.brand.clone(),
135                title: props.title.clone(),
136                children: props.children.clone(),
137                actions: props.actions.clone(),
138                sidebar_width: props.sidebar_width,
139                header_height: props.header_height,
140                class: props.class.clone(),
141            }
142        },
143        LayoutType::FullWidth => rsx! {
144            FullWidthLayoutRenderer {
145                children: props.children.clone(),
146                class: props.class.clone(),
147            }
148        },
149    }
150}
151
152/// Sidebar layout with collapsible navigation
153#[derive(Props, Clone, PartialEq)]
154pub struct SidebarLayoutProps {
155    nav_items: Vec<LayoutNavItem>,
156    brand: Option<Element>,
157    title: Option<String>,
158    children: Element,
159    actions: Option<Element>,
160    collapsible: bool,
161    sidebar_collapsed: bool,
162    sidebar_width: u16,
163    header_height: u16,
164    class: Option<String>,
165}
166
167#[component]
168fn SidebarLayoutRenderer(props: SidebarLayoutProps) -> Element {
169    let mut is_collapsed = use_signal(|| props.sidebar_collapsed);
170    let sidebar_width = if is_collapsed() { 80 } else { props.sidebar_width };
171
172    let layout_style = use_style(|_t| {
173        "display: flex; height: 100vh;".to_string()
174    });
175
176    let sidebar_style = format!(
177        "width: {}px; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid #e2e8f0; transition: width 200ms ease;",
178        sidebar_width
179    );
180
181    let main_style = "display: flex; flex-direction: column; flex: 1; height: 100vh; overflow: auto;";
182
183    let header_style = format!(
184        "height: {}px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e2e8f0;",
185        props.header_height
186    );
187
188    let content_style = "flex: 1; padding: 24px; overflow: auto;";
189
190    rsx! {
191        div {
192            style: "{layout_style} {props.class.clone().unwrap_or_default()}",
193            
194            // Sidebar
195            aside {
196                style: "{sidebar_style}",
197                
198                // Brand
199                div {
200                    style: "{header_style}",
201                    
202                    if let Some(brand) = props.brand.clone() {
203                        div {
204                            style: "flex: 1; overflow: hidden;",
205                            {brand}
206                        }
207                    }
208                    
209                    if props.collapsible {
210                        button {
211                            style: "background: none; border: none; cursor: pointer; padding: 8px;",
212                            onclick: move |_| is_collapsed.toggle(),
213                            
214                            if is_collapsed() {
215                                "→"
216                            } else {
217                                "←"
218                            }
219                        }
220                    }
221                }
222                
223                // Navigation
224                nav {
225                    style: "flex: 1; overflow-y: auto; padding: 16px 12px;",
226                    
227                    for item in props.nav_items.clone() {
228                        SidebarNavItem {
229                            item: item,
230                            collapsed: is_collapsed(),
231                        }
232                    }
233                }
234            }
235            
236            // Main content area
237            main {
238                style: "{main_style}",
239                
240                // Header
241                header {
242                    style: "{header_style}",
243                    
244                    if let Some(title) = props.title.clone() {
245                        h1 {
246                            style: "margin: 0; font-size: 20px; font-weight: 600;",
247                            "{title}"
248                        }
249                    }
250                    
251                    if let Some(actions) = props.actions.clone() {
252                        div {
253                            style: "display: flex; align-items: center; gap: 8px;",
254                            {actions}
255                        }
256                    }
257                }
258                
259                // Content
260                div {
261                    style: "{content_style}",
262                    {props.children}
263                }
264            }
265        }
266    }
267}
268
269/// Top navigation layout
270#[derive(Props, Clone, PartialEq)]
271pub struct TopNavLayoutProps {
272    nav_items: Vec<LayoutNavItem>,
273    brand: Option<Element>,
274    title: Option<String>,
275    children: Element,
276    actions: Option<Element>,
277    header_height: u16,
278    class: Option<String>,
279}
280
281#[component]
282fn TopNavLayoutRenderer(props: TopNavLayoutProps) -> Element {
283    let header_style = format!(
284        "height: {}px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e2e8f0;",
285        props.header_height
286    );
287
288    let content_style = format!("flex: 1; padding: 24px; overflow: auto; min-height: calc(100vh - {}px);", props.header_height);
289
290    rsx! {
291        div {
292            style: "display: flex; flex-direction: column; min-height: 100vh; {props.class.clone().unwrap_or_default()}",
293            
294            // Header with navigation
295            header {
296                style: "{header_style}",
297                
298                div {
299                    style: "display: flex; align-items: center; gap: 24px;",
300                    
301                    if let Some(brand) = props.brand.clone() {
302                        {brand}
303                    }
304                    
305                    nav {
306                        style: "display: flex; align-items: center; gap: 24px;",
307                        
308                        for item in props.nav_items.clone() {
309                            TopNavLink { item: item }
310                        }
311                    }
312                }
313                
314                if let Some(actions) = props.actions.clone() {
315                    div {
316                        style: "display: flex; align-items: center; gap: 8px;",
317                        {actions}
318                    }
319                }
320            }
321            
322            // Main content
323            main {
324                style: "{content_style}",
325                {props.children}
326            }
327        }
328    }
329}
330
331/// Drawer layout (mobile-friendly slide-out navigation)
332#[derive(Props, Clone, PartialEq)]
333pub struct DrawerLayoutProps {
334    nav_items: Vec<LayoutNavItem>,
335    brand: Option<Element>,
336    title: Option<String>,
337    children: Element,
338    actions: Option<Element>,
339    sidebar_width: u16,
340    header_height: u16,
341    class: Option<String>,
342}
343
344#[component]
345fn DrawerLayoutRenderer(props: DrawerLayoutProps) -> Element {
346    let mut drawer_open = use_signal(|| false);
347
348    let header_style = format!(
349        "height: {}px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e2e8f0;",
350        props.header_height
351    );
352
353    let content_style = format!(
354        "flex: 1; padding: 24px; overflow: auto; min-height: calc(100vh - {}px);",
355        props.header_height
356    );
357
358    let drawer_overlay_style = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 40;";
359
360    let drawer_style = format!(
361        "position: fixed; top: 0; left: 0; height: 100vh; width: {}px; background: white; border-right: 1px solid #e2e8f0; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); z-index: 50; display: flex; flex-direction: column;",
362        props.sidebar_width
363    );
364
365    rsx! {
366        div {
367            style: "display: flex; flex-direction: column; min-height: 100vh; {props.class.clone().unwrap_or_default()}",
368            
369            // Header
370            header {
371                style: "{header_style}",
372                
373                div {
374                    style: "display: flex; align-items: center; gap: 16px;",
375                    
376                    Button {
377                        variant: ButtonVariant::Ghost,
378                        size: crate::atoms::ButtonSize::Icon,
379                        onclick: move |_| drawer_open.set(true),
380                        Icon {
381                            name: "menu".to_string(),
382                            size: IconSize::Medium,
383                            color: IconColor::Current,
384                        }
385                    }
386                    
387                    if let Some(brand) = props.brand.clone() {
388                        {brand}
389                    }
390                    
391                    if let Some(title) = props.title.clone() {
392                        h1 {
393                            style: "margin: 0; font-size: 20px; font-weight: 600;",
394                            "{title}"
395                        }
396                    }
397                }
398                
399                if let Some(actions) = props.actions.clone() {
400                    div {
401                        style: "display: flex; align-items: center; gap: 8px;",
402                        {actions}
403                    }
404                }
405            }
406            
407            // Main content
408            main {
409                style: "{content_style}",
410                {props.children}
411            }
412            
413            // Drawer overlay
414            if drawer_open() {
415                div {
416                    style: "{drawer_overlay_style}",
417                    onclick: move |_| drawer_open.set(false),
418                }
419                
420                // Drawer
421                aside {
422                    style: "{drawer_style}",
423                    onclick: move |e| e.stop_propagation(),
424                    
425                    // Drawer header
426                    div {
427                        style: "{header_style}",
428                        
429                        if let Some(brand) = props.brand.clone() {
430                            div {
431                                style: "flex: 1;",
432                                {brand}
433                            }
434                        }
435                        
436                        Button {
437                            variant: ButtonVariant::Ghost,
438                            size: crate::atoms::ButtonSize::Icon,
439                            onclick: move |_| drawer_open.set(false),
440                            "✕"
441                        }
442                    }
443                    
444                    // Drawer navigation
445                    nav {
446                        style: "flex: 1; overflow-y: auto; padding: 16px 12px;",
447                        
448                        for item in props.nav_items.clone() {
449                            SidebarNavItem {
450                                item: item,
451                                collapsed: false,
452                            }
453                        }
454                    }
455                }
456            }
457        }
458    }
459}
460
461/// Full-width layout (no navigation)
462#[derive(Props, Clone, PartialEq)]
463pub struct FullWidthLayoutProps {
464    children: Element,
465    class: Option<String>,
466}
467
468#[component]
469fn FullWidthLayoutRenderer(props: FullWidthLayoutProps) -> Element {
470    let _theme = use_style(|t| {
471        Style::new()
472            .w_full()
473            .min_h_full()
474            .bg(&t.colors.background)
475            .build()
476    });
477
478    rsx! {
479        div {
480            style: "width: 100%; min-height: 100vh; {props.class.clone().unwrap_or_default()}",
481            {props.children}
482        }
483    }
484}
485
486/// Sidebar navigation item
487#[derive(Props, Clone, PartialEq)]
488struct SidebarNavItemProps {
489    item: LayoutNavItem,
490    collapsed: bool,
491}
492
493#[component]
494fn SidebarNavItem(props: SidebarNavItemProps) -> Element {
495    let mut is_hovered = use_signal(|| false);
496    let item = props.item.clone();
497    let has_icon = item.icon.is_some();
498
499    let link_style = use_style(move |t| {
500        let base = Style::new()
501            .flex()
502            .items_center()
503            .gap(&t.spacing, "sm")
504            .px(&t.spacing, "sm")
505            .py(&t.spacing, "sm")
506            .rounded(&t.radius, "md")
507            .text(&t.typography, "sm")
508            .font_weight(500)
509            .transition("all 150ms ease")
510            .no_underline();
511
512        if item.active {
513            base.bg(&t.colors.secondary)
514                .text_color(&t.colors.secondary_foreground)
515        } else if is_hovered() {
516            base.bg(&t.colors.muted)
517                .text_color(&t.colors.foreground)
518        } else {
519            base.text_color(&t.colors.muted_foreground)
520        }.build()
521    });
522
523    rsx! {
524        a {
525            href: "{item.href}",
526            style: "{link_style}",
527            onmouseenter: move |_| is_hovered.set(true),
528            onmouseleave: move |_| is_hovered.set(false),
529            
530            if has_icon {
531                Icon {
532                    name: item.icon.clone().unwrap(),
533                    size: IconSize::Medium,
534                    color: IconColor::Current,
535                }
536            }
537            
538            if !props.collapsed {
539                span {
540                    "{item.label}"
541                }
542            }
543        }
544    }
545}
546
547/// Top navigation link
548#[derive(Props, Clone, PartialEq)]
549struct TopNavLinkProps {
550    item: LayoutNavItem,
551}
552
553#[component]
554fn TopNavLink(props: TopNavLinkProps) -> Element {
555    let mut is_hovered = use_signal(|| false);
556    let item = props.item.clone();
557    let has_icon = item.icon.is_some();
558
559    let link_style = use_style(move |t| {
560        let base = Style::new()
561            .inline_flex()
562            .items_center()
563            .gap(&t.spacing, "xs")
564            .px(&t.spacing, "sm")
565            .py(&t.spacing, "xs")
566            .rounded(&t.radius, "md")
567            .text(&t.typography, "sm")
568            .font_weight(500)
569            .transition("all 150ms ease")
570            .no_underline();
571
572        if item.active {
573            base.bg(&t.colors.muted)
574                .text_color(&t.colors.foreground)
575        } else if is_hovered() {
576            base.bg(&t.colors.muted)
577                .text_color(&t.colors.foreground)
578        } else {
579            base.text_color(&t.colors.muted_foreground)
580        }.build()
581    });
582
583    rsx! {
584        a {
585            href: "{item.href}",
586            style: "{link_style}",
587            onmouseenter: move |_| is_hovered.set(true),
588            onmouseleave: move |_| is_hovered.set(false),
589            
590            if has_icon {
591                Icon {
592                    name: item.icon.clone().unwrap(),
593                    size: IconSize::Small,
594                    color: IconColor::Current,
595                }
596            }
597            
598            "{item.label}"
599        }
600    }
601}