yew_nav_link/
nav_link.rs

1//! Navigation link component with automatic active state detection.
2//!
3//! This module provides the [`NavLink`] component and [`nav_link`] helper
4//! function for building navigation menus in Yew applications. The component
5//! automatically detects when its target route matches the current URL and
6//! applies an `active` CSS class accordingly.
7//!
8//! # Features
9//!
10//! - **Automatic Active State**: Compares the target route against the current
11//!   route and applies the `active` class when they match.
12//! - **Type-Safe Routing**: Leverages Yew Router's [`Routable`] trait for
13//!   compile-time route validation.
14//! - **Flexible Children**: Accepts any valid Yew children, including text,
15//!   HTML elements, or other components.
16//! - **CSS Integration**: Renders with `nav-link` base class, compatible with
17//!   Bootstrap and similar CSS frameworks.
18//!
19//! # CSS Classes
20//!
21//! | Class | Condition |
22//! |-------|-----------|
23//! | `nav-link` | Always applied |
24//! | `active` | Applied when the target route matches the current route |
25//!
26//! # Match Modes
27//!
28//! NavLink supports two matching modes via the `partial` prop:
29//!
30//! - **Exact** (default): Link is active only when paths match exactly
31//! - **Partial**: Link is active when current path starts with target path
32//!
33//! ```rust
34//! use yew::prelude::*;
35//! use yew_nav_link::NavLink;
36//! use yew_router::prelude::*;
37//!
38//! #[derive(Clone, PartialEq, Routable)]
39//! enum Route {
40//!     #[at("/docs")]
41//!     Docs,
42//!     #[at("/docs/api")]
43//!     DocsApi
44//! }
45//!
46//! #[component]
47//! fn Navigation() -> Html {
48//!     html! {
49//!         <nav>
50//!             // Exact: active only on /docs
51//!             <NavLink<Route> to={Route::Docs}>{ "Docs" }</NavLink<Route>>
52//!             // Partial: active on /docs, /docs/api, /docs/*
53//!             <NavLink<Route> to={Route::Docs} partial=true>{ "Docs" }</NavLink<Route>>
54//!         </nav>
55//!     }
56//! }
57//! ```
58//!
59//! # Function Syntax
60//!
61//! For text-only links, use [`nav_link`] with explicit [`Match`] mode:
62//!
63//! ```rust
64//! use yew::prelude::*;
65//! use yew_nav_link::{Match, nav_link};
66//! use yew_router::prelude::*;
67//!
68//! #[derive(Clone, PartialEq, Debug, Routable)]
69//! enum Route {
70//!     #[at("/")]
71//!     Home,
72//!     #[at("/docs")]
73//!     Docs
74//! }
75//!
76//! #[component]
77//! fn Navigation() -> Html {
78//!     html! {
79//!         <ul class="nav">
80//!             <li>{ nav_link(Route::Home, "Home", Match::Exact) }</li>
81//!             <li>{ nav_link(Route::Docs, "Docs", Match::Partial) }</li>
82//!         </ul>
83//!     }
84//! }
85//! ```
86
87use std::marker::PhantomData;
88
89use yew::prelude::*;
90use yew_router::prelude::*;
91
92/// Path matching strategy for NavLink active state detection.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub enum Match {
95    /// Link is active only when paths match exactly.
96    #[default]
97    Exact,
98    /// Link is active when current path starts with target path (segment-wise).
99    Partial
100}
101
102/// Properties for the [`NavLink`] component.
103#[derive(Properties, PartialEq, Debug)]
104pub struct NavLinkProps<R: Routable + PartialEq + Clone + 'static> {
105    /// Target route for navigation.
106    pub to: R,
107
108    /// Content rendered inside the link element.
109    pub children: Children,
110
111    /// Enable partial (prefix) path matching.
112    ///
113    /// When `false` (default), the link is active only on exact path match.
114    /// When `true`, the link is active if current path starts with target path.
115    #[prop_or(false)]
116    pub partial: bool,
117
118    #[prop_or_default]
119    pub(crate) _marker: PhantomData<R>
120}
121
122/// Navigation link with automatic active state detection.
123///
124/// # CSS Classes
125///
126/// - `nav-link` - Always applied
127/// - `active` - Applied when route matches current URL
128///
129/// # Example
130///
131/// ```rust
132/// use yew::prelude::*;
133/// use yew_nav_link::NavLink;
134/// use yew_router::prelude::*;
135///
136/// #[derive(Clone, PartialEq, Routable)]
137/// enum Route {
138///     #[at("/")]
139///     Home,
140///     #[at("/about")]
141///     About
142/// }
143///
144/// #[component]
145/// fn Navigation() -> Html {
146///     html! {
147///         <nav>
148///             <NavLink<Route> to={Route::Home}>{ "Home" }</NavLink<Route>>
149///             <NavLink<Route> to={Route::About}>{ "About" }</NavLink<Route>>
150///         </nav>
151///     }
152/// }
153/// ```
154#[component]
155pub fn NavLink<R: Routable + PartialEq + Clone + 'static>(props: &NavLinkProps<R>) -> Html {
156    let current_route = use_route::<R>();
157    let is_active = current_route.is_some_and(|route| {
158        if props.partial {
159            is_path_prefix(&props.to.to_path(), &route.to_path())
160        } else {
161            route == props.to
162        }
163    });
164
165    html! {
166        <Link<R> to={props.to.clone()} classes={classes!(build_class(is_active))}>
167            { for props.children.iter() }
168        </Link<R>>
169    }
170}
171
172/// Creates a NavLink with the specified match mode.
173///
174/// # Arguments
175///
176/// * `to` - Target route
177/// * `children` - Link text
178/// * `match_mode` - [`Match::Exact`] or [`Match::Partial`]
179///
180/// # Example
181///
182/// ```rust
183/// use yew::prelude::*;
184/// use yew_nav_link::{Match, nav_link};
185/// use yew_router::prelude::*;
186///
187/// #[derive(Clone, PartialEq, Debug, Routable)]
188/// enum Route {
189///     #[at("/")]
190///     Home,
191///     #[at("/docs")]
192///     Docs
193/// }
194///
195/// #[component]
196/// fn Menu() -> Html {
197///     html! {
198///         <nav>
199///             { nav_link(Route::Home, "Home", Match::Exact) }
200///             { nav_link(Route::Docs, "Docs", Match::Partial) }
201///         </nav>
202///     }
203/// }
204/// ```
205pub fn nav_link<R: Routable + PartialEq + Clone + 'static>(
206    to: R,
207    children: &str,
208    match_mode: Match
209) -> Html {
210    let partial = match_mode == Match::Partial;
211    html! {
212        <NavLink<R> to={to} {partial}>{ Html::from(children) }</NavLink<R>>
213    }
214}
215
216/// Checks if `target` path is a segment-wise prefix of `current` path.
217///
218/// Uses iterators without heap allocation for efficiency during renders.
219///
220/// # Examples
221///
222/// ```text
223/// is_path_prefix("/docs", "/docs/api")  -> true
224/// is_path_prefix("/docs", "/docs")      -> true
225/// is_path_prefix("/doc", "/documents")  -> false (segment boundary)
226/// is_path_prefix("/", "/anything")      -> true
227/// ```
228#[inline]
229fn is_path_prefix(target: &str, current: &str) -> bool {
230    let mut target_iter = target.split('/').filter(|s| !s.is_empty());
231    let mut current_iter = current.split('/').filter(|s| !s.is_empty());
232
233    loop {
234        match (target_iter.next(), current_iter.next()) {
235            (Some(t), Some(c)) if t == c => continue,
236            (Some(_), Some(_)) => return false,
237            (Some(_), None) => return false,
238            (None, _) => return true
239        }
240    }
241}
242
243#[inline]
244fn build_class(is_active: bool) -> &'static str {
245    if is_active {
246        "nav-link active"
247    } else {
248        "nav-link"
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[derive(Clone, PartialEq, Debug, Routable)]
257    enum TestRoute {
258        #[at("/")]
259        Home,
260        #[at("/about")]
261        About,
262        #[at("/docs")]
263        Docs,
264        #[at("/docs/api")]
265        DocsApi
266    }
267
268    // Match enum tests
269    #[test]
270    fn match_default_is_exact() {
271        assert_eq!(Match::default(), Match::Exact);
272    }
273
274    #[test]
275    fn match_equality() {
276        assert_eq!(Match::Exact, Match::Exact);
277        assert_eq!(Match::Partial, Match::Partial);
278        assert_ne!(Match::Exact, Match::Partial);
279    }
280
281    #[test]
282    fn match_debug() {
283        assert_eq!(format!("{:?}", Match::Exact), "Exact");
284        assert_eq!(format!("{:?}", Match::Partial), "Partial");
285    }
286
287    #[test]
288    fn match_clone() {
289        let m = Match::Partial;
290        let cloned = m;
291        assert_eq!(m, cloned);
292    }
293
294    // build_class tests
295    #[test]
296    fn build_class_active() {
297        assert_eq!(build_class(true), "nav-link active");
298    }
299
300    #[test]
301    fn build_class_inactive() {
302        assert_eq!(build_class(false), "nav-link");
303    }
304
305    // NavLinkProps tests
306    #[test]
307    fn props_equality_same() {
308        let props1: NavLinkProps<TestRoute> = NavLinkProps {
309            to:       TestRoute::Home,
310            children: Default::default(),
311            partial:  false,
312            _marker:  PhantomData
313        };
314        let props2: NavLinkProps<TestRoute> = NavLinkProps {
315            to:       TestRoute::Home,
316            children: Default::default(),
317            partial:  false,
318            _marker:  PhantomData
319        };
320        assert_eq!(props1, props2);
321    }
322
323    #[test]
324    fn props_equality_different_route() {
325        let props1: NavLinkProps<TestRoute> = NavLinkProps {
326            to:       TestRoute::Home,
327            children: Default::default(),
328            partial:  false,
329            _marker:  PhantomData
330        };
331        let props2: NavLinkProps<TestRoute> = NavLinkProps {
332            to:       TestRoute::About,
333            children: Default::default(),
334            partial:  false,
335            _marker:  PhantomData
336        };
337        assert_ne!(props1, props2);
338    }
339
340    #[test]
341    fn props_equality_different_partial() {
342        let props1: NavLinkProps<TestRoute> = NavLinkProps {
343            to:       TestRoute::Home,
344            children: Default::default(),
345            partial:  false,
346            _marker:  PhantomData
347        };
348        let props2: NavLinkProps<TestRoute> = NavLinkProps {
349            to:       TestRoute::Home,
350            children: Default::default(),
351            partial:  true,
352            _marker:  PhantomData
353        };
354        assert_ne!(props1, props2);
355    }
356
357    #[test]
358    fn props_debug() {
359        let props: NavLinkProps<TestRoute> = NavLinkProps {
360            to:       TestRoute::Home,
361            children: Default::default(),
362            partial:  false,
363            _marker:  PhantomData
364        };
365        let debug = format!("{:?}", props);
366        assert!(debug.contains("NavLinkProps"));
367        assert!(debug.contains("Home"));
368    }
369
370    // nav_link function tests
371    #[test]
372    fn nav_link_exact_returns_html() {
373        let html = nav_link(TestRoute::Home, "Home", Match::Exact);
374        assert!(matches!(html, Html::VComp(_)));
375    }
376
377    #[test]
378    fn nav_link_partial_returns_html() {
379        let html = nav_link(TestRoute::Docs, "Docs", Match::Partial);
380        assert!(matches!(html, Html::VComp(_)));
381    }
382
383    #[test]
384    fn nav_link_different_routes() {
385        let h1 = nav_link(TestRoute::Home, "Home", Match::Exact);
386        let h2 = nav_link(TestRoute::About, "About", Match::Exact);
387        assert!(matches!(h1, Html::VComp(_)));
388        assert!(matches!(h2, Html::VComp(_)));
389    }
390
391    #[test]
392    fn nav_link_empty_text() {
393        let html = nav_link(TestRoute::Home, "", Match::Exact);
394        assert!(matches!(html, Html::VComp(_)));
395    }
396
397    // is_path_prefix tests - exact matches
398    #[test]
399    fn prefix_exact_match() {
400        assert!(is_path_prefix("/", "/"));
401        assert!(is_path_prefix("/docs", "/docs"));
402        assert!(is_path_prefix("/docs/api", "/docs/api"));
403    }
404
405    // is_path_prefix tests - valid prefixes
406    #[test]
407    fn prefix_valid() {
408        assert!(is_path_prefix("/docs", "/docs/api"));
409        assert!(is_path_prefix("/docs", "/docs/api/ref"));
410        assert!(is_path_prefix("/a", "/a/b/c/d"));
411    }
412
413    #[test]
414    fn prefix_root_matches_all() {
415        assert!(is_path_prefix("/", "/docs"));
416        assert!(is_path_prefix("/", "/docs/api"));
417        assert!(is_path_prefix("/", "/any/path/here"));
418    }
419
420    // is_path_prefix tests - not prefixes
421    #[test]
422    fn prefix_not_prefix() {
423        assert!(!is_path_prefix("/docs/api", "/docs"));
424        assert!(!is_path_prefix("/about", "/docs"));
425        assert!(!is_path_prefix("/a/b/c", "/a/b"));
426    }
427
428    #[test]
429    fn prefix_segment_boundary() {
430        assert!(!is_path_prefix("/doc", "/documents"));
431        assert!(!is_path_prefix("/api", "/api-v2"));
432        assert!(!is_path_prefix("/user", "/users"));
433    }
434
435    // is_path_prefix tests - edge cases
436    #[test]
437    fn prefix_trailing_slashes() {
438        assert!(is_path_prefix("/docs/", "/docs/api"));
439        assert!(is_path_prefix("/docs", "/docs/api/"));
440        assert!(is_path_prefix("/docs/", "/docs/"));
441    }
442
443    #[test]
444    fn prefix_multiple_slashes() {
445        assert!(is_path_prefix("/docs//", "/docs/api"));
446        assert!(is_path_prefix("//docs", "/docs//api"));
447    }
448
449    #[test]
450    fn prefix_empty_paths() {
451        assert!(is_path_prefix("", "/docs"));
452        assert!(is_path_prefix("", ""));
453        assert!(!is_path_prefix("/docs", ""));
454    }
455
456    // Route tests
457    #[test]
458    fn route_equality() {
459        assert_eq!(TestRoute::Home, TestRoute::Home);
460        assert_ne!(TestRoute::Home, TestRoute::About);
461    }
462
463    #[test]
464    fn route_to_path() {
465        assert_eq!(TestRoute::Home.to_path(), "/");
466        assert_eq!(TestRoute::About.to_path(), "/about");
467        assert_eq!(TestRoute::Docs.to_path(), "/docs");
468        assert_eq!(TestRoute::DocsApi.to_path(), "/docs/api");
469    }
470}