1use dioxus::prelude::*;
7use dioxus::router::components::Link;
8
9use crate::router::Route;
10
11#[derive(Clone, PartialEq)]
18pub struct NavItemConfig {
19 pub route: Route,
21 pub label: &'static str,
23 pub is_active: bool,
25}
26
27#[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 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 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 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 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 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#[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#[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#[component]
247pub fn SearchIconLink(
248 #[props(default)]
250 active: Option<bool>,
251 #[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}