1use std::marker::PhantomData;
88
89use yew::prelude::*;
90use yew_router::prelude::*;
91
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub enum Match {
95 #[default]
97 Exact,
98 Partial
100}
101
102#[derive(Properties, PartialEq, Debug)]
104pub struct NavLinkProps<R: Routable + PartialEq + Clone + 'static> {
105 pub to: R,
107
108 pub children: Children,
110
111 #[prop_or(false)]
116 pub partial: bool,
117
118 #[prop_or_default]
119 pub(crate) _marker: PhantomData<R>
120}
121
122#[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
172pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}