yew_nested_router/
router.rs

1use crate::base;
2use crate::history::{History, HistoryListener};
3use crate::scope::{NavigationTarget, ScopeContext};
4use crate::state::State;
5use crate::target::Target;
6use gloo_utils::window;
7use std::borrow::Cow;
8use std::fmt::Debug;
9use std::rc::Rc;
10use web_sys::Location;
11use yew::html::IntoPropValue;
12use yew::prelude::*;
13
14#[derive(Clone, PartialEq)]
15pub struct RouterContext<T>
16where
17    T: Target,
18{
19    /// The base URL of the page
20    pub(crate) base: Rc<String>,
21    pub(crate) scope: Rc<ScopeContext<T>>,
22    // The active target
23    pub active_target: Option<T>,
24}
25
26impl<T> RouterContext<T>
27where
28    T: Target,
29{
30    /// Push a new state to the history. This changes the current page, but doesn't actually leave the page.
31    pub fn push(&self, target: T) {
32        self.scope.push(target);
33    }
34    /// Replace current state on the history. This changes the current page, but doesn't actually leave the page.
35    pub fn replace(&self, target: T) {
36        self.scope.replace(target);
37    }
38
39    /// Push a new state to the history, allow setting page state at the same time.
40    pub fn push_with(&self, target: T, state: State) {
41        self.scope.push_with(target, state.0);
42    }
43    /// Replace current state on the history, allow setting page state at the same time.
44    pub fn replace_with(&self, target: T, state: State) {
45        self.scope.replace_with(target, state.0);
46    }
47
48    /// Render the path of target.
49    ///
50    /// This includes the parenting scopes as well as the "base" URL of the document.
51    pub fn render_target(&self, target: T) -> String {
52        self.scope.collect(target)
53    }
54
55    /// Render the path of target.
56    ///
57    /// This includes the parenting scopes as well as the "base" URL of the document. It also adds the state using the
58    /// hash.
59    pub fn render_target_with(&self, target: T, state: impl IntoPropValue<State>) -> String {
60        let mut result = self.scope.collect(target);
61
62        let state = state.into_prop_value().0;
63        if state.is_null() || state.is_undefined() {
64            // no-op
65        } else if let Some(value) = state.as_string() {
66            result.push('#');
67            result.push_str(&value);
68        } else if let Some(value) = js_sys::JSON::stringify(&state)
69            .ok()
70            .and_then(|s| s.as_string())
71        {
72            result.push('#');
73            result.push_str(&value);
74        }
75
76        result
77    }
78
79    /// Check if the provided target is the active target
80    pub fn is_same(&self, target: &T) -> bool {
81        match &self.active_target {
82            Some(current) => current == target,
83            None => false,
84        }
85    }
86
87    /// Check if the target is active.
88    ///
89    /// This is intended for components to find out if their target, or part of their target
90    /// is active. If the function is provided with a predicate, then this will override the
91    /// decision process. Otherwise function will check if the provided `target` is the same as
92    /// the active target.
93    ///
94    /// Assume you have a nested navigation tree. The active state of a leaf entry would be
95    /// identified by the target being "the same". While branch entries would need to provide a
96    /// predicate, as there is no "value" to compare to.
97    ///
98    /// A component supporting this model can provide two properties: a target, and an optional
99    /// predicate. The user can then configure this accordingly. The component can simply pass
100    /// the information to this function to perform the check.
101    pub fn is_active(&self, target: &T, predicate: Option<&Callback<T, bool>>) -> bool {
102        match predicate {
103            Some(predicate) => self
104                .active_target
105                .clone()
106                .map(|target| predicate.emit(target))
107                .unwrap_or_default(),
108            None => self.is_same(target),
109        }
110    }
111
112    /// Get the active target, this may be [`None`], in the case this branch doesn't have an
113    /// active target.
114    pub fn active(&self) -> &Option<T> {
115        &self.active_target
116    }
117}
118
119/// Properties for the [`Router`] component.
120#[derive(Clone, Debug, PartialEq, Properties)]
121pub struct RouterProps<T>
122where
123    T: Target,
124{
125    /// The content to render.
126    #[prop_or_default]
127    pub children: Children,
128
129    /// The default target to use in case none matched.
130    #[prop_or_default]
131    pub default: Option<T>,
132
133    /// The application base.
134    ///
135    /// Defaults to an empty string or the content of the `href` attribute of the `<base>` element.
136    ///
137    /// This can be used in case the application is hosted on a sub path to adapt paths generated
138    /// and expected by the router.
139    ///
140    /// ## Usage with `trunk`
141    ///
142    /// If you are using `trunk` to build the application, you can add the following to your
143    /// `index.html` file:
144    ///
145    /// ```html
146    /// <head>
147    ///   <base data-trunk-public-url/>
148    /// </head>
149    /// ```
150    ///
151    /// This will automatically populate the `<base>` element with the root provided using the
152    /// `--public-url` argument.
153    #[prop_or_default]
154    pub base: Option<String>,
155}
156
157#[derive(Debug, Copy, Clone, Eq, PartialEq)]
158pub enum StackOperation {
159    Push,
160    Replace,
161}
162
163#[derive(Debug)]
164#[doc(hidden)]
165pub enum Msg<T: Target> {
166    /// The target was changed
167    ///
168    /// This can happen either by navigating to a new target, or by the history API's popstate event.
169    RouteChanged,
170    /// Change to a new target
171    ChangeTarget(NavigationTarget<T>, StackOperation),
172}
173
174/// Top-level router component.
175pub struct Router<T: Target> {
176    _listener: HistoryListener,
177    target: Option<T>,
178
179    scope: Rc<ScopeContext<T>>,
180    router: RouterContext<T>,
181
182    base: Rc<String>,
183}
184
185impl<T> Component for Router<T>
186where
187    T: Target + 'static,
188{
189    type Message = Msg<T>;
190    type Properties = RouterProps<T>;
191
192    fn create(ctx: &Context<Self>) -> Self {
193        let cb = ctx.link().callback(|_| Msg::RouteChanged);
194
195        let base = Rc::new(
196            ctx.props()
197                .base
198                .clone()
199                .or_else(base::eval_base)
200                .unwrap_or_default(),
201        );
202
203        let target = Self::parse_location(&base, window().location())
204            .or_else(|| ctx.props().default.clone());
205
206        let listener = History::listener(move || {
207            cb.emit(window().location());
208        });
209
210        let (scope, router) = Self::build_context(base.clone(), &target, ctx);
211
212        Self {
213            _listener: listener,
214            target,
215            scope,
216            router,
217            base,
218        }
219    }
220
221    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
222        match msg {
223            Msg::RouteChanged => {
224                let target = Self::parse_location(&self.base, window().location())
225                    .or_else(|| ctx.props().default.clone());
226                if target != self.target {
227                    self.target = target;
228                    self.sync_context(ctx);
229                    return true;
230                }
231            }
232            Msg::ChangeTarget(target, operation) => {
233                let route = Self::render_target(&self.base, &target.target);
234                let _ = match operation {
235                    StackOperation::Push => History::push_state(target.state, &route),
236                    StackOperation::Replace => History::replace_state(target.state, &route),
237                };
238                ctx.link().send_message(Msg::RouteChanged)
239            }
240        }
241
242        false
243    }
244
245    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
246        self.sync_context(ctx);
247        true
248    }
249
250    fn view(&self, ctx: &Context<Self>) -> Html {
251        let scope = self.scope.clone();
252        let router = self.router.clone();
253
254        html! (
255            <ContextProvider<ScopeContext<T>> context={(*scope).clone()}>
256                <ContextProvider<RouterContext<T >> context={router}>
257                    { for ctx.props().children.iter() }
258                </ContextProvider<RouterContext<T >>>
259            </ContextProvider<ScopeContext<T>>>
260        )
261    }
262}
263
264impl<T: Target> Router<T> {
265    fn render_target(base: &str, target: &T) -> String {
266        let path = target
267            .render_path()
268            .into_iter()
269            .map(|segment| urlencoding::encode(&segment).to_string())
270            .collect::<Vec<_>>()
271            .join("/");
272
273        format!("{base}/{path}",)
274    }
275
276    fn parse_location(base: &str, location: Location) -> Option<T> {
277        // get the current path
278        let path = location.pathname().unwrap_or_default();
279        // if the prefix doesn't match, nothing will
280        if !path.starts_with(base) {
281            return None;
282        }
283        // split off the prefix
284        let (_, path) = path.split_at(base.len());
285        // log::debug!("Path: {path}");
286
287        // parse into path segments
288        let path: Result<Vec<Cow<str>>, _> = path
289            .split('/')
290            .skip(1)
291            // urldecode in the process
292            .map(urlencoding::decode)
293            .collect();
294
295        // get a path, or return none if we had an urldecode error
296        let path = match &path {
297            Ok(path) => path.iter().map(|s| s.as_ref()).collect::<Vec<_>>(),
298            Err(_) => return None,
299        };
300
301        // parse the path into a target
302        T::parse_path(&path)
303    }
304
305    fn sync_context(&mut self, ctx: &Context<Self>) {
306        let (scope, router) = Self::build_context(self.base.clone(), &self.target, ctx);
307        self.scope = scope;
308        self.router = router;
309    }
310
311    fn build_context(
312        base: Rc<String>,
313        target: &Option<T>,
314        ctx: &Context<Self>,
315    ) -> (Rc<ScopeContext<T>>, RouterContext<T>) {
316        let scope = Rc::new(ScopeContext {
317            upwards: ctx
318                .link()
319                .callback(|(target, operation)| Msg::ChangeTarget(target, operation)),
320            collect: {
321                let base = base.clone();
322                Callback::from(move |target| Self::render_target(&base, &target))
323            },
324        });
325
326        let router = RouterContext {
327            base,
328            scope: scope.clone(),
329            active_target: target.clone(),
330        };
331
332        (scope, router)
333    }
334}
335
336#[hook]
337/// Get access to the router.
338///
339/// The hook requires to be called from a component which is nested into a [`Router`] component of
340/// the type `T` provided here. If not, it will return [`None`].
341pub fn use_router<T>() -> Option<RouterContext<T>>
342where
343    T: Target + 'static,
344{
345    use_context()
346}