typstify_ui/
navigation.rs1use leptos::prelude::*;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct NavItem {
11 pub label: String,
13
14 pub url: String,
16
17 #[serde(default)]
19 pub active: bool,
20
21 #[serde(default)]
23 pub children: Vec<NavItem>,
24}
25
26impl NavItem {
27 pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
29 Self {
30 label: label.into(),
31 url: url.into(),
32 active: false,
33 children: Vec::new(),
34 }
35 }
36
37 pub fn with_active(mut self, active: bool) -> Self {
39 self.active = active;
40 self
41 }
42
43 pub fn with_children(mut self, children: Vec<NavItem>) -> Self {
45 self.children = children;
46 self
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct TocEntry {
53 pub level: u8,
55
56 pub text: String,
58
59 pub id: String,
61}
62
63impl TocEntry {
64 pub fn new(level: u8, text: impl Into<String>, id: impl Into<String>) -> Self {
66 Self {
67 level,
68 text: text.into(),
69 id: id.into(),
70 }
71 }
72}
73
74#[component]
76pub fn Navigation(
77 items: Signal<Vec<NavItem>>,
79 #[prop(default = "/".to_string().into())]
81 current_path: Signal<String>,
82) -> impl IntoView {
83 view! {
84 <nav class="typstify-nav" aria-label="Main navigation">
85 <ul class="typstify-nav-list">
86 <For
87 each=move || items.get()
88 key=|item| item.url.clone()
89 children=move |item| {
90 view! { <NavLinkWithChildren item=item current_path=current_path /> }
91 }
92 />
93
94 </ul>
95 </nav>
96 }
97}
98
99#[component]
101fn NavLinkWithChildren(
102 item: NavItem,
104 current_path: Signal<String>,
106) -> impl IntoView {
107 let url = item.url.clone();
108 let has_children = !item.children.is_empty();
109 let children_list = StoredValue::new(item.children.clone());
110
111 let is_active = Memo::new(move |_| {
112 let current = current_path.get();
113 current == url || current.starts_with(&format!("{url}/"))
114 });
115
116 view! {
117 <li class="typstify-nav-item" class:active=is_active>
118 <a
119 href=item.url.clone()
120 class="typstify-nav-link"
121 aria-current=move || { if is_active.get() { Some("page") } else { None } }
122 >
123 {item.label.clone()}
124 </a>
125
126 <Show when=move || has_children>
127 <ul class="typstify-nav-children">
128 <For
129 each=move || children_list.get_value()
130 key=|child| child.url.clone()
131 children=move |child| {
132 let child_url = child.url.clone();
133 let is_child_active = Memo::new(move |_| {
134 let current = current_path.get();
135 current == child_url || current.starts_with(&format!("{child_url}/"))
136 });
137 view! {
139 <li class="typstify-nav-item" class:active=is_child_active>
140 <a
141 href=child.url.clone()
142 class="typstify-nav-link"
143 aria-current=move || {
144 if is_child_active.get() { Some("page") } else { None }
145 }
146 >
147
148 {child.label.clone()}
149 </a>
150 </li>
151 }
152 }
153 />
154
155 </ul>
156 </Show>
157 </li>
158 }
159}
160
161#[component]
163pub fn TableOfContents(
164 entries: Signal<Vec<TocEntry>>,
166 #[prop(default = "".to_string().into())]
168 active_id: Signal<String>,
169) -> impl IntoView {
170 view! {
171 <nav class="typstify-toc" aria-label="Table of contents">
172 <h2 class="typstify-toc-title">"On this page"</h2>
173 <ul class="typstify-toc-list">
174 <For
175 each=move || entries.get()
176 key=|entry| entry.id.clone()
177 children=move |entry| {
178 let id = entry.id.clone();
179 let is_active = Memo::new(move |_| active_id.get() == id);
180 let indent_class = format!("typstify-toc-level-{}", entry.level);
181 let href = format!("#{}", entry.id);
182
183 view! {
184 <li class=indent_class class:active=is_active>
185 <a href=href class="typstify-toc-link">
186 {entry.text.clone()}
187 </a>
188 </li>
189 }
190 }
191 />
192
193 </ul>
194 </nav>
195 }
196}
197
198#[component]
200pub fn Breadcrumbs(
201 items: Signal<Vec<(String, String)>>,
203) -> impl IntoView {
204 view! {
205 <nav class="typstify-breadcrumbs" aria-label="Breadcrumb">
206 <ol class="typstify-breadcrumb-list">
207 <For
208 each=move || {
209 let items_vec = items.get();
210 let len = items_vec.len();
211 items_vec
212 .into_iter()
213 .enumerate()
214 .map(move |(i, (label, url))| (i, label, url, i == len - 1))
215 .collect::<Vec<_>>()
216 }
217
218 key=|(i, _, _, _)| *i
219 children=move |(_, label, url, is_last)| {
220 let label_for_fallback = label.clone();
221 let label_for_link = label.clone();
222 view! {
223 <li class="typstify-breadcrumb-item">
224 <Show
225 when=move || !is_last
226 fallback=move || {
227 view! {
228 <span class="typstify-breadcrumb-current" aria-current="page">
229 {label_for_fallback.clone()}
230 </span>
231 }
232 }
233 >
234
235 <a href=url.clone() class="typstify-breadcrumb-link">
236 {label_for_link.clone()}
237 </a>
238 <span class="typstify-breadcrumb-separator" aria-hidden="true">
239 "/"
240 </span>
241 </Show>
242 </li>
243 }
244 }
245 />
246
247 </ol>
248 </nav>
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn test_nav_item_creation() {
258 let item = NavItem::new("Home", "/");
259 assert_eq!(item.label, "Home");
260 assert_eq!(item.url, "/");
261 assert!(!item.active);
262 assert!(item.children.is_empty());
263 }
264
265 #[test]
266 fn test_nav_item_with_active() {
267 let item = NavItem::new("Home", "/").with_active(true);
268 assert!(item.active);
269 }
270
271 #[test]
272 fn test_nav_item_with_children() {
273 let child = NavItem::new("Child", "/child");
274 let parent = NavItem::new("Parent", "/parent").with_children(vec![child]);
275
276 assert_eq!(parent.children.len(), 1);
277 assert_eq!(parent.children[0].label, "Child");
278 }
279
280 #[test]
281 fn test_toc_entry_creation() {
282 let entry = TocEntry::new(2, "Introduction", "introduction");
283 assert_eq!(entry.level, 2);
284 assert_eq!(entry.text, "Introduction");
285 assert_eq!(entry.id, "introduction");
286 }
287
288 #[test]
289 fn test_nav_item_serialization() {
290 let item = NavItem::new("Test", "/test");
291 let json = serde_json::to_string(&item).unwrap();
292 assert!(json.contains("\"label\":\"Test\""));
293 assert!(json.contains("\"url\":\"/test\""));
294 }
295}