Skip to main content

yew_nav_link/hooks/route_info/
breadcrumbs.rs

1// SPDX-FileCopyrightText: 2024-2026 RAprogramm <andrey.rozanov-vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::rc::Rc;
5
6use yew::prelude::*;
7use yew_router::prelude::*;
8
9/// A trait for providing custom breadcrumb labels.
10pub trait BreadcrumbLabelProvider: Send + Sync {
11    /// Returns a human-readable label for the given path.
12    fn label_for_path(&self, path: &str) -> String;
13}
14
15/// Yew context wrapper around a [`BreadcrumbLabelProvider`].
16///
17/// Place an instance into the tree with `<ContextProvider<…>>` to override
18/// the default path-as-label behaviour of [`use_breadcrumbs`]. Equality is
19/// pointer-equality on the inner [`Rc`], so re-renders happen only when the
20/// concrete provider value changes.
21///
22/// The inner `Rc` is **not** publicly accessible — construct via
23/// [`BreadcrumbLabelProviderContext::new`] and read with
24/// [`BreadcrumbLabelProviderContext::provider`]. Keeping the field private
25/// lets future versions evolve the representation (e.g. a provider chain or
26/// internal cache) without breaking consumers.
27#[derive(Clone)]
28pub struct BreadcrumbLabelProviderContext(Rc<dyn BreadcrumbLabelProvider>);
29
30impl BreadcrumbLabelProviderContext {
31    /// Wraps the given provider so it can be passed to `ContextProvider`.
32    #[must_use]
33    pub fn new(provider: Rc<dyn BreadcrumbLabelProvider>) -> Self {
34        Self(provider)
35    }
36
37    /// Returns a clone of the inner [`Rc`] for callers that need to invoke
38    /// the provider directly.
39    #[must_use]
40    pub fn provider(&self) -> Rc<dyn BreadcrumbLabelProvider> {
41        Rc::clone(&self.0)
42    }
43}
44
45impl PartialEq for BreadcrumbLabelProviderContext {
46    fn eq(&self, other: &Self) -> bool {
47        Rc::ptr_eq(&self.0, &other.0)
48    }
49}
50
51/// A single item in a breadcrumb trail.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct BreadcrumbItem<R> {
54    /// The route this breadcrumb points to.
55    pub route:     R,
56    /// Human-readable label for the breadcrumb.
57    pub label:     String,
58    /// Whether this breadcrumb represents the currently active route.
59    pub is_active: bool
60}
61
62/// Returns a list of [`BreadcrumbItem`]s representing the current navigation
63/// path.
64#[hook]
65pub fn use_breadcrumbs<R>() -> Vec<BreadcrumbItem<R>>
66where
67    R: Routable + Clone + PartialEq + 'static
68{
69    let current = use_route::<R>();
70    let provider = use_context::<BreadcrumbLabelProviderContext>();
71
72    current.map_or_else(Vec::new, |route| {
73        let path = route.to_path();
74        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
75        let mut items = Vec::new();
76        let mut built = String::new();
77        // Root
78        let root_label = provider
79            .as_ref()
80            .map_or_else(|| "/".to_string(), |p| p.0.label_for_path("/"));
81        items.push(BreadcrumbItem {
82            route:     route.clone(),
83            label:     root_label,
84            is_active: segments.is_empty()
85        });
86        // Segments
87        let total = segments.len();
88        for (i, segment) in segments.iter().enumerate() {
89            built.push('/');
90            built.push_str(segment);
91            let is_last = i + 1 == total;
92            let label = provider
93                .as_ref()
94                .map_or_else(|| built.clone(), |p| p.0.label_for_path(&built));
95            items.push(BreadcrumbItem {
96                route: route.clone(),
97                label,
98                is_active: is_last
99            });
100        }
101        items
102    })
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[derive(Clone, PartialEq, Debug, Routable)]
110    enum SimpleRoute {
111        #[at("/")]
112        Home,
113        #[at("/about")]
114        About,
115        #[at("/docs")]
116        Docs,
117        #[at("/docs/api")]
118        Api,
119        #[at("/docs/api/v1")]
120        ApiV1
121    }
122
123    #[derive(Clone, PartialEq, Debug, Routable)]
124    enum ParamRoute {
125        #[at("/")]
126        Home,
127        #[at("/users/:id")]
128        User { id: String }
129    }
130
131    #[derive(Clone, PartialEq, Debug, Routable)]
132    enum RootOnlyRoute {
133        #[at("/")]
134        Root
135    }
136
137    struct TestLabelProvider;
138
139    impl BreadcrumbLabelProvider for TestLabelProvider {
140        fn label_for_path(&self, path: &str) -> String {
141            match path {
142                "/" => "Home".to_string(),
143                "/about" => "About".to_string(),
144                "/docs" => "Docs".to_string(),
145                "/docs/api" => "API".to_string(),
146                "/docs/api/v1" => "V1".to_string(),
147                "/users/42" => "User #42".to_string(),
148                _ => path.to_string()
149            }
150        }
151    }
152
153    // ===== BreadcrumbItem tests =====
154
155    #[test]
156    fn breadcrumb_item_new() {
157        let item = BreadcrumbItem {
158            route:     SimpleRoute::Home,
159            label:     "Home".to_string(),
160            is_active: true
161        };
162        assert_eq!(item.label, "Home");
163        assert!(item.is_active);
164        assert_eq!(item.route.to_path(), "/");
165    }
166
167    #[test]
168    fn breadcrumb_item_inactive() {
169        let item = BreadcrumbItem {
170            route:     SimpleRoute::About,
171            label:     "About".to_string(),
172            is_active: false
173        };
174        assert!(!item.is_active);
175        assert_eq!(item.label, "About");
176    }
177
178    #[test]
179    fn breadcrumb_item_clone_preserves_all_fields() {
180        let item1 = BreadcrumbItem {
181            route:     SimpleRoute::Api,
182            label:     "Root".to_string(),
183            is_active: true
184        };
185        let item2 = item1.clone();
186        assert_eq!(item1, item2);
187        assert_eq!(item2.label, "Root");
188        assert!(item2.is_active);
189    }
190
191    #[test]
192    fn breadcrumb_item_eq_with_same_values() {
193        let item1 = BreadcrumbItem {
194            route:     SimpleRoute::Home,
195            label:     "Home".to_string(),
196            is_active: true
197        };
198        let item2 = BreadcrumbItem {
199            route:     SimpleRoute::Home,
200            label:     "Home".to_string(),
201            is_active: true
202        };
203        assert_eq!(item1, item2);
204    }
205
206    #[test]
207    fn breadcrumb_item_neq_different_label() {
208        let item1 = BreadcrumbItem {
209            route:     SimpleRoute::Home,
210            label:     "Home".to_string(),
211            is_active: true
212        };
213        let item2 = BreadcrumbItem {
214            route:     SimpleRoute::Home,
215            label:     "Index".to_string(),
216            is_active: true
217        };
218        assert_ne!(item1, item2);
219    }
220
221    #[test]
222    fn breadcrumb_item_neq_different_state() {
223        let item1 = BreadcrumbItem {
224            route:     SimpleRoute::Home,
225            label:     "Home".to_string(),
226            is_active: true
227        };
228        let item2 = BreadcrumbItem {
229            route:     SimpleRoute::Home,
230            label:     "Home".to_string(),
231            is_active: false
232        };
233        assert_ne!(item1, item2);
234    }
235
236    #[test]
237    fn breadcrumb_item_neq_different_route() {
238        let item1 = BreadcrumbItem {
239            route:     SimpleRoute::Docs,
240            label:     "Docs".to_string(),
241            is_active: false
242        };
243        let item2 = BreadcrumbItem {
244            route:     SimpleRoute::Api,
245            label:     "Docs".to_string(),
246            is_active: false
247        };
248        assert_ne!(item1, item2);
249    }
250
251    #[test]
252    fn breadcrumb_item_debug_contains_all_fields() {
253        let item = BreadcrumbItem {
254            route:     SimpleRoute::Home,
255            label:     "Home".to_string(),
256            is_active: true
257        };
258        let debug_str = format!("{item:?}");
259        assert!(debug_str.contains("BreadcrumbItem"));
260        assert!(debug_str.contains("Home"));
261        assert!(debug_str.contains("is_active"));
262    }
263
264    #[test]
265    fn breadcrumb_item_long_label() {
266        let label = "Extremely long breadcrumb label to test string handling in various scenarios"
267            .to_string();
268        let item = BreadcrumbItem {
269            route:     SimpleRoute::Home,
270            label:     label.clone(),
271            is_active: false
272        };
273        assert_eq!(item.label, label);
274        assert!(!item.is_active);
275    }
276
277    #[test]
278    fn breadcrumb_item_short_label() {
279        let item = BreadcrumbItem {
280            route:     SimpleRoute::Home,
281            label:     "a".to_string(),
282            is_active: true
283        };
284        assert_eq!(item.label, "a");
285    }
286
287    #[test]
288    fn breadcrumb_item_clone_deep_copy() {
289        let item1 = BreadcrumbItem {
290            route:     SimpleRoute::ApiV1,
291            label:     "Deep".to_string(),
292            is_active: true
293        };
294        let item2 = item1.clone();
295        assert_eq!(item1, item2);
296    }
297
298    #[test]
299    fn breadcrumb_item_root_path() {
300        let item = BreadcrumbItem {
301            route:     SimpleRoute::Home,
302            label:     "/".to_string(),
303            is_active: true
304        };
305        assert_eq!(item.route.to_path(), "/");
306    }
307
308    #[test]
309    fn breadcrumb_item_nested_path() {
310        let item = BreadcrumbItem {
311            route:     SimpleRoute::ApiV1,
312            label:     "/docs/api/v1".to_string(),
313            is_active: true
314        };
315        assert_eq!(item.route.to_path(), "/docs/api/v1");
316    }
317
318    // ===== BreadcrumbLabelProvider tests =====
319
320    #[test]
321    fn breadcrumb_label_provider_returns_custom_labels() {
322        let provider = TestLabelProvider;
323        assert_eq!(provider.label_for_path("/"), "Home");
324        assert_eq!(provider.label_for_path("/about"), "About");
325        assert_eq!(provider.label_for_path("/docs/api/v1"), "V1");
326    }
327
328    #[test]
329    fn breadcrumb_label_provider_returns_path_for_unknown() {
330        let provider = TestLabelProvider;
331        assert_eq!(provider.label_for_path("/unknown/path"), "/unknown/path");
332        assert_eq!(provider.label_for_path("/missing"), "/missing");
333    }
334
335    #[test]
336    fn breadcrumb_label_provider_empty_path_not_root() {
337        let provider = TestLabelProvider;
338        assert_ne!(provider.label_for_path(""), "Home");
339    }
340
341    #[test]
342    fn breadcrumb_label_provider_whitespace() {
343        let provider = TestLabelProvider;
344        assert_eq!(provider.label_for_path("   "), "   ");
345    }
346
347    #[test]
348    fn breadcrumb_label_provider_special_chars() {
349        let provider = TestLabelProvider;
350        assert_eq!(provider.label_for_path("@#$%"), "@#$%");
351    }
352
353    // ===== BreadcrumbLabelProviderContext tests =====
354
355    #[test]
356    fn context_eq_same_rc() {
357        let rc = Rc::new(TestLabelProvider);
358        let ctx1 = BreadcrumbLabelProviderContext(rc.clone());
359        let ctx2 = BreadcrumbLabelProviderContext(rc);
360        assert!(ctx1 == ctx2);
361    }
362
363    #[test]
364    fn context_neq_different_rc() {
365        let ctx1 = BreadcrumbLabelProviderContext(Rc::new(TestLabelProvider));
366        let ctx2 = BreadcrumbLabelProviderContext(Rc::new(TestLabelProvider));
367        assert!(ctx1 != ctx2);
368    }
369
370    #[test]
371    fn context_clone_preserves_identity() {
372        let rc = Rc::new(TestLabelProvider);
373        let ctx1 = BreadcrumbLabelProviderContext(rc);
374        let ctx2 = ctx1.clone();
375        assert!(ctx1 == ctx2);
376    }
377
378    // ===== use_breadcrumbs tests =====
379
380    #[test]
381    fn use_breadcrumbs_simple_route() {
382        let _result = use_breadcrumbs::<SimpleRoute>();
383    }
384
385    #[test]
386    fn use_breadcrumbs_param_route() {
387        let _result = use_breadcrumbs::<ParamRoute>();
388    }
389
390    #[test]
391    fn use_breadcrumbs_multiple_calls() {
392        let _r1 = use_breadcrumbs::<SimpleRoute>();
393        let _r2 = use_breadcrumbs::<SimpleRoute>();
394        let _r3 = use_breadcrumbs::<SimpleRoute>();
395    }
396
397    #[test]
398    fn use_breadcrumbs_root_only_route() {
399        let _result = use_breadcrumbs::<RootOnlyRoute>();
400    }
401
402    #[test]
403    fn use_breadcrumbs_all_route_types() {
404        let _simple = use_breadcrumbs::<SimpleRoute>();
405        let _param = use_breadcrumbs::<ParamRoute>();
406        let _root = use_breadcrumbs::<RootOnlyRoute>();
407    }
408
409    // ===== Negative tests =====
410
411    #[test]
412    fn breadcrumb_item_neq_negatives() {
413        let item1 = BreadcrumbItem {
414            route:     SimpleRoute::Home,
415            label:     "Home".to_string(),
416            is_active: true
417        };
418        let mut item2 = item1.clone();
419        item2.label = "Other".to_string();
420        assert_ne!(item1, item2);
421        item2.is_active = false;
422        assert_ne!(item1, item2);
423    }
424}