yggdrasil/components/
header.rs1use 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]
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 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 { "data-vt-shell": "frontend-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 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 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 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 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 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#[component]
144fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element {
145 let base_class =
146 "relative inline-flex px-3 py-1 text-base rounded-lg transition-colors duration-200";
147 let class_str = if is_active {
148 format!("{} font-medium text-paper-accent", base_class)
149 } else {
150 format!(
151 "{} text-paper-secondary hover:text-paper-primary",
152 base_class
153 )
154 };
155
156 rsx! {
157 li {
158 Link {
159 class: "{class_str}",
160 to: route,
161 aria_current: is_active.then_some("page"),
162 "{label}"
163 if is_active {
164 span { class: "nav-indicator", "aria-hidden": "true" }
165 }
166 }
167 }
168 }
169}
170
171#[component]
173fn MobileNavItem(
174 route: Route,
175 label: &'static str,
176 is_active: bool,
177 on_navigate: EventHandler<()>,
178) -> Element {
179 let class_str = if is_active {
180 "block w-full px-3 py-2 text-base font-medium text-paper-accent rounded-lg bg-paper-entry transition-colors"
181 } else {
182 "block w-full px-3 py-2 text-base text-paper-secondary hover:text-paper-primary hover:bg-paper-entry rounded-lg transition-colors"
183 };
184
185 rsx! {
186 Link {
187 class: "{class_str}",
188 to: route,
189 aria_current: is_active.then_some("page"),
190 onclick: move |_| on_navigate.call(()),
191 "{label}"
192 }
193 }
194}
195
196#[component]
202pub fn SearchIconLink() -> Element {
203 let is_active = matches!(use_route::<Route>(), Route::Search {});
204 let color = if is_active {
205 "text-paper-accent"
206 } else {
207 "text-paper-secondary hover:text-paper-accent"
208 };
209 rsx! {
210 Link {
211 class: "relative p-2 rounded-full {color} transition-colors duration-200",
212 to: Route::Search {},
213 aria_current: is_active.then_some("page"),
214 aria_label: "搜索",
215 title: "搜索",
216 svg {
217 xmlns: "http://www.w3.org/2000/svg",
218 height: "24px",
219 view_box: "0 -960 960 960",
220 width: "24px",
221 fill: "currentColor",
222 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" }
223 }
224 if is_active {
225 span { class: "nav-indicator hidden md:block", "aria-hidden": "true" }
226 }
227 }
228 }
229}