Skip to main content

tablo_core/
navigation.rs

1//! Sidebar navigation: [`NavigationItem`] and the [`NavTarget`] it points with.
2
3use topcoat::{context::Cx, icon::IconData, view::Attributes};
4
5/// The `href` and runtime-navigation attributes of a link to another panel page.
6pub(crate) fn runtime_link(cx: &Cx, href: &str) -> Attributes {
7    topcoat::runtime::link_attrs(cx, href.to_string(), topcoat::runtime::prefetch_mode(cx))
8}
9
10/// A dropdown menu item that navigates: the registry item's look on a link.
11pub(crate) const MENU_LINK_CLASS: &str = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 \
12     text-left text-sm whitespace-nowrap outline-none hover:bg-foreground/5 \
13     focus-visible:bg-foreground/5 active:bg-foreground/10 [&_svg]:size-4 [&_svg]:shrink-0";
14
15/// Where a sidebar entry points.
16#[derive(Clone, Default, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum NavTarget {
19    /// No URL yet; the owning Panel resolves it.
20    #[default]
21    Derived,
22    /// An explicit URL.
23    Url(String),
24}
25
26impl NavTarget {
27    /// The URL this target names.
28    pub fn url(&self) -> Option<&str> {
29        match self {
30            Self::Derived => None,
31            Self::Url(url) => Some(url),
32        }
33    }
34}
35
36impl std::fmt::Debug for NavTarget {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Derived => f.write_str("Derived"),
40            Self::Url(url) => f.debug_tuple("Url").field(url).finish(),
41        }
42    }
43}
44
45/// A sidebar entry: a resource's, from its [`ResourceDef`](super::ResourceDef), or a `Page`'s.
46#[derive(Clone, Debug, Default)]
47pub struct NavigationItem {
48    /// The entry's text.
49    pub label: crate::Text,
50    /// Where this entry points.
51    pub target: NavTarget,
52    /// Sort key for the sidebar.
53    pub order: i32,
54    /// The icon before the label, if any.
55    pub icon: Option<IconData>,
56    /// The labelled sidebar group the entry renders in; `None` renders it with the ungrouped
57    /// entries, above every group.
58    pub group: Option<crate::Text>,
59    /// The text of the badge after the label, if any.
60    pub badge: Option<String>,
61}
62
63impl NavigationItem {
64    /// A sidebar entry at an explicit `url`, kept as written.
65    ///
66    /// The entry whose URL is the longest match for the current path is marked active. Of the
67    /// entries sharing that path, the one whose query the request carries wins, then the one
68    /// naming more of that query, then the first declared.
69    pub fn at(label: impl Into<crate::Text>, url: impl Into<String>) -> Self {
70        Self {
71            label: label.into(),
72            target: NavTarget::Url(url.into()),
73            order: 0,
74            icon: None,
75            group: None,
76            badge: None,
77        }
78    }
79
80    /// This entry with `icon` before its label.
81    #[must_use]
82    pub fn icon(mut self, icon: IconData) -> Self {
83        self.icon = Some(icon);
84        self
85    }
86
87    /// This entry in the sidebar group labelled `group`. Groups render in the order of their
88    /// first entry, after the ungrouped entries.
89    #[must_use]
90    pub fn group(mut self, group: impl Into<crate::Text>) -> Self {
91        self.group = Some(group.into());
92        self
93    }
94
95    /// This entry with a badge reading `text` after its label, such as `"New"`; a blank `text`
96    /// is ignored. A resource's [`navigation_badge`](crate::ResourceDef::navigation_badge) counts
97    /// its rows for one instead.
98    #[must_use]
99    pub fn badge(mut self, text: impl Into<String>) -> Self {
100        let text = text.into().trim().to_string();
101        if !text.is_empty() {
102            self.badge = Some(text);
103        }
104        self
105    }
106
107    /// Resolve a [`NavTarget::Derived`] entry to `url`.
108    pub(crate) fn resolved(mut self, url: &str) -> Self {
109        if matches!(self.target, NavTarget::Derived) {
110            self.target = NavTarget::Url(url.to_string());
111        }
112        self
113    }
114
115    /// The URL this entry points at.
116    pub fn url(&self) -> Option<&str> {
117        self.target.url()
118    }
119
120    /// The path this entry points at, without the query or fragment it carries.
121    pub(crate) fn path(&self) -> Option<&str> {
122        let url = self.url()?;
123        let path = url.split_once(['?', '#']).map_or(url, |(path, _)| path);
124        (!path.is_empty()).then_some(path)
125    }
126
127    /// Whether this item is current for the given request path, which carries no query or
128    /// fragment: an entry's own are the state it points with, not part of where it points.
129    pub fn is_current_path(&self, current_path: &str) -> bool {
130        let Some(path) = self.path() else {
131            return false;
132        };
133        if current_path == path {
134            return true;
135        }
136        current_path
137            .strip_prefix(path)
138            .is_some_and(|rest| rest.starts_with('/'))
139    }
140
141    /// The query the entry points with: the text between its `?` and any fragment.
142    fn query(&self) -> &str {
143        let url = self.url().unwrap_or_default();
144        let before_fragment = url.split_once('#').map_or(url, |(before, _)| before);
145        before_fragment
146            .split_once('?')
147            .map_or("", |(_, query)| query)
148    }
149
150    /// The `&`-separated parameters the entry's query names.
151    fn query_pairs(&self) -> impl Iterator<Item = &str> {
152        self.query().split('&').filter(|pair| !pair.is_empty())
153    }
154
155    /// Whether `request_query` carries every parameter the entry's query names.
156    pub(crate) fn query_held(&self, request_query: &str) -> bool {
157        self.query_pairs()
158            .all(|pair| request_query.split('&').any(|held| held == pair))
159    }
160}
161
162/// The index of the entry a request is under, given the request's path and query. Entries whose
163/// path holds `path` compete, and the longest path wins. Of the entries sharing it, the one whose
164/// query `query` carries wins, then the one naming more of that query, then the first declared.
165pub(crate) fn current_index(
166    nav_items: &[NavigationItem],
167    path: &str,
168    query: &str,
169) -> Option<usize> {
170    nav_items
171        .iter()
172        .enumerate()
173        .filter(|(_, item)| item.is_current_path(path))
174        .min_by_key(|(_, item)| {
175            (
176                std::cmp::Reverse(item.path().map_or(0, str::len)),
177                !item.query_held(query),
178                std::cmp::Reverse(item.query_pairs().count()),
179            )
180        })
181        .map(|(index, _)| index)
182}
183
184#[cfg(test)]
185mod tests;