1use dioxus::prelude::*;
6use crate::theme::use_style;
7use crate::styles::Style;
8use crate::atoms::{Button, ButtonVariant, Icon, IconSize, IconColor};
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)]
29pub 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() { 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 aside {
196 style: "{sidebar_style}",
197
198 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 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 {
238 style: "{main_style}",
239
240 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 div {
261 style: "{content_style}",
262 {props.children}
263 }
264 }
265 }
266 }
267}
268
269#[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 {
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 {
324 style: "{content_style}",
325 {props.children}
326 }
327 }
328 }
329}
330
331#[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 {
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 {
409 style: "{content_style}",
410 {props.children}
411 }
412
413 if drawer_open() {
415 div {
416 style: "{drawer_overlay_style}",
417 onclick: move |_| drawer_open.set(false),
418 }
419
420 aside {
422 style: "{drawer_style}",
423 onclick: move |e| e.stop_propagation(),
424
425 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 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#[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#[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#[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}