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