Skip to main content

dioxus_ui_system/organisms/
header.rs

1//! Header organism component
2//!
3//! Application header with navigation, branding, and actions.
4
5use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8use crate::atoms::{Button, ButtonVariant, Icon, IconSize, IconColor, Heading, HeadingLevel};
9
10/// Navigation item
11#[derive(Clone, PartialEq)]
12pub struct NavItem {
13    pub label: String,
14    pub href: String,
15    pub icon: Option<String>,
16    pub active: bool,
17}
18
19/// Header properties
20#[derive(Props, Clone, PartialEq)]
21pub struct HeaderProps {
22    /// Brand/logo element
23    #[props(default)]
24    pub brand: Option<Element>,
25    /// Brand title text (alternative to brand element)
26    #[props(default)]
27    pub brand_title: Option<String>,
28    /// Navigation items
29    #[props(default)]
30    pub nav_items: Vec<NavItem>,
31    /// Right-side actions
32    #[props(default)]
33    pub actions: Option<Element>,
34    /// Whether header is sticky
35    #[props(default = true)]
36    pub sticky: bool,
37    /// Whether to show border
38    #[props(default = true)]
39    pub bordered: bool,
40    /// Custom inline styles
41    #[props(default)]
42    pub style: Option<String>,
43}
44
45/// Header organism component
46///
47/// # Example
48/// ```rust,ignore
49/// use dioxus_ui_system::organisms::{Header, NavItem};
50///
51/// let nav_items = vec![
52///     NavItem {
53///         label: "Home".to_string(),
54///         href: "/".to_string(),
55///         icon: Some("home".to_string()),
56///         active: true,
57///     },
58///     NavItem {
59///         label: "About".to_string(),
60///         href: "/about".to_string(),
61///         icon: None,
62///         active: false,
63///     },
64/// ];
65///
66/// rsx! {
67///     Header {
68///         brand_title: "My App",
69///         nav_items: nav_items,
70///     }
71/// }
72/// ```
73#[component]
74pub fn Header(props: HeaderProps) -> Element {
75    let _theme = use_theme();
76    let sticky = props.sticky;
77    let bordered = props.bordered;
78    
79    let style = use_style(move |t| {
80        let base = Style::new()
81            .w_full()
82            .h_px(64)
83            .flex()
84            .items_center()
85            .justify_between()
86            .px(&t.spacing, "lg")
87            .bg(&t.colors.background)
88            .z_index(50);
89            
90        // Sticky positioning
91        let base = if sticky {
92            base.position("sticky").top("0")
93        } else {
94            base
95        };
96        
97        // Border
98        if bordered {
99            base.border_bottom(1, &t.colors.border)
100        } else {
101            base
102        }.build()
103    });
104    
105    let final_style = if let Some(custom) = &props.style {
106        format!("{} {}", style(), custom)
107    } else {
108        style()
109    };
110    
111    // Brand element
112    let brand = if let Some(brand_el) = props.brand {
113        brand_el
114    } else if let Some(title) = &props.brand_title {
115        rsx! {
116            div {
117                style: "display: flex; align-items: center; gap: 8px;",
118                Heading {
119                    level: HeadingLevel::H4,
120                    "{title}"
121                }
122            }
123        }
124    } else {
125        rsx! {}
126    };
127    
128    // Navigation
129    let nav_items = props.nav_items.clone();
130    let has_nav = !nav_items.is_empty();
131    
132    let nav_style = use_style(|t| {
133        Style::new()
134            .flex()
135            .items_center()
136            .gap(&t.spacing, "md")
137            .build()
138    });
139    
140    let nav = if has_nav {
141        rsx! {
142            nav {
143                style: "{nav_style}",
144                for item in nav_items {
145                    HeaderNavLink { item: item }
146                }
147            }
148        }
149    } else {
150        rsx! {}
151    };
152    
153    rsx! {
154        header {
155            style: "{final_style}",
156            
157            // Left section: Brand + Nav
158            div {
159                style: "display: flex; align-items: center; gap: 32px;",
160                {brand}
161                {nav}
162            }
163            
164            // Right section: Actions
165            if props.actions.is_some() {
166                div {
167                    style: "display: flex; align-items: center; gap: 8px;",
168                    {props.actions.unwrap()}
169                }
170            }
171        }
172    }
173}
174
175/// Header navigation link component
176#[derive(Props, Clone, PartialEq)]
177pub struct HeaderNavLinkProps {
178    pub item: NavItem,
179}
180
181#[component]
182pub fn HeaderNavLink(props: HeaderNavLinkProps) -> Element {
183    let item = props.item.clone();
184    let is_active = item.active;
185    
186    let style = use_style(move |t| {
187        let base = Style::new()
188            .inline_flex()
189            .items_center()
190            .gap(&t.spacing, "xs")
191            .px(&t.spacing, "sm")
192            .py(&t.spacing, "xs")
193            .rounded(&t.radius, "md")
194            .text(&t.typography, "sm")
195            .font_weight(500)
196            .transition("all 150ms ease")
197            .no_underline();
198            
199        if is_active {
200            base
201                .bg(&t.colors.muted)
202                .text_color(&t.colors.foreground)
203        } else {
204            base
205                .text_color(&t.colors.muted_foreground)
206        }.build()
207    });
208    
209    let href = item.href.clone();
210    let has_icon = item.icon.is_some();
211    
212    rsx! {
213        a {
214            style: "{style}",
215            href: "{href}",
216            
217            if has_icon {
218                Icon {
219                    name: item.icon.unwrap(),
220                    size: IconSize::Small,
221                    color: IconColor::Current,
222                }
223            }
224            
225            "{item.label}"
226        }
227    }
228}
229
230/// Mobile menu toggle button
231#[component]
232pub fn MobileMenuToggle(
233    #[props(default)]
234    is_open: bool,
235    #[props(default)]
236    onclick: Option<EventHandler<()>>,
237) -> Element {
238    let icon_name = if is_open { "x".to_string() } else { "menu".to_string() };
239    
240    rsx! {
241        Button {
242            variant: ButtonVariant::Ghost,
243            size: crate::atoms::ButtonSize::Icon,
244            onclick: move |_| {
245                if let Some(handler) = &onclick {
246                    handler.call(());
247                }
248            },
249            Icon {
250                name: icon_name,
251                size: IconSize::Medium,
252                color: IconColor::Current,
253            }
254        }
255    }
256}
257
258/// User menu component for header
259#[derive(Props, Clone, PartialEq)]
260pub struct UserMenuProps {
261    /// User name
262    pub name: String,
263    /// User email
264    #[props(default)]
265    pub email: Option<String>,
266    /// User avatar URL
267    #[props(default)]
268    pub avatar: Option<String>,
269    /// Menu items
270    #[props(default)]
271    pub menu_items: Vec<UserMenuItem>,
272}
273
274/// User menu item
275#[derive(Clone, PartialEq)]
276pub struct UserMenuItem {
277    pub label: String,
278    pub icon: Option<String>,
279    pub onclick: Option<EventHandler<()>>,
280}
281
282/// User Menu component
283#[component]
284pub fn UserMenu(props: UserMenuProps) -> Element {
285    let mut is_open = use_signal(|| false);
286    
287    let avatar = if let Some(url) = props.avatar {
288        rsx! {
289            img {
290                src: "{url}",
291                style: "width: 32px; height: 32px; border-radius: 50%; object-fit: cover;",
292                alt: "{props.name}",
293            }
294        }
295    } else {
296        // Default avatar with initials
297        let initials: String = props.name
298            .split_whitespace()
299            .filter_map(|s| s.chars().next())
300            .collect::<String>()
301            .to_uppercase()
302            .chars()
303            .take(2)
304            .collect();
305        
306        let style = use_style(|t| {
307            Style::new()
308                .w_px(32)
309                .h_px(32)
310                .rounded_full()
311                .flex()
312                .items_center()
313                .justify_center()
314                .bg(&t.colors.primary)
315                .text_color(&t.colors.primary_foreground)
316                .font_size(12)
317                .font_weight(600)
318                .build()
319        });
320        
321        rsx! {
322            div { style: "{style}", "{initials}" }
323        }
324    };
325    
326    rsx! {
327        div {
328            style: "position: relative;",
329            
330            Button {
331                variant: ButtonVariant::Ghost,
332                onclick: move |_| is_open.toggle(),
333                
334                div {
335                    style: "display: flex; align-items: center; gap: 8px;",
336                    {avatar}
337                    Icon {
338                        name: "chevron-down".to_string(),
339                        size: IconSize::Small,
340                        color: IconColor::Current,
341                    }
342                }
343            }
344            
345            if is_open() {
346                UserMenuDropdown {
347                    items: props.menu_items.clone(),
348                    on_close: move || is_open.set(false),
349                }
350            }
351        }
352    }
353}
354
355/// User menu dropdown
356#[derive(Props, Clone, PartialEq)]
357pub struct UserMenuDropdownProps {
358    pub items: Vec<UserMenuItem>,
359    pub on_close: EventHandler<()>,
360}
361
362#[component]
363pub fn UserMenuDropdown(props: UserMenuDropdownProps) -> Element {
364    let style = use_style(|t| {
365        Style::new()
366            .absolute()
367            .right("0")
368            .top("calc(100% + 8px)")
369            .w_px(200)
370            .rounded(&t.radius, "md")
371            .border(1, &t.colors.border)
372            .bg(&t.colors.popover)
373            .shadow(&t.shadows.lg)
374            .flex()
375            .flex_col()
376            .p(&t.spacing, "xs")
377            .build()
378    });
379    
380    rsx! {
381        div {
382            style: "{style}",
383            
384            for item in props.items {
385                UserMenuItemView {
386                    item: item,
387                    on_close: props.on_close.clone(),
388                }
389            }
390        }
391    }
392}
393
394#[derive(Props, Clone, PartialEq)]
395pub struct UserMenuItemViewProps {
396    pub item: UserMenuItem,
397    pub on_close: EventHandler<()>,
398}
399
400#[component]
401pub fn UserMenuItemView(props: UserMenuItemViewProps) -> Element {
402    let item = props.item.clone();
403    
404    let style = use_style(|t| {
405        Style::new()
406            .flex()
407            .items_center()
408            .gap(&t.spacing, "sm")
409            .w_full()
410            .p(&t.spacing, "sm")
411            .rounded(&t.radius, "sm")
412            .text(&t.typography, "sm")
413            .cursor_pointer()
414            .transition("all 150ms ease")
415            .build()
416    });
417    
418    let has_icon = item.icon.is_some();
419    
420    rsx! {
421        button {
422            style: "{style} background: transparent; border: none; text-align: left; color: inherit;",
423            onclick: move |_| {
424                if let Some(handler) = &item.onclick {
425                    handler.call(());
426                }
427                props.on_close.call(());
428            },
429            
430            if has_icon {
431                Icon {
432                    name: item.icon.unwrap(),
433                    size: IconSize::Small,
434                    color: IconColor::Current,
435                }
436            }
437            
438            "{item.label}"
439        }
440    }
441}