use crate::pagination::{CursorPage, Page};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PageItem {
Page(u32),
Ellipsis,
}
#[derive(Debug, Clone)]
pub struct PagerOptions<'a> {
pub base_path: &'a str,
pub query: &'a str,
pub page_param: &'a str,
pub size_param: &'a str,
pub cursor_param: &'a str,
pub window: u32,
pub include_size: bool,
pub aria_label: &'a str,
pub prev_label: &'a str,
pub next_label: &'a str,
pub hx_target: Option<&'a str>,
pub hx_push_url: bool,
pub prev_cursor: Option<&'a str>,
}
impl<'a> PagerOptions<'a> {
#[must_use]
pub const fn new(base_path: &'a str) -> Self {
Self {
base_path,
query: "",
page_param: "page",
size_param: "size",
cursor_param: "cursor",
window: 2,
include_size: false,
aria_label: "Pagination",
prev_label: "Previous",
next_label: "Next",
hx_target: None,
hx_push_url: false,
prev_cursor: None,
}
}
#[must_use]
pub const fn query(mut self, query: &'a str) -> Self {
self.query = query;
self
}
#[must_use]
pub const fn page_param(mut self, name: &'a str) -> Self {
self.page_param = name;
self
}
#[must_use]
pub const fn size_param(mut self, name: &'a str) -> Self {
self.size_param = name;
self
}
#[must_use]
pub const fn cursor_param(mut self, name: &'a str) -> Self {
self.cursor_param = name;
self
}
#[must_use]
pub const fn window(mut self, radius: u32) -> Self {
self.window = radius;
self
}
#[must_use]
pub const fn include_size(mut self) -> Self {
self.include_size = true;
self
}
#[must_use]
pub const fn aria_label(mut self, label: &'a str) -> Self {
self.aria_label = label;
self
}
#[must_use]
pub const fn prev_label(mut self, label: &'a str) -> Self {
self.prev_label = label;
self
}
#[must_use]
pub const fn next_label(mut self, label: &'a str) -> Self {
self.next_label = label;
self
}
#[must_use]
pub const fn hx_target(mut self, target: &'a str) -> Self {
self.hx_target = Some(target);
self
}
#[must_use]
pub const fn hx_push_url(mut self) -> Self {
self.hx_push_url = true;
self
}
#[must_use]
pub const fn prev_cursor(mut self, cursor: &'a str) -> Self {
self.prev_cursor = Some(cursor);
self
}
}
fn page_window(current: u32, total: u32, radius: u32) -> Vec<PageItem> {
if total <= 1 {
return vec![PageItem::Page(1)];
}
let current = current.clamp(1, total);
let lo = current.saturating_sub(radius).max(1);
let hi = current.saturating_add(radius).min(total);
let mut items: Vec<PageItem> =
Vec::with_capacity((hi as usize).saturating_sub(lo as usize).saturating_add(5));
if lo > 1 {
items.push(PageItem::Page(1));
if lo == 3 {
items.push(PageItem::Page(2));
} else if lo > 2 {
items.push(PageItem::Ellipsis);
}
}
for p in lo..=hi {
items.push(PageItem::Page(p));
}
if hi < total {
if hi == total - 2 {
items.push(PageItem::Page(total - 1));
} else if hi < total - 1 {
items.push(PageItem::Ellipsis);
}
items.push(PageItem::Page(total));
}
items
}
fn filter_query<'a>(query: &'a str, drop_keys: &[&str]) -> String {
let query = query.strip_prefix('?').unwrap_or(query);
let parts: Vec<&'a str> = query
.split('&')
.filter(|pair| {
if pair.is_empty() {
return false;
}
let key = pair.split('=').next().unwrap_or(pair);
!drop_keys.contains(&key)
})
.collect();
parts.join("&")
}
#[cfg(feature = "maud")]
fn anchor(href: &str, class: &str, content: &str, opts: &PagerOptions<'_>) -> maud::Markup {
let hx_get = opts.hx_target.map(|_| href);
let hx_push = if opts.hx_push_url && opts.hx_target.is_some() {
Some("true")
} else {
None
};
maud::html! {
a
class=(class)
href=(href)
hx-get=[hx_get]
hx-target=[opts.hx_target]
hx-push-url=[hx_push] {
(content)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn pagination_nav<T>(page: &Page<T>, opts: &PagerOptions<'_>) -> maud::Markup {
let filtered = filter_query(opts.query, &[opts.page_param, opts.size_param]);
let prefix = if filtered.is_empty() {
String::new()
} else {
format!("{filtered}&")
};
let href = |p: u32| -> String {
if opts.include_size {
format!(
"{}?{}{}={p}&{}={}",
opts.base_path, prefix, opts.page_param, opts.size_param, page.size
)
} else {
format!("{}?{}{}={p}", opts.base_path, prefix, opts.page_param)
}
};
maud::html! {
nav aria-label=(opts.aria_label) class="autumn-pager" {
@if page.has_previous {
(anchor(&href(page.page.saturating_sub(1)), "autumn-pager__link autumn-pager__prev", opts.prev_label, opts))
} @else {
span class="autumn-pager__prev autumn-pager__disabled" aria-disabled="true" {
(opts.prev_label)
}
}
@for item in page_window(page.page, page.total_pages, opts.window) {
@match item {
PageItem::Page(p) => {
@if p == page.page {
span class="autumn-pager__current" aria-current="page" { (p) }
} @else {
(anchor(&href(p), "autumn-pager__link", &p.to_string(), opts))
}
}
PageItem::Ellipsis => {
span class="autumn-pager__ellipsis" aria-hidden="true" { "…" }
}
}
}
@if page.has_next {
(anchor(&href(page.page + 1), "autumn-pager__link autumn-pager__next", opts.next_label, opts))
} @else {
span class="autumn-pager__next autumn-pager__disabled" aria-disabled="true" {
(opts.next_label)
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn cursor_pagination_nav<T>(page: &CursorPage<T>, opts: &PagerOptions<'_>) -> maud::Markup {
let cursor_filtered = filter_query(
opts.query,
&[opts.cursor_param, opts.page_param, opts.size_param],
);
let cursor_prefix = if cursor_filtered.is_empty() {
String::new()
} else {
format!("{cursor_filtered}&")
};
let cursor_href = |token: &str| -> String {
format!(
"{}?{}{}={token}",
opts.base_path, cursor_prefix, opts.cursor_param
)
};
let next_token = page.next_cursor.as_deref();
maud::html! {
nav aria-label=(opts.aria_label) class="autumn-pager autumn-pager--cursor" {
@if let Some(prev) = opts.prev_cursor {
(anchor(&cursor_href(prev), "autumn-pager__link autumn-pager__prev", opts.prev_label, opts))
} @else {
span class="autumn-pager__prev autumn-pager__disabled" aria-disabled="true" {
(opts.prev_label)
}
}
@if let Some(token) = next_token {
(anchor(&cursor_href(token), "autumn-pager__link autumn-pager__next", opts.next_label, opts))
} @else {
span class="autumn-pager__next autumn-pager__disabled" aria-disabled="true" {
(opts.next_label)
}
}
}
}
}
#[cfg(all(test, feature = "maud"))]
mod tests {
use super::*;
use crate::pagination::{CursorPage, CursorRequest, Page, PageRequest};
fn offset_page(page: u32, size: u32, total: i64) -> Page<u32> {
let req = PageRequest::new(page, size);
Page::new(Vec::new(), total, &req)
}
#[test]
fn window_single_page() {
assert_eq!(page_window(1, 1, 2), vec![PageItem::Page(1)]);
}
#[test]
fn window_small_total_has_no_ellipsis() {
let items = page_window(3, 5, 2);
assert_eq!(
items,
vec![
PageItem::Page(1),
PageItem::Page(2),
PageItem::Page(3),
PageItem::Page(4),
PageItem::Page(5),
]
);
}
#[test]
fn window_first_and_last_always_present() {
let items = page_window(10, 20, 2);
assert_eq!(items.first(), Some(&PageItem::Page(1)));
assert_eq!(items.last(), Some(&PageItem::Page(20)));
}
#[test]
fn window_middle_has_ellipses_both_sides() {
let items = page_window(10, 20, 2);
assert_eq!(
items,
vec![
PageItem::Page(1),
PageItem::Ellipsis,
PageItem::Page(8),
PageItem::Page(9),
PageItem::Page(10),
PageItem::Page(11),
PageItem::Page(12),
PageItem::Ellipsis,
PageItem::Page(20),
]
);
}
#[test]
fn window_no_ellipsis_for_single_gap() {
let items = page_window(4, 6, 1);
assert!(!items.contains(&PageItem::Ellipsis), "{items:?}");
assert!(items.contains(&PageItem::Page(2)), "{items:?}");
}
#[test]
fn window_no_adjacent_duplicate_pages() {
let items = page_window(2, 20, 2);
let ones = items.iter().filter(|i| **i == PageItem::Page(1)).count();
assert_eq!(ones, 1, "{items:?}");
}
#[test]
fn filter_query_drops_named_params_and_preserves_rest() {
let q = filter_query("q=foo&page=3&sort=name", &["page", "size"]);
assert_eq!(q, "q=foo&sort=name");
}
#[test]
fn filter_query_drops_all_listed_keys() {
let q = filter_query("q=foo&page=1&size=25", &["page", "size"]);
assert_eq!(q, "q=foo");
}
#[test]
fn filter_query_handles_empty_query() {
assert_eq!(filter_query("", &["page"]), "");
}
#[test]
fn filter_query_handles_query_with_only_stripped_params() {
assert_eq!(filter_query("page=5&size=10", &["page", "size"]), "");
}
#[test]
fn filter_query_strips_leading_question_mark() {
let q = filter_query("?q=foo&page=3", &["page"]);
assert_eq!(q, "q=foo");
}
#[test]
fn filter_query_preserves_unlisted_keys() {
let q = filter_query("a=1&b=2&c=3", &["b"]);
assert_eq!(q, "a=1&c=3");
}
#[test]
fn window_total_u32_max_does_not_panic() {
let _ = page_window(1, u32::MAX, 2);
}
#[test]
fn window_total_zero_returns_single_page() {
let items = page_window(1, 0, 2);
assert_eq!(items, vec![PageItem::Page(1)]);
}
#[test]
fn offset_include_size_appends_size_param_to_links() {
let page = offset_page(2, 25, 100);
let opts = PagerOptions::new("/posts").include_size();
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains("size=25"), "size param missing: {html}");
let hrefs: Vec<&str> = html
.match_indices("href=\"")
.map(|(i, _)| &html[i..])
.collect();
for h in &hrefs {
let end = h[6..].find('"').map_or(h.len(), |e| 6 + e);
let href = &h[6..end];
assert!(
href.matches("size=").count() <= 1,
"duplicate size= in {href}"
);
}
}
#[test]
fn offset_renders_nav_with_aria_label() {
let page = offset_page(2, 10, 100);
let opts = PagerOptions::new("/posts").aria_label("Posts pagination");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains("<nav"), "{html}");
assert!(html.contains(r#"aria-label="Posts pagination""#), "{html}");
}
#[test]
fn offset_active_page_has_aria_current() {
let page = offset_page(3, 10, 100);
let opts = PagerOptions::new("/posts");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn offset_disabled_prev_is_non_focusable_on_first_page() {
let page = offset_page(1, 10, 100);
let opts = PagerOptions::new("/posts");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains(r#"aria-disabled="true""#), "{html}");
assert!(
!html.contains(r#"href="/posts?page=0"#),
"prev link must not point at page 0: {html}"
);
}
#[test]
fn offset_disabled_next_on_last_page() {
let page = offset_page(10, 10, 100); let opts = PagerOptions::new("/posts");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains(r#"aria-disabled="true""#), "{html}");
assert!(!html.contains("page=11"), "{html}");
}
#[test]
fn offset_preserves_query_on_every_link() {
let page = offset_page(5, 10, 200); let opts = PagerOptions::new("/posts").query("q=foo&sort=name");
let html = pagination_nav(&page, &opts).into_string();
let hrefs: Vec<&str> = html
.match_indices("href=\"")
.map(|(i, _)| &html[i..])
.collect();
assert!(!hrefs.is_empty(), "expected page links: {html}");
for h in &hrefs {
let end = h[6..].find('"').map_or(h.len(), |e| 6 + e);
let href = &h[6..end];
assert!(href.contains("q=foo"), "missing q in {href}");
assert!(href.contains("sort=name"), "missing sort in {href}");
}
}
#[test]
fn offset_emits_windowed_numbers_with_ellipsis() {
let page = offset_page(10, 10, 200); let opts = PagerOptions::new("/posts");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains('…'), "{html}");
assert!(html.contains(">1<"), "first page anchor: {html}");
assert!(html.contains(">20<"), "last page anchor: {html}");
}
#[test]
fn offset_no_htmx_by_default() {
let page = offset_page(2, 10, 100);
let opts = PagerOptions::new("/posts");
let html = pagination_nav(&page, &opts).into_string();
assert!(!html.contains("hx-get"), "{html}");
assert!(html.contains("href="), "plain links expected: {html}");
}
#[test]
fn offset_htmx_opt_in_emits_hx_get_and_target() {
let page = offset_page(2, 10, 100);
let opts = PagerOptions::new("/posts").hx_target("#list").hx_push_url();
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains("hx-get="), "{html}");
assert!(html.contains(r##"hx-target="#list""##), "{html}");
assert!(html.contains(r#"hx-push-url="true""#), "{html}");
}
#[test]
fn offset_custom_page_param() {
let page = offset_page(2, 10, 100);
let opts = PagerOptions::new("/admin/users").page_param("p");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains("p=1"), "{html}");
assert!(!html.contains("page="), "{html}");
}
#[test]
fn offset_single_page_renders_nav_without_links() {
let page = offset_page(1, 10, 5); let opts = PagerOptions::new("/posts");
let html = pagination_nav(&page, &opts).into_string();
assert!(html.contains("<nav"), "{html}");
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
fn cursor_page(size: u32, overfetch: u32) -> CursorPage<u32> {
let req = CursorRequest::new(None, size);
let items: Vec<u32> = (1..=overfetch).collect();
CursorPage::from_overfetched(items, &req, |n| *n)
}
#[test]
fn cursor_renders_nav() {
let page = cursor_page(20, 21);
let opts = PagerOptions::new("/feed");
let html = cursor_pagination_nav(&page, &opts).into_string();
assert!(html.contains("<nav"), "{html}");
}
#[test]
fn cursor_has_no_page_numbers() {
let page = cursor_page(20, 21);
let opts = PagerOptions::new("/feed");
let html = cursor_pagination_nav(&page, &opts).into_string();
assert!(!html.contains("aria-current"), "{html}");
assert!(!html.contains('…'), "{html}");
}
#[test]
fn cursor_next_uses_next_cursor_when_has_next() {
let page = cursor_page(20, 21); assert!(page.has_next);
let token = page.next_cursor.clone().unwrap();
let opts = PagerOptions::new("/feed");
let html = cursor_pagination_nav(&page, &opts).into_string();
assert!(html.contains("cursor="), "{html}");
assert!(html.contains(&token), "next token in link: {html}");
}
#[test]
fn cursor_next_disabled_on_last_page() {
let page = cursor_page(20, 10); assert!(!page.has_next);
let opts = PagerOptions::new("/feed");
let html = cursor_pagination_nav(&page, &opts).into_string();
assert!(html.contains(r#"aria-disabled="true""#), "{html}");
}
#[test]
fn cursor_prev_link_only_when_prev_cursor_supplied() {
let page = cursor_page(20, 21);
let opts = PagerOptions::new("/feed").prev_cursor("PREVTOK");
let html = cursor_pagination_nav(&page, &opts).into_string();
assert!(html.contains("PREVTOK"), "{html}");
}
}