1use crate::atoms::{Box, Button, ButtonVariant, HStack, Icon, IconColor, IconSize, VStack};
6use crate::styles::Style;
7use crate::theme::use_style;
8use dioxus::prelude::*;
9
10#[derive(Clone, PartialEq, Default)]
12pub enum LayoutType {
13 #[default]
15 Sidebar,
16 TopNav,
18 Drawer,
20 FullWidth,
22}
23
24#[derive(Props, Clone, PartialEq)]
26pub struct LayoutProps {
27 #[props(default)]
29 pub layout_type: LayoutType,
30 #[props(default)]
32 pub nav_items: Vec<LayoutNavItem>,
33 #[props(default)]
35 pub brand: Option<Element>,
36 #[props(default)]
38 pub title: Option<String>,
39 pub children: Element,
41 #[props(default)]
43 pub actions: Option<Element>,
44 #[props(default = true)]
46 pub collapsible: bool,
47 #[props(default)]
49 pub sidebar_collapsed: bool,
50 #[props(default = 260)]
52 pub sidebar_width: u16,
53 #[props(default = 64)]
55 pub header_height: u16,
56 #[props(default)]
58 pub class: Option<String>,
59}
60
61#[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#[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#[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 aside {
201 style: "{sidebar_style}",
202
203 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 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 {
247 style: "{main_style}",
248
249 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 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#[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 {
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 {
344 style: "{content_style}",
345 {props.children}
346 }
347 }
348 }
349}
350
351#[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 {
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 {
433 style: "{content_style}",
434 {props.children}
435 }
436
437 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 aside {
451 style: "{drawer_style}",
452 onclick: move |e| e.stop_propagation(),
453
454 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 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#[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#[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#[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}