Skip to main content

yggdrasil/components/
header.rs

1//! 顶部导航栏组件
2//!
3//! 文字标、栏目导航(当前项为浮起的胶囊)与右侧操作区;767px 以下只留文字标、
4//! 搜索与菜单按钮,菜单为底部抽屉(栏目、外观与次要链接)。样式见
5//! styles/components/shell.css。
6//!
7//! 链接一律用普通 `<a href>`:JS 视图过渡协调器接管站内点击;不用
8//! `leptos_router::components::A`,因为它会按前缀自动写 `aria-current`,
9//! 与这里按栏目家族计算的激活态冲突(`/` 会对所有页面高亮)。
10
11use 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
19/// 手机导航抽屉的 DOM ID;预载回归脚本按 `aria-controls` 定位菜单按钮。
20const MENU_ID: &str = "mobile-nav-menu";
21
22/// 导航项配置,用于描述 Header 中的一个链接。
23///
24/// 字段:
25/// - `route`:目标路由
26/// - `label`:显示文本
27/// - `is_active`:当前是否处于激活状态
28#[derive(Debug, Clone, PartialEq)]
29pub struct NavItemConfig {
30    /// 目标路由。
31    pub route: Route,
32    /// 显示文本。
33    pub label: &'static str,
34    /// 当前是否处于激活状态。
35    pub is_active: bool,
36}
37
38/// 某个栏目(按 `label` 区分)当前是否处于激活状态。
39///
40/// 栏目列表本身固定,随路径变化的只有激活态;各栏目元素按 `label` 保持同一个 DOM 节点,
41/// 激活态原地切换(`aria-current` 与 `.nav-indicator` 胶囊),与转场脚本测量胶囊的方式一致。
42fn 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/// 顶部导航栏组件。
53///
54/// Props:
55/// - `nav_items`:导航项列表(随路径变化)
56/// - `right_content`:右侧自定义内容(如搜索入口、主题切换)
57/// - `max_width`:内部导航的宽度类,需与正文 `max-w-*` 一致以保证左右边缘对齐
58#[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/// 单个桌面导航项:当前项内含 `.nav-indicator` 胶囊(转场时只有胶囊在栏目间滑动,文字留在原位)。
135#[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/// 手机导航抽屉:底部面板,含栏目、外观分段控件与次要链接。选中栏目或链接后关闭。
158#[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            // rel="external":让路由协调器放行,整页加载 RSS。
204            <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/// 抽屉中的栏目链接(48px 触控目标),点击后关闭抽屉。
218#[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/// 顶部导航栏右侧的搜索入口:「搜索 ⌘K」小胶囊。
238///
239/// 默认跳转搜索页并随路径感知激活状态;`active` 可显式覆盖,`on_click` 传入时
240/// 拦截默认跳转,用于打开搜索面板或图鉴演示。
241#[component]
242pub fn SearchIconLink(
243    /// 可选显式覆盖激活状态。默认随当前路径自动感知(`/search`)。
244    #[prop(optional)]
245    active: Option<bool>,
246    /// 可选点击事件回调。若提供,将拦截默认路由跳转。
247    #[prop(optional, into)]
248    on_click: Option<Callback<MouseEvent>>,
249) -> impl IntoView {
250    // 显式覆盖时不读路由,组件可脱离 Router 使用(图鉴演示、测试)。
251    // 与导航激活项一样跟随已显示的页面,不跟地址栏。
252    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                // 传入 on_click 时只执行回调;prevent_default 让协调器跳过这次点击。
267                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        // 只有「归档」带 aria-current 与胶囊。
306        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        // 无搜索面板句柄时,搜索图标退化为指向 /search 的链接。
313        assert!(html.contains("href=\"/search\""));
314        assert!(html.contains("id=\"right-slot\""));
315        // 抽屉未打开:只渲染触发按钮。
316        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}