dioxus_ui_system/organisms/
header.rs1use 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#[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#[derive(Props, Clone, PartialEq)]
23pub struct HeaderProps {
24 #[props(default)]
26 pub brand: Option<Element>,
27 #[props(default)]
29 pub brand_title: Option<String>,
30 #[props(default)]
32 pub nav_items: Vec<NavItem>,
33 #[props(default)]
35 pub actions: Option<Element>,
36 #[props(default = true)]
38 pub sticky: bool,
39 #[props(default = true)]
41 pub bordered: bool,
42 #[props(default)]
44 pub style: Option<String>,
45}
46
47#[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 let base = if sticky {
94 base.position("sticky").top("0")
95 } else {
96 base
97 };
98
99 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 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 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 HStack {
163 align: crate::atoms::AlignItems::Center,
164 gap: crate::atoms::SpacingSize::Xl,
165 {brand}
166 {nav}
167 }
168
169 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#[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#[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#[derive(Props, Clone, PartialEq)]
266pub struct UserMenuProps {
267 pub name: String,
269 #[props(default)]
271 pub email: Option<String>,
272 #[props(default)]
274 pub avatar: Option<String>,
275 #[props(default)]
277 pub menu_items: Vec<UserMenuItem>,
278}
279
280#[derive(Clone, PartialEq)]
282pub struct UserMenuItem {
283 pub label: String,
284 pub icon: Option<String>,
285 pub onclick: Option<EventHandler<()>>,
286}
287
288#[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 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#[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}