Skip to main content

yggdrasil/components/
header.rs

1//! 顶部导航栏组件
2//!
3//! 提供站点 Logo、响应式导航菜单项与右侧自定义内容区,
4//! 支持前台布局与后台布局复用,并包含小屏幕下的汉堡菜单。
5
6use dioxus::prelude::*;
7use dioxus::router::components::Link;
8
9use crate::router::Route;
10
11/// 导航项配置,用于描述 Header 中的一个链接。
12///
13/// 字段:
14/// - `route`:目标路由
15/// - `label`:显示文本
16/// - `is_active`:当前是否处于激活状态
17#[derive(Clone, PartialEq)]
18pub struct NavItemConfig {
19    /// 目标路由。
20    pub route: Route,
21    /// 显示文本。
22    pub label: &'static str,
23    /// 当前是否处于激活状态。
24    pub is_active: bool,
25}
26
27/// 顶部导航栏组件。
28///
29/// Props:
30/// - `nav_items`:导航项列表
31/// - `right_content`:右侧自定义内容(如主题切换、登出按钮)
32/// - `max_width`:内部导航的宽度类,需与正文 `max-w-*` 一致以保证左右边缘对齐。
33///   默认 `max-w-3xl`(前台阅读宽度);后台传 `max-w-5xl` 与之同宽。
34#[component]
35pub fn Header(
36    nav_items: Vec<NavItemConfig>,
37    right_content: Element,
38    #[props(default = "max-w-3xl")] max_width: &'static str,
39) -> Element {
40    let mut mobile_open = use_signal(|| false);
41    // D12:对常量字符串做 use_memo 是无谓的 memo+String 分配,改用 &'static str。
42    let menu_id: &str = "mobile-nav-menu";
43
44    let is_open = mobile_open();
45    let burger_icon_class = if is_open {
46        "w-6 h-6 absolute transition-all duration-300 transform rotate-90 opacity-0 scale-75"
47    } else {
48        "w-6 h-6 absolute transition-all duration-300 transform rotate-0 opacity-100 scale-100"
49    };
50    let close_icon_class = if is_open {
51        "w-6 h-6 absolute transition-all duration-300 transform rotate-0 opacity-100 scale-100"
52    } else {
53        "w-6 h-6 absolute transition-all duration-300 transform -rotate-90 opacity-0 scale-75"
54    };
55    let panel_class = if is_open {
56        "mobile-nav-panel md:hidden bg-paper-theme/95 backdrop-blur-sm is-open"
57    } else {
58        "mobile-nav-panel md:hidden bg-paper-theme/95 backdrop-blur-sm"
59    };
60    rsx! {
61        header { class: "sticky top-0 z-40 w-full bg-[var(--color-paper-theme)]/70 backdrop-blur-md transition-all duration-300",
62            nav { class: "{max_width} mx-auto px-6 h-16 flex items-center justify-between",
63                Link {
64                    class: "text-2xl font-extrabold tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors duration-200",
65                    to: Route::Home {},
66                    "Yggdrasil"
67                }
68                div { class: "flex items-center gap-2",
69                    // 桌面端导航
70                    ul { class: "hidden md:flex items-center gap-1",
71                        for item in nav_items.iter().cloned() {
72                            NavItem {
73                                key: "{item.label}",
74                                route: item.route,
75                                label: item.label,
76                                is_active: item.is_active,
77                            }
78                        }
79                    }
80
81                    {right_content}
82
83                    // 移动端汉堡菜单按钮
84                    button {
85                        class: "md:hidden p-2 rounded-lg text-paper-secondary hover:text-paper-primary hover:bg-paper-entry transition-colors relative flex items-center justify-center w-10 h-10 overflow-hidden",
86                        r#type: "button",
87                        aria_label: if is_open { "关闭导航菜单" } else { "打开导航菜单" },
88                        aria_expanded: is_open,
89                        aria_controls: menu_id,
90                        onclick: move |_| mobile_open.set(!mobile_open()),
91                        // 汉堡图标
92                        svg {
93                            class: "{burger_icon_class}",
94                            fill: "none",
95                            stroke: "currentColor",
96                            view_box: "0 0 24 24",
97                            path {
98                                stroke_linecap: "round",
99                                stroke_linejoin: "round",
100                                stroke_width: "2",
101                                d: "M4 6h16M4 12h16M4 18h16",
102                            }
103                        }
104                        // 关闭图标(X)
105                        svg {
106                            class: "{close_icon_class}",
107                            fill: "none",
108                            stroke: "currentColor",
109                            view_box: "0 0 24 24",
110                            path {
111                                stroke_linecap: "round",
112                                stroke_linejoin: "round",
113                                stroke_width: "2",
114                                d: "M6 18L18 6M6 6l12 12",
115                            }
116                        }
117                    }
118                }
119            }
120
121            // 移动端导航面板(常驻 DOM 以支持展开/折叠双向平滑动画)
122            div { id: menu_id, class: "{panel_class}",
123                div { class: "mobile-nav-content",
124                    ul { class: "py-2 px-6 space-y-1",
125                        for item in nav_items.iter().cloned() {
126                            li { key: "{item.label}", class: "mobile-nav-item",
127                                MobileNavItem {
128                                    route: item.route,
129                                    label: item.label,
130                                    is_active: item.is_active,
131                                    on_navigate: move |_| mobile_open.set(false),
132                                }
133                            }
134                        }
135                    }
136                }
137            }
138        }
139    }
140}
141
142/// 单个桌面导航项组件,根据 `is_active` 切换高亮样式。
143#[component]
144fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element {
145    let base_class = "px-3 py-1 text-base rounded-lg transition-all duration-200";
146    let class_str = if is_active {
147        format!("{} font-medium text-paper-accent underline underline-offset-[0.3rem] decoration-2 decoration-paper-accent", base_class)
148    } else {
149        format!(
150            "{} text-paper-secondary hover:text-paper-primary",
151            base_class
152        )
153    };
154
155    rsx! {
156        li {
157            Link { class: "{class_str}", to: route, "{label}" }
158        }
159    }
160}
161
162/// 单个移动端导航项组件,点击后关闭菜单。
163#[component]
164fn MobileNavItem(
165    route: Route,
166    label: &'static str,
167    is_active: bool,
168    on_navigate: EventHandler<()>,
169) -> Element {
170    let class_str = if is_active {
171        "block w-full px-3 py-2 text-base font-medium text-paper-accent rounded-lg bg-paper-entry transition-colors"
172    } else {
173        "block w-full px-3 py-2 text-base text-paper-secondary hover:text-paper-primary hover:bg-paper-entry rounded-lg transition-colors"
174    };
175
176    rsx! {
177        Link {
178            class: "{class_str}",
179            to: route,
180            onclick: move |_| on_navigate.call(()),
181            "{label}"
182        }
183    }
184}
185
186/// 搜索图标链接:置于 Header 右侧(主题切换左边),点击跳转搜索页。
187///
188/// 样式与 `ThemeToggle` 对齐(圆形 padding + currentColor 图标),保持右侧
189/// 图标组视觉一致。SVG 来自 `public/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg`,
190/// 改用 `fill: "currentColor"` 以适配明暗主题。
191#[component]
192pub fn SearchIconLink() -> Element {
193    rsx! {
194        Link {
195            class: "p-2 rounded-full text-paper-secondary hover:text-paper-accent transition-colors duration-200",
196            to: Route::Search {},
197            aria_label: "搜索",
198            title: "搜索",
199            svg {
200                xmlns: "http://www.w3.org/2000/svg",
201                height: "24px",
202                view_box: "0 -960 960 960",
203                width: "24px",
204                fill: "currentColor",
205                path { d: "M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z" }
206            }
207        }
208    }
209}