typstify_ui/
navigation.rs

1//! Navigation components for site navigation and table of contents.
2//!
3//! Provides Navigation, NavLink, and TableOfContents components.
4
5use leptos::prelude::*;
6use serde::{Deserialize, Serialize};
7
8/// A navigation item.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct NavItem {
11    /// Display label.
12    pub label: String,
13
14    /// Link URL.
15    pub url: String,
16
17    /// Whether this is the active/current page.
18    #[serde(default)]
19    pub active: bool,
20
21    /// Child navigation items.
22    #[serde(default)]
23    pub children: Vec<NavItem>,
24}
25
26impl NavItem {
27    /// Create a new navigation item.
28    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    /// Set this item as active.
38    pub fn with_active(mut self, active: bool) -> Self {
39        self.active = active;
40        self
41    }
42
43    /// Add child items.
44    pub fn with_children(mut self, children: Vec<NavItem>) -> Self {
45        self.children = children;
46        self
47    }
48}
49
50/// Table of contents entry.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct TocEntry {
53    /// Heading level (1-6).
54    pub level: u8,
55
56    /// Heading text.
57    pub text: String,
58
59    /// Anchor ID.
60    pub id: String,
61}
62
63impl TocEntry {
64    /// Create a new TOC entry.
65    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/// Main navigation component.
75#[component]
76pub fn Navigation(
77    /// Navigation items.
78    items: Signal<Vec<NavItem>>,
79    /// Current path for active highlighting.
80    #[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/// Navigation link component with support for child items.
100#[component]
101fn NavLinkWithChildren(
102    /// The navigation item.
103    item: NavItem,
104    /// Current path for active highlighting.
105    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                // Only render single-level children (no recursion)
138                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/// Table of contents component.
162#[component]
163pub fn TableOfContents(
164    /// TOC entries.
165    entries: Signal<Vec<TocEntry>>,
166    /// Currently active heading ID.
167    #[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/// Breadcrumb navigation component.
199#[component]
200pub fn Breadcrumbs(
201    /// Breadcrumb items (label, url).
202    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}