dioxus_ui_system/organisms/
header.rs1use 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#[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#[derive(Props, Clone, PartialEq)]
21pub struct HeaderProps {
22 #[props(default)]
24 pub brand: Option<Element>,
25 #[props(default)]
27 pub brand_title: Option<String>,
28 #[props(default)]
30 pub nav_items: Vec<NavItem>,
31 #[props(default)]
33 pub actions: Option<Element>,
34 #[props(default = true)]
36 pub sticky: bool,
37 #[props(default = true)]
39 pub bordered: bool,
40 #[props(default)]
42 pub style: Option<String>,
43}
44
45#[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 let base = if sticky {
92 base.position("sticky").top("0")
93 } else {
94 base
95 };
96
97 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 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 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 div {
159 style: "display: flex; align-items: center; gap: 32px;",
160 {brand}
161 {nav}
162 }
163
164 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#[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#[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#[derive(Props, Clone, PartialEq)]
260pub struct UserMenuProps {
261 pub name: String,
263 #[props(default)]
265 pub email: Option<String>,
266 #[props(default)]
268 pub avatar: Option<String>,
269 #[props(default)]
271 pub menu_items: Vec<UserMenuItem>,
272}
273
274#[derive(Clone, PartialEq)]
276pub struct UserMenuItem {
277 pub label: String,
278 pub icon: Option<String>,
279 pub onclick: Option<EventHandler<()>>,
280}
281
282#[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 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#[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}