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/// - `menu_id`:小屏菜单的稳定 DOM ID;同页有多个 Header 时必须各不相同。
35/// - `on_demo_navigate`:图鉴用局部导航回调;正式页面省略以保留 Link 行为。
36/// - `page_shell`:图鉴实例设 false,避免被页面过渡系统识别为真实站点外壳。
37#[component]
38pub fn Header(
39    nav_items: Vec<NavItemConfig>,
40    right_content: Element,
41    #[props(default = "max-w-3xl")] max_width: &'static str,
42    #[props(default = "mobile-nav-menu")] menu_id: &'static str,
43    #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
44    #[props(default = true)] page_shell: bool,
45) -> Element {
46    let mut mobile_open = use_signal(|| false);
47
48    let is_open = mobile_open();
49    let burger_icon_class = if is_open {
50        "w-6 h-6 absolute transition-all duration-300 transform rotate-90 opacity-0 scale-75"
51    } else {
52        "w-6 h-6 absolute transition-all duration-300 transform rotate-0 opacity-100 scale-100"
53    };
54    let close_icon_class = if is_open {
55        "w-6 h-6 absolute transition-all duration-300 transform rotate-0 opacity-100 scale-100"
56    } else {
57        "w-6 h-6 absolute transition-all duration-300 transform -rotate-90 opacity-0 scale-75"
58    };
59    let panel_class = if is_open {
60        "mobile-nav-panel md:hidden bg-paper-theme/95 backdrop-blur-sm is-open"
61    } else {
62        "mobile-nav-panel md:hidden bg-paper-theme/95 backdrop-blur-sm"
63    };
64    rsx! {
65        header { "data-vt-shell": page_shell.then_some("frontend-header"), class: "sticky top-0 z-40 w-full bg-[var(--color-paper-theme)]/70 backdrop-blur-md transition-all duration-300",
66            nav { class: "{max_width} mx-auto px-6 h-16 flex items-center justify-between",
67                if let Some(on_navigate) = on_demo_navigate {
68                    button {
69                        class: "text-2xl font-extrabold tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors duration-200",
70                        r#type: "button",
71                        onclick: move |_| on_navigate.call("首页"),
72                        "Yggdrasil"
73                    }
74                } else {
75                    Link {
76                        class: "text-2xl font-extrabold tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors duration-200",
77                        to: Route::Home {},
78                        "Yggdrasil"
79                    }
80                }
81                div { class: "flex items-center gap-2",
82                    // 桌面端导航
83                    ul { class: "hidden md:flex items-center gap-1",
84                        for item in nav_items.iter().cloned() {
85                            NavItem {
86                                key: "{item.label}",
87                                route: item.route,
88                                label: item.label,
89                                is_active: item.is_active,
90                                on_demo_navigate,
91                            }
92                        }
93                    }
94
95                    {right_content}
96
97                    // 移动端汉堡菜单按钮
98                    button {
99                        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",
100                        r#type: "button",
101                        aria_label: if is_open { "关闭导航菜单" } else { "打开导航菜单" },
102                        aria_expanded: is_open,
103                        aria_controls: menu_id,
104                        onclick: move |_| mobile_open.set(!mobile_open()),
105                        // 汉堡图标
106                        svg {
107                            class: "{burger_icon_class}",
108                            fill: "none",
109                            stroke: "currentColor",
110                            view_box: "0 0 24 24",
111                            path {
112                                stroke_linecap: "round",
113                                stroke_linejoin: "round",
114                                stroke_width: "2",
115                                d: "M4 6h16M4 12h16M4 18h16",
116                            }
117                        }
118                        // 关闭图标(X)
119                        svg {
120                            class: "{close_icon_class}",
121                            fill: "none",
122                            stroke: "currentColor",
123                            view_box: "0 0 24 24",
124                            path {
125                                stroke_linecap: "round",
126                                stroke_linejoin: "round",
127                                stroke_width: "2",
128                                d: "M6 18L18 6M6 6l12 12",
129                            }
130                        }
131                    }
132                }
133            }
134
135            // 移动端导航面板(常驻 DOM 以支持展开/折叠双向平滑动画)
136            div { id: menu_id, class: "{panel_class}",
137                div { class: "mobile-nav-content",
138                    ul { class: "py-2 px-6 space-y-1",
139                        for item in nav_items.iter().cloned() {
140                            li { key: "{item.label}", class: "mobile-nav-item",
141                                MobileNavItem {
142                                    route: item.route,
143                                    label: item.label,
144                                    is_active: item.is_active,
145                                    on_demo_navigate,
146                                    on_navigate: move |_| mobile_open.set(false),
147                                }
148                            }
149                        }
150                    }
151                }
152            }
153        }
154    }
155}
156
157/// 单个桌面导航项组件,根据 `is_active` 切换高亮样式。
158#[component]
159fn NavItem(
160    route: Route,
161    label: &'static str,
162    is_active: bool,
163    on_demo_navigate: Option<EventHandler<&'static str>>,
164) -> Element {
165    let base_class =
166        "relative inline-flex px-3 py-1 text-base rounded-lg transition-colors duration-200 whitespace-nowrap shrink-0";
167    let class_str = if is_active {
168        format!("{} font-medium text-paper-accent", base_class)
169    } else {
170        format!(
171            "{} text-paper-secondary hover:text-paper-primary",
172            base_class
173        )
174    };
175
176    rsx! {
177        li {
178            if let Some(on_navigate) = on_demo_navigate {
179                button {
180                    class: "{class_str}",
181                    r#type: "button",
182                    aria_current: is_active.then_some("page"),
183                    onclick: move |_| on_navigate.call(label),
184                    "{label}"
185                    if is_active { span { class: "nav-indicator", "aria-hidden": "true" } }
186                }
187            } else {
188                Link {
189                    class: "{class_str}",
190                    to: route,
191                    aria_current: is_active.then_some("page"),
192                    "{label}"
193                    if is_active { span { class: "nav-indicator", "aria-hidden": "true" } }
194                }
195            }
196        }
197    }
198}
199
200/// 单个移动端导航项组件,点击后关闭菜单。
201#[component]
202fn MobileNavItem(
203    route: Route,
204    label: &'static str,
205    is_active: bool,
206    on_demo_navigate: Option<EventHandler<&'static str>>,
207    on_navigate: EventHandler<()>,
208) -> Element {
209    let class_str = if is_active {
210        "block w-full px-3 py-2 text-base font-medium text-paper-accent rounded-lg bg-paper-entry transition-colors"
211    } else {
212        "block w-full px-3 py-2 text-base text-paper-secondary hover:text-paper-primary hover:bg-paper-entry rounded-lg transition-colors"
213    };
214
215    rsx! {
216        if let Some(demo_navigate) = on_demo_navigate {
217            button {
218                class: "{class_str}",
219                r#type: "button",
220                aria_current: is_active.then_some("page"),
221                onclick: move |_| {
222                    demo_navigate.call(label);
223                    on_navigate.call(());
224                },
225                "{label}"
226            }
227        } else {
228            Link {
229                class: "{class_str}",
230                to: route,
231                aria_current: is_active.then_some("page"),
232                onclick: move |_| on_navigate.call(()),
233                "{label}"
234            }
235        }
236    }
237}
238
239/// 搜索图标链接:置于 Header 右侧(主题切换左边),点击跳转搜索页。
240/// 顶部导航栏右侧的搜索入口链接。
241///
242/// 默认自动根据全局路由匹配判断激活状态;支持通过 `active` 属性显式覆盖,
243/// 以及通过 `on_click` 拦截默认跳转行为以支持弹层或自定义检索交互。
244/// 样式与 `ThemeToggle` 对齐(圆形 padding + currentColor 图标),保持右侧
245/// 图标组视觉一致。图标采用项目标准线性放大镜(与 `/search` 页面风格一致)。
246#[component]
247pub fn SearchIconLink(
248    /// 可选显式覆盖激活状态。默认为随全局路由自动感知 (`Route::Search`).
249    #[props(default)]
250    active: Option<bool>,
251    /// 可选点击事件回调。若提供,将拦截默认路由跳转。
252    #[props(default)]
253    on_click: Option<EventHandler<MouseEvent>>,
254) -> Element {
255    let route_active = matches!(use_route::<Route>(), Route::Search {});
256    let is_active = active.unwrap_or(route_active);
257    let color = if is_active {
258        "text-paper-accent"
259    } else {
260        "text-paper-secondary hover:text-paper-accent"
261    };
262    rsx! {
263        Link {
264            class: "relative p-2 rounded-full {color} transition-colors duration-200",
265            to: Route::Search {},
266            aria_current: is_active.then_some("page"),
267            aria_label: "搜索",
268            title: "搜索",
269            onclick: move |e: MouseEvent| {
270                if let Some(ref handler) = on_click {
271                    e.prevent_default();
272                    handler.call(e);
273                }
274            },
275            svg {
276                xmlns: "http://www.w3.org/2000/svg",
277                class: "w-6 h-6 shrink-0",
278                view_box: "0 0 24 24",
279                fill: "none",
280                stroke: "currentColor",
281                stroke_width: "1.6",
282                stroke_linecap: "round",
283                stroke_linejoin: "round",
284                "aria-hidden": "true",
285                circle { cx: "10.75", cy: "10.75", r: "6.75" }
286                path { d: "m16 16 4.5 4.5" }
287            }
288            if is_active {
289                span { class: "nav-indicator hidden md:block", "aria-hidden": "true" }
290            }
291        }
292    }
293}