yggdrasil/components/
header.rs1use leptos::children::ViewFn;
12use leptos::ev::MouseEvent;
13use leptos::prelude::*;
14
15use crate::components::ui::{use_sheet_close, BottomSheet, Icon, IconName};
16use crate::routes::Route;
17use crate::theme::ThemeToggle;
18
19const MENU_ID: &str = "mobile-nav-menu";
21
22#[derive(Debug, Clone, PartialEq)]
29pub struct NavItemConfig {
30 pub route: Route,
32 pub label: &'static str,
34 pub is_active: bool,
36}
37
38fn item_active(nav_items: Signal<Vec<NavItemConfig>>, label: &'static str) -> Signal<bool> {
43 Signal::derive(move || {
44 nav_items.with(|items| {
45 items
46 .iter()
47 .any(|item| item.label == label && item.is_active)
48 })
49 })
50}
51
52#[component]
59pub fn Header(
60 #[prop(into)] nav_items: Signal<Vec<NavItemConfig>>,
61 #[prop(into)] right_content: ViewFn,
62 #[prop(default = "max-w-3xl")] max_width: &'static str,
63) -> impl IntoView {
64 let sheet_open = RwSignal::new(false);
65 let menu_button_id = format!("{MENU_ID}-trigger");
66 let palette = crate::components::palette::use_search_palette();
67
68 view! {
69 <header data-vt-shell="frontend-header" class="site-header">
70 <div class=format!("site-header-inner {max_width}")>
71 <a class="wordmark" href=Route::Home.to_string()>
72 <span class="wordmark-mark" aria-hidden="true"></span>
73 "Yggdrasil"
74 </a>
75 <nav class="site-nav" aria-label="主导航">
76 <For
77 each=move || nav_items.get()
78 key=|item| item.label
79 children=move |item| {
80 let is_active = item_active(nav_items, item.label);
81 view! { <NavItem route=item.route label=item.label is_active=is_active/> }
82 }
83 />
84 </nav>
85 <div class="site-actions">
86 {right_content.run()}
87 {match palette {
88 Some(palette) => {
89 view! {
90 <button
91 class="touch-icon"
92 type="button"
93 aria-label="搜索"
94 aria-haspopup="dialog"
95 on:click=move |_| palette.open()
96 >
97 <Icon name=IconName::Search size=19/>
98 </button>
99 }
100 .into_any()
101 }
102 None => {
103 view! {
104 <a
105 class="touch-icon"
106 href=Route::Search { q: String::new() }.to_string()
107 aria-label="搜索"
108 >
109 <Icon name=IconName::Search size=19/>
110 </a>
111 }
112 .into_any()
113 }
114 }}
115 <button
116 id=menu_button_id.clone()
117 class="touch-icon"
118 type="button"
119 aria-label="打开导航"
120 aria-haspopup="dialog"
121 aria-expanded=move || sheet_open.get().to_string()
122 aria-controls=MENU_ID
123 on:click=move |_| sheet_open.set(true)
124 >
125 <Icon name=IconName::Menu size=19/>
126 </button>
127 </div>
128 </div>
129 </header>
130 <NavSheet open=sheet_open id=MENU_ID trigger_id=menu_button_id nav_items=nav_items/>
131 }
132}
133
134#[component]
136fn NavItem(
137 route: Route,
138 label: &'static str,
139 #[prop(into)] is_active: Signal<bool>,
140) -> impl IntoView {
141 view! {
142 <a
143 class="nav-item"
144 href=route.to_string()
145 aria-current=move || is_active.get().then_some("page")
146 >
147 {move || {
148 is_active
149 .get()
150 .then(|| view! { <span class="nav-indicator" aria-hidden="true"></span> })
151 }}
152 {label}
153 </a>
154 }
155}
156
157#[component]
159fn NavSheet(
160 open: RwSignal<bool>,
161 id: &'static str,
162 #[prop(into)] trigger_id: String,
163 nav_items: Signal<Vec<NavItemConfig>>,
164) -> impl IntoView {
165 view! {
166 <BottomSheet open=open id=id trigger_id=trigger_id label="导航">
167 <NavSheetBody nav_items=nav_items/>
168 </BottomSheet>
169 }
170}
171
172#[component]
173fn NavSheetBody(nav_items: Signal<Vec<NavItemConfig>>) -> impl IntoView {
174 let sheet = use_sheet_close();
175 view! {
176 <nav aria-label="栏目">
177 <ul class="flex flex-col gap-0.5">
178 <For
179 each=move || nav_items.get()
180 key=|item| item.label
181 children=move |item| {
182 let is_active = item_active(nav_items, item.label);
183 view! {
184 <li>
185 <SheetNavItem
186 route=item.route
187 label=item.label
188 is_active=is_active
189 on_navigate=move |_: ()| sheet.close()
190 />
191 </li>
192 }
193 }
194 />
195 </ul>
196 </nav>
197 <div class="my-3 mx-1 h-(--hairline) bg-hairline"></div>
198 <div class="sheet-row">
199 <span>"外观"</span>
200 <ThemeToggle/>
201 </div>
202 <div class="sheet-links">
203 <a href="/feed.xml" rel="external">
205 "RSS"
206 </a>
207 <a href=Route::Changelog.to_string() on:click=move |_| sheet.close()>
208 "更新日志"
209 </a>
210 <a href=Route::WritingGuide.to_string() on:click=move |_| sheet.close()>
211 "写作指南"
212 </a>
213 </div>
214 }
215}
216
217#[component]
219fn SheetNavItem(
220 route: Route,
221 label: &'static str,
222 #[prop(into)] is_active: Signal<bool>,
223 #[prop(into)] on_navigate: Callback<()>,
224) -> impl IntoView {
225 view! {
226 <a
227 class="sheet-nav-item"
228 href=route.to_string()
229 aria-current=move || is_active.get().then_some("page")
230 on:click=move |_| on_navigate.run(())
231 >
232 {label}
233 </a>
234 }
235}
236
237#[component]
242pub fn SearchIconLink(
243 #[prop(optional)]
245 active: Option<bool>,
246 #[prop(optional, into)]
248 on_click: Option<Callback<MouseEvent>>,
249) -> impl IntoView {
250 let route = active.is_none().then(crate::navigation::use_rendered_route);
253 let is_active = move || match (active, route) {
254 (Some(active), _) => active,
255 (None, Some(route)) => route.with(|r| r.pathname.trim_end_matches('/') == "/search"),
256 (None, None) => false,
257 };
258 view! {
259 <a
260 class="search-trigger"
261 href=Route::Search { q: String::new() }.to_string()
262 aria-current=move || is_active().then_some("page")
263 aria-label="搜索"
264 aria-keyshortcuts="Meta+K Control+K"
265 on:click=move |ev: MouseEvent| {
266 if let Some(cb) = on_click {
268 ev.prevent_default();
269 cb.run(ev);
270 }
271 }
272 >
273 <Icon name=IconName::Search size=15/>
274 <span>"搜索"</span>
275 <kbd>"⌘K"</kbd>
276 </a>
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::components::nav::build_nav_items;
284
285 fn render_header(path: &'static str) -> String {
286 Owner::new().with(|| {
287 let items = build_nav_items(path);
288 view! {
289 <Header
290 nav_items=items
291 right_content=|| view! { <span id="right-slot"></span> }
292 max_width="max-w-[880px]"
293 />
294 }
295 .to_html()
296 })
297 }
298
299 #[test]
300 fn header_marks_only_active_item_with_indicator() {
301 let html = render_header("/archives");
302 assert!(html.contains("data-vt-shell=\"frontend-header\""));
303 assert!(html.contains("site-header-inner max-w-[880px]"));
304 assert!(html.contains("aria-label=\"主导航\""));
305 assert_eq!(html.matches("aria-current=\"page\"").count(), 1);
307 assert_eq!(html.matches("class=\"nav-indicator\"").count(), 1);
308 let active_start = html.find("aria-current=\"page\"").unwrap();
309 let tail = &html[active_start..];
310 assert!(tail.contains("nav-indicator"));
311 assert!(tail.find("nav-indicator").unwrap() < tail.find("归档").unwrap());
312 assert!(html.contains("href=\"/search\""));
314 assert!(html.contains("id=\"right-slot\""));
315 assert!(html.contains("aria-expanded=\"false\""));
317 assert!(html.contains("aria-controls=\"mobile-nav-menu\""));
318 assert!(!html.contains("class=\"sheet\""));
319 }
320
321 #[test]
322 fn header_without_match_has_no_indicator() {
323 let html = render_header("/post/hello");
324 assert!(!html.contains("nav-indicator"));
325 assert!(!html.contains("aria-current"));
326 }
327
328 #[test]
329 fn search_link_active_override() {
330 let on = Owner::new().with(|| view! { <SearchIconLink active=true/> }.to_html());
331 assert!(on.contains("aria-current=\"page\""));
332 assert!(on.contains("aria-keyshortcuts=\"Meta+K Control+K\""));
333 assert!(on.contains("href=\"/search\""));
334 let off = Owner::new().with(|| view! { <SearchIconLink active=false/> }.to_html());
335 assert!(!off.contains("aria-current"));
336 }
337}