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