Skip to main content

hypersig_ui/
navigation.rs

1//! Navigation between pages: a breadcrumb trail, a list of the sections of
2//! an application, and the links between the pages of a long list.
3
4use hypersig::html::{El, a, li, nav, ol, span, ul};
5use hypersig::style::pattern::{hstack, stack};
6use hypersig::style::value::{AlignItems, FlexWrap, JustifyContent, pct, rem};
7use hypersig::style::{PseudoClass, Style};
8use hypersig::{Link, Params, Route, Search, Url};
9use serde::Serialize;
10
11use crate::button::{ButtonSize, ButtonVariant, button_style};
12use crate::look::{self, icon};
13use crate::token;
14
15/// A trail of links to the pages above the current one, ending at it.
16///
17/// It is a `<nav>` named by its label, holding an ordered list. The current
18/// page ends the trail as text with `aria-current="page"`, and
19/// [`Breadcrumb::current`] is the only way to finish one, so a trail always
20/// names the page it is on and nothing follows it. The separators are
21/// hidden from assistive technology.
22///
23/// ```
24/// use hypersig::{Page, Route, Signals};
25/// use hypersig_ui::Breadcrumb;
26/// use serde::{Deserialize, Serialize};
27///
28/// #[derive(Signals, Serialize, Deserialize)]
29/// struct Issue {}
30///
31/// # async fn page<P>((): (), _: P) -> Option<Page<Issue>> { None }
32/// hypersig::routes! {
33///     fn pages(Router<()>);
34///     const HOME: Route = "/" => page;
35///     const ISSUES: Route = "/issues" => page;
36/// }
37///
38/// let trail = Breadcrumb::<Issue>::new("Breadcrumb")
39///     .link("Home", HOME.link())
40///     .link("Issues", ISSUES.link())
41///     .current("Login is slow");
42/// let html = trail.render();
43/// assert!(html.contains(r#"<nav aria-label="Breadcrumb""#));
44/// assert!(html.contains(r#"aria-current="page""#));
45/// # let _ = pages;
46/// ```
47#[derive(Debug)]
48pub struct Breadcrumb<S> {
49    label: String,
50    links: Vec<(String, Link<S>)>,
51}
52
53impl<S> Breadcrumb<S> {
54    /// An empty trail whose `<nav>` is named `label`, such as "Breadcrumb".
55    #[must_use]
56    pub fn new(label: impl Into<String>) -> Self {
57        Self {
58            label: label.into(),
59            links: Vec::new(),
60        }
61    }
62
63    /// Adds a link to a page above the current one, after the links before it.
64    #[must_use]
65    pub fn link(mut self, text: impl Into<String>, link: Link<S>) -> Self {
66        self.links.push((text.into(), link));
67        self
68    }
69
70    /// The trail, ending at the current page `text`.
71    #[must_use]
72    pub fn current(self, text: impl Into<String>) -> El<S> {
73        let separator = || {
74            li().attr("role", "presentation")
75                .attr("aria-hidden", "true")
76                .css(Style::new().display(hypersig::style::value::Display::InlineFlex))
77                .child(icon(look::icon::CHEVRON_RIGHT, rem(0.875)))
78        };
79        let mut list = ol().css(
80            hstack(rem(0.375))
81                .flex_wrap(FlexWrap::Wrap)
82                .merge(look::small_text())
83                .color(token::muted_foreground().var()),
84        );
85        for (text, link) in self.links {
86            list = list
87                .child(
88                    li().child(
89                        a().href(link)
90                            .css(
91                                Style::new()
92                                    .merge(look::transition())
93                                    .merge(look::focus_ring())
94                                    .on(
95                                        PseudoClass::Hover,
96                                        Style::new().color(token::foreground().var()),
97                                    ),
98                            )
99                            .text(text),
100                    ),
101                )
102                .child(separator());
103        }
104        list = list.child(
105            li().child(
106                span()
107                    .attr("aria-current", "page")
108                    .css(Style::new().color(token::foreground().var()))
109                    .text(text),
110            ),
111        );
112        nav().attr("aria-label", self.label).child(list)
113    }
114}
115
116/// A list of links to the sections of an application, marking the one the
117/// page is in.
118///
119/// It is a `<nav>` named by its label, holding a list of links. The page
120/// passes its own [`Url`], which the server writes with [`Route::url`], and
121/// each link is compared with it by path, without the query string. A link
122/// to the page's path has `aria-current="page"`. A link to a path that the
123/// page's path continues, such as `/users` on the page `/users/7`, has
124/// `aria-current="true"`, so the section stays marked on the pages under it.
125///
126/// ```
127/// use hypersig::{Page, Route, Signals};
128/// use hypersig_ui::NavList;
129/// use serde::{Deserialize, Serialize};
130///
131/// #[derive(Signals, Serialize, Deserialize)]
132/// struct Admin {
133///     name: String,
134/// }
135///
136/// # async fn page<P>((): (), _: P) -> Option<Page<Admin>> { None }
137/// hypersig::routes! {
138///     fn pages(Router<()>);
139///     const HOME: Route = "/" => page;
140///     const USERS: Route = "/users" => page;
141///     const USER: Route<i32> = "/users/{id}" => page;
142/// }
143///
144/// let sidebar = NavList::<Admin>::new("Main", USER.url(7))
145///     .item("Dashboard", HOME, ())
146///     .item("Users", USERS, ())
147///     .render();
148/// let html = sidebar.render();
149/// assert!(html.contains(r#"href="/users" aria-current="true""#));
150/// # let _ = pages;
151/// ```
152#[derive(Debug)]
153pub struct NavList<S> {
154    label: String,
155    current: Url,
156    items: Vec<(String, Link<S>, Url)>,
157}
158
159impl<S> NavList<S> {
160    /// An empty list whose `<nav>` is named `label`, on the page at `current`.
161    #[must_use]
162    pub fn new(label: impl Into<String>, current: Url) -> Self {
163        Self {
164            label: label.into(),
165            current,
166            items: Vec::new(),
167        }
168    }
169
170    /// Adds a link to `route` with the arguments `args`, as [`Route::url`]
171    /// takes them.
172    #[must_use]
173    pub fn item<P: Params, Q: Search>(
174        mut self,
175        text: impl Into<String>,
176        route: Route<P, Q>,
177        args: Q::Args<P>,
178    ) -> Self
179    where
180        Q::Args<P>: Serialize,
181    {
182        let link = route.link_with(&args);
183        self.items.push((text.into(), link, route.url(args)));
184        self
185    }
186
187    /// The `<nav>` and its list.
188    #[must_use]
189    pub fn render(self) -> El<S> {
190        let path = |url: &Url| {
191            let url = url.as_str();
192            url.split_once('?').map_or(url, |(path, _)| path).to_owned()
193        };
194        let current = path(&self.current);
195        let items = self.items.into_iter().map(|(text, link, url)| {
196            let target = path(&url);
197            let mark = if target == current {
198                Some("page")
199            } else if current
200                .strip_prefix(target.trim_end_matches('/'))
201                .is_some_and(|rest| rest.starts_with('/') && target != "/")
202            {
203                Some("true")
204            } else {
205                None
206            };
207            let mut style = button_style(ButtonVariant::Ghost, ButtonSize::Sm)
208                .width(pct(100))
209                .justify_content(JustifyContent::FlexStart);
210            if mark.is_some() {
211                style = style.merge(
212                    Style::new()
213                        .background_color(token::accent().var())
214                        .color(token::accent_foreground().var()),
215                );
216            }
217            let mut item = a().href(link).css(style).text(text);
218            if let Some(mark) = mark {
219                item = item.attr("aria-current", mark);
220            }
221            li().child(item)
222        });
223        nav()
224            .attr("aria-label", self.label)
225            .child(ul().css(stack(rem(0.25))).children(items))
226    }
227}
228
229impl<S> From<NavList<S>> for El<S> {
230    fn from(list: NavList<S>) -> Self {
231        list.render()
232    }
233}
234
235impl<S> From<NavList<S>> for hypersig::html::Node<S> {
236    fn from(list: NavList<S>) -> Self {
237        list.render().into()
238    }
239}
240
241/// The words [`pagination`] names its parts with.
242///
243/// They are English until replaced, so a page in another language passes
244/// its own:
245///
246/// ```
247/// use hypersig_ui::PaginationText;
248///
249/// let japanese = PaginationText {
250///     label: "ページ送り".into(),
251///     previous: "前へ".into(),
252///     next: "次へ".into(),
253/// };
254/// assert_ne!(japanese, PaginationText::default());
255/// ```
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct PaginationText {
258    /// The name of the `<nav>`, "Pagination".
259    pub label: String,
260    /// The link to the page before, "Previous".
261    pub previous: String,
262    /// The link to the page after, "Next".
263    pub next: String,
264}
265
266impl Default for PaginationText {
267    fn default() -> Self {
268        Self {
269            label: "Pagination".to_owned(),
270            previous: "Previous".to_owned(),
271            next: "Next".to_owned(),
272        }
273    }
274}
275
276/// Links to the pages of a list of `count` pages, on page `current`.
277///
278/// Pages are counted from 1, and `link` gives the link to a page. The
279/// links are the page before and the page after, the first and the last
280/// page, and the pages next to the current one, with `…` for the pages
281/// left out. The current page's link has `aria-current="page"`. On the
282/// first page the link to the page before is text marked
283/// `aria-disabled="true"`, as is the link to the page after on the last.
284///
285/// ```
286/// use hypersig::{Page, Route, Signals};
287/// use hypersig_ui::{PaginationText, pagination};
288/// use serde::{Deserialize, Serialize};
289///
290/// #[derive(Signals, Serialize, Deserialize)]
291/// struct Catalog {}
292///
293/// # async fn page((): (), _: u32) -> Option<Page<Catalog>> { None }
294/// hypersig::routes! {
295///     fn pages(Router<()>);
296///     const PRODUCTS: Route<u32> = "/products/{page}" => page;
297/// }
298///
299/// let pager = pagination::<Catalog>(5, 10, |page| PRODUCTS.link_with(&page), &PaginationText::default());
300/// let html = pager.render();
301/// assert!(html.contains(r#"href="/products/5" aria-current="page""#));
302/// assert!(html.contains(r#"href="/products/4""#) && !html.contains(r#"href="/products/3""#));
303/// # let _ = pages;
304/// ```
305///
306/// # Panics
307///
308/// Panics if `current` is 0 or above `count`.
309#[must_use]
310pub fn pagination<S>(
311    current: u32,
312    count: u32,
313    link: impl Fn(u32) -> Link<S>,
314    text: &PaginationText,
315) -> El<S> {
316    assert!(
317        current >= 1 && current <= count,
318        "the current page is between 1 and the number of pages"
319    );
320    let square = |variant| {
321        button_style(variant, ButtonSize::Default)
322            .width(rem(2.25))
323            .padding_inline(rem(0))
324    };
325    let step = |target: Option<u32>, label: &str, rel: &'static str, back: bool| {
326        let arrow = icon(
327            if back {
328                look::icon::CHEVRON_LEFT
329            } else {
330                look::icon::CHEVRON_RIGHT
331            },
332            rem(1),
333        );
334        let style =
335            button_style(ButtonVariant::Ghost, ButtonSize::Default).padding_inline(rem(0.625));
336        let el = match target {
337            Some(page) => a().href(link(page)).attr("rel", rel).css(style),
338            None => span().attr("aria-disabled", "true").css(
339                style
340                    .opacity(hypersig::style::value::Opacity::Number(0.5))
341                    .cursor(hypersig::style::value::Cursor::NotAllowed),
342            ),
343        };
344        let el = if back {
345            el.child(arrow).child(span().text(label))
346        } else {
347            el.child(span().text(label)).child(arrow)
348        };
349        li().child(el)
350    };
351
352    let mut list = ul()
353        .css(
354            hstack(rem(0.25))
355                .flex_wrap(FlexWrap::Wrap)
356                .align_items(AlignItems::Center),
357        )
358        .child(step(
359            (current > 1).then(|| current - 1),
360            &text.previous,
361            "prev",
362            true,
363        ));
364    let mut shown = 0;
365    for page in 1..=count {
366        let near = page.abs_diff(current) <= 1;
367        // A gap of one page shows that page, since `…` would take its place.
368        let fills_gap = (page == 2 && current == 4) || (page + 1 == count && current + 3 == count);
369        if !(page == 1 || page == count || near || fills_gap) {
370            continue;
371        }
372        if page > shown + 1 {
373            list = list.child(
374                li().attr("aria-hidden", "true")
375                    .css(
376                        Style::new()
377                            .width(rem(2.25))
378                            .text_align(hypersig::style::value::TextAlign::Center),
379                    )
380                    .text("…"),
381            );
382        }
383        shown = page;
384        let mut item = a().href(link(page));
385        if page == current {
386            item = item
387                .attr("aria-current", "page")
388                .css(square(ButtonVariant::Outline));
389        } else {
390            item = item.css(square(ButtonVariant::Ghost));
391        }
392        list = list.child(li().child(item.text(page.to_string())));
393    }
394    list = list.child(step(
395        (current < count).then(|| current + 1),
396        &text.next,
397        "next",
398        false,
399    ));
400    nav()
401        .attr("aria-label", text.label.clone())
402        .css(
403            Style::new()
404                .display(hypersig::style::value::Display::Flex)
405                .justify_content(JustifyContent::Center),
406        )
407        .child(list)
408}
409
410#[cfg(test)]
411mod tests {
412    use hypersig::{Page, Route, Signals};
413    use serde::{Deserialize, Serialize};
414
415    use super::{PaginationText, pagination};
416
417    #[derive(Signals, Serialize, Deserialize)]
418    struct Catalog {
419        query: String,
420    }
421
422    async fn page((): (), _: u32) -> Option<Page<Catalog>> {
423        None
424    }
425
426    hypersig::routes! {
427        fn pages(Router<()>);
428        const PRODUCTS: Route<u32> = "/products/{page}" => page;
429    }
430
431    /// The numbers of the page links `pagination` shows, with 0 for `…`.
432    fn shown(current: u32, count: u32) -> Vec<u32> {
433        let _ = pages;
434        let html = pagination::<Catalog>(
435            current,
436            count,
437            |page| PRODUCTS.link_with(&page),
438            &PaginationText::default(),
439        )
440        .render();
441        let mut out = Vec::new();
442        for part in html.split("<li").skip(1) {
443            if part.contains("…") && !part.contains("href") {
444                out.push(0);
445            } else if let Some(rest) = part.split(r#"href="/products/"#).nth(1)
446                && !part.contains("rel=")
447            {
448                out.push(rest.split('"').next().unwrap().parse().unwrap());
449            }
450        }
451        out
452    }
453
454    #[test]
455    fn a_gap_of_one_page_shows_the_page() {
456        assert_eq!(shown(1, 1), [1]);
457        assert_eq!(shown(1, 3), [1, 2, 3]);
458        assert_eq!(shown(4, 10), [1, 2, 3, 4, 5, 0, 10]);
459        assert_eq!(shown(5, 10), [1, 0, 4, 5, 6, 0, 10]);
460        assert_eq!(shown(7, 10), [1, 0, 6, 7, 8, 9, 10]);
461        assert_eq!(shown(10, 10), [1, 0, 9, 10]);
462    }
463
464    #[test]
465    #[should_panic(expected = "between 1 and the number of pages")]
466    fn a_page_past_the_last_panics() {
467        let _ = shown(4, 3);
468    }
469}