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 crate::atoms::{Box, Button, ButtonVariant, HStack, Icon, IconColor, IconSize, VStack};
6use crate::styles::Style;
7use crate::theme::use_style;
8use dioxus::prelude::*;
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)]
29    pub 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() {
171        80
172    } else {
173        props.sidebar_width
174    };
175
176    let _layout_style = use_style(|_t| "display: flex; height: 100vh;".to_string());
177
178    let sidebar_style = format!(
179        "width: {}px; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid #e2e8f0; transition: width 200ms ease;",
180        sidebar_width
181    );
182
183    let main_style =
184        "display: flex; flex-direction: column; flex: 1; height: 100vh; overflow: auto;";
185
186    let header_style = format!(
187        "height: {}px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e2e8f0;",
188        props.header_height
189    );
190
191    let _content_style = "flex: 1; padding: 24px; overflow: auto;";
192
193    rsx! {
194        Box {
195            display: crate::atoms::BoxDisplay::Flex,
196            height: Some("100vh".to_string()),
197            class: props.class.clone(),
198
199            // Sidebar
200            aside {
201                style: "{sidebar_style}",
202
203                // Brand
204                HStack {
205                    align: crate::atoms::AlignItems::Center,
206                    justify: crate::atoms::JustifyContent::SpaceBetween,
207                    height: Some(format!("{}px", props.header_height)),
208                    padding: crate::atoms::SpacingSize::Lg,
209                    style: Some(format!("border-bottom: 1px solid #e2e8f0; padding-left: 24px; padding-right: 24px; height: {}px;", props.header_height)),
210
211                    if let Some(brand) = props.brand.clone() {
212                        div {
213                            style: "flex: 1; overflow: hidden;",
214                            {brand}
215                        }
216                    }
217
218                    if props.collapsible {
219                        button {
220                            style: "background: none; border: none; cursor: pointer; padding: 8px;",
221                            onclick: move |_| is_collapsed.toggle(),
222
223                            if is_collapsed() {
224                                "→"
225                            } else {
226                                "←"
227                            }
228                        }
229                    }
230                }
231
232                // Navigation
233                nav {
234                    style: "flex: 1; overflow-y: auto; padding: 16px 12px;",
235
236                    for item in props.nav_items.clone() {
237                        SidebarNavItem {
238                            item: item,
239                            collapsed: is_collapsed(),
240                        }
241                    }
242                }
243            }
244
245            // Main content area
246            main {
247                style: "{main_style}",
248
249                // Header
250                header {
251                    style: "{header_style}",
252
253                    if let Some(title) = props.title.clone() {
254                        h1 {
255                            style: "margin: 0; font-size: 20px; font-weight: 600;",
256                            "{title}"
257                        }
258                    }
259
260                    if let Some(actions) = props.actions.clone() {
261                        HStack {
262                            align: crate::atoms::AlignItems::Center,
263                            gap: crate::atoms::SpacingSize::Sm,
264                            {actions}
265                        }
266                    }
267                }
268
269                // Content
270                Box {
271                    width: Some("100%".to_string()),
272                    padding: crate::atoms::SpacingSize::Lg,
273                    overflow: crate::atoms::Overflow::Auto,
274                    style: Some("flex: 1;".to_string()),
275                    {props.children}
276                }
277            }
278        }
279    }
280}
281
282/// Top navigation layout
283#[derive(Props, Clone, PartialEq)]
284pub struct TopNavLayoutProps {
285    nav_items: Vec<LayoutNavItem>,
286    brand: Option<Element>,
287    title: Option<String>,
288    children: Element,
289    actions: Option<Element>,
290    header_height: u16,
291    class: Option<String>,
292}
293
294#[component]
295fn TopNavLayoutRenderer(props: TopNavLayoutProps) -> Element {
296    let header_style = format!(
297        "height: {}px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e2e8f0;",
298        props.header_height
299    );
300
301    let content_style = format!(
302        "flex: 1; padding: 24px; overflow: auto; min-height: calc(100vh - {}px);",
303        props.header_height
304    );
305
306    rsx! {
307        VStack {
308            height: Some("100vh".to_string()),
309            class: props.class.clone(),
310            style: Some("min-height: 100vh;".to_string()),
311
312            // Header with navigation
313            header {
314                style: "{header_style}",
315
316                HStack {
317                    align: crate::atoms::AlignItems::Center,
318                    gap: crate::atoms::SpacingSize::Lg,
319
320                    if let Some(brand) = props.brand.clone() {
321                        {brand}
322                    }
323
324                    nav {
325                        style: "display: flex; align-items: center; gap: 24px;",
326
327                        for item in props.nav_items.clone() {
328                            TopNavLink { item: item }
329                        }
330                    }
331                }
332
333                if let Some(actions) = props.actions.clone() {
334                    HStack {
335                        align: crate::atoms::AlignItems::Center,
336                        gap: crate::atoms::SpacingSize::Sm,
337                        {actions}
338                    }
339                }
340            }
341
342            // Main content
343            main {
344                style: "{content_style}",
345                {props.children}
346            }
347        }
348    }
349}
350
351/// Drawer layout (mobile-friendly slide-out navigation)
352#[derive(Props, Clone, PartialEq)]
353pub struct DrawerLayoutProps {
354    nav_items: Vec<LayoutNavItem>,
355    brand: Option<Element>,
356    title: Option<String>,
357    children: Element,
358    actions: Option<Element>,
359    sidebar_width: u16,
360    header_height: u16,
361    class: Option<String>,
362}
363
364#[component]
365fn DrawerLayoutRenderer(props: DrawerLayoutProps) -> Element {
366    let mut drawer_open = use_signal(|| false);
367
368    let header_style = format!(
369        "height: {}px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e2e8f0;",
370        props.header_height
371    );
372
373    let content_style = format!(
374        "flex: 1; padding: 24px; overflow: auto; min-height: calc(100vh - {}px);",
375        props.header_height
376    );
377
378    let _drawer_overlay_style = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 40;";
379
380    let drawer_style = format!(
381        "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;",
382        props.sidebar_width
383    );
384
385    rsx! {
386        VStack {
387            height: Some("100vh".to_string()),
388            class: props.class.clone(),
389            style: Some("min-height: 100vh;".to_string()),
390
391            // Header
392            header {
393                style: "{header_style}",
394
395                HStack {
396                    align: crate::atoms::AlignItems::Center,
397                    gap: crate::atoms::SpacingSize::Md,
398
399                    Button {
400                        variant: ButtonVariant::Ghost,
401                        size: crate::atoms::ButtonSize::Icon,
402                        onclick: move |_| drawer_open.set(true),
403                        Icon {
404                            name: "menu".to_string(),
405                            size: IconSize::Medium,
406                            color: IconColor::Current,
407                        }
408                    }
409
410                    if let Some(brand) = props.brand.clone() {
411                        {brand}
412                    }
413
414                    if let Some(title) = props.title.clone() {
415                        h1 {
416                            style: "margin: 0; font-size: 20px; font-weight: 600;",
417                            "{title}"
418                        }
419                    }
420                }
421
422                if let Some(actions) = props.actions.clone() {
423                    HStack {
424                        align: crate::atoms::AlignItems::Center,
425                        gap: crate::atoms::SpacingSize::Sm,
426                        {actions}
427                    }
428                }
429            }
430
431            // Main content
432            main {
433                style: "{content_style}",
434                {props.children}
435            }
436
437            // Drawer overlay
438            if drawer_open() {
439                Box {
440                    position: crate::atoms::Position::Fixed,
441                    top: Some("0".to_string()),
442                    left: Some("0".to_string()),
443                    width: Some("100%".to_string()),
444                    height: Some("100%".to_string()),
445                    style: Some("background: rgba(0,0,0,0.5); z-index: 40;".to_string()),
446                    onclick: move |_| drawer_open.set(false),
447                }
448
449                // Drawer
450                aside {
451                    style: "{drawer_style}",
452                    onclick: move |e| e.stop_propagation(),
453
454                    // Drawer header
455                    HStack {
456                        align: crate::atoms::AlignItems::Center,
457                        justify: crate::atoms::JustifyContent::SpaceBetween,
458                        height: Some(format!("{}px", props.header_height)),
459                        padding: crate::atoms::SpacingSize::Lg,
460                        style: Some("border-bottom: 1px solid #e2e8f0;".to_string()),
461
462                        if let Some(brand) = props.brand.clone() {
463                            Box {
464                                width: Some("100%".to_string()),
465                                {brand}
466                            }
467                        }
468
469                        Button {
470                            variant: ButtonVariant::Ghost,
471                            size: crate::atoms::ButtonSize::Icon,
472                            onclick: move |_| drawer_open.set(false),
473                            "✕"
474                        }
475                    }
476
477                    // Drawer navigation
478                    nav {
479                        style: "flex: 1; overflow-y: auto; padding: 16px 12px;",
480
481                        for item in props.nav_items.clone() {
482                            SidebarNavItem {
483                                item: item,
484                                collapsed: false,
485                            }
486                        }
487                    }
488                }
489            }
490        }
491    }
492}
493
494/// Full-width layout (no navigation)
495#[derive(Props, Clone, PartialEq)]
496pub struct FullWidthLayoutProps {
497    children: Element,
498    class: Option<String>,
499}
500
501#[component]
502fn FullWidthLayoutRenderer(props: FullWidthLayoutProps) -> Element {
503    let _theme = use_style(|t| {
504        Style::new()
505            .w_full()
506            .min_h_full()
507            .bg(&t.colors.background)
508            .build()
509    });
510
511    rsx! {
512        Box {
513            width: Some("100%".to_string()),
514            min_height: Some("100vh".to_string()),
515            class: props.class.clone(),
516            {props.children}
517        }
518    }
519}
520
521/// Sidebar navigation item
522#[derive(Props, Clone, PartialEq)]
523struct SidebarNavItemProps {
524    item: LayoutNavItem,
525    collapsed: bool,
526}
527
528#[component]
529fn SidebarNavItem(props: SidebarNavItemProps) -> Element {
530    let mut is_hovered = use_signal(|| false);
531    let item = props.item.clone();
532    let has_icon = item.icon.is_some();
533
534    let link_style = use_style(move |t| {
535        let base = Style::new()
536            .flex()
537            .items_center()
538            .gap(&t.spacing, "sm")
539            .px(&t.spacing, "sm")
540            .py(&t.spacing, "sm")
541            .rounded(&t.radius, "md")
542            .text(&t.typography, "sm")
543            .font_weight(500)
544            .transition("all 150ms ease")
545            .no_underline();
546
547        if item.active {
548            base.bg(&t.colors.secondary)
549                .text_color(&t.colors.secondary_foreground)
550        } else if is_hovered() {
551            base.bg(&t.colors.muted).text_color(&t.colors.foreground)
552        } else {
553            base.text_color(&t.colors.muted_foreground)
554        }
555        .build()
556    });
557
558    rsx! {
559        a {
560            href: "{item.href}",
561            style: "{link_style}",
562            onmouseenter: move |_| is_hovered.set(true),
563            onmouseleave: move |_| is_hovered.set(false),
564
565            if has_icon {
566                Icon {
567                    name: item.icon.clone().unwrap(),
568                    size: IconSize::Medium,
569                    color: IconColor::Current,
570                }
571            }
572
573            if !props.collapsed {
574                span {
575                    "{item.label}"
576                }
577            }
578        }
579    }
580}
581
582/// Top navigation link
583#[derive(Props, Clone, PartialEq)]
584struct TopNavLinkProps {
585    item: LayoutNavItem,
586}
587
588#[component]
589fn TopNavLink(props: TopNavLinkProps) -> Element {
590    let mut is_hovered = use_signal(|| false);
591    let item = props.item.clone();
592    let has_icon = item.icon.is_some();
593
594    let link_style = use_style(move |t| {
595        let base = Style::new()
596            .inline_flex()
597            .items_center()
598            .gap(&t.spacing, "xs")
599            .px(&t.spacing, "sm")
600            .py(&t.spacing, "xs")
601            .rounded(&t.radius, "md")
602            .text(&t.typography, "sm")
603            .font_weight(500)
604            .transition("all 150ms ease")
605            .no_underline();
606
607        if item.active {
608            base.bg(&t.colors.muted).text_color(&t.colors.foreground)
609        } else if is_hovered() {
610            base.bg(&t.colors.muted).text_color(&t.colors.foreground)
611        } else {
612            base.text_color(&t.colors.muted_foreground)
613        }
614        .build()
615    });
616
617    rsx! {
618        a {
619            href: "{item.href}",
620            style: "{link_style}",
621            onmouseenter: move |_| is_hovered.set(true),
622            onmouseleave: move |_| is_hovered.set(false),
623
624            if has_icon {
625                Icon {
626                    name: item.icon.clone().unwrap(),
627                    size: IconSize::Small,
628                    color: IconColor::Current,
629                }
630            }
631
632            "{item.label}"
633        }
634    }
635}