Howdy

Pagination

Pagination: links to the previous, next and nearby pages of a list.

componentimport howdy/ui/paginationSince Howdy 2.0
gleam run -m howdy/ui add pagination

Pagination

ui.pagination(current: 6, total: 12, href: fn(_) { "#" })
Full source of howdy/ui/pagination
//// Pagination: links to the previous, next and nearby pages of a list.
////
//// ```gleam
//// pagination.pagination(current: 4, total: 12, href: fn(page) {
////   "/orders?page=" <> int.to_string(page)
//// })
//// ```
////
//// The first and last pages and those next to the current one are always
//// shown; the gaps between are marked with an ellipsis. Pages are counted
//// from 1. Each link is an ordinary `<a>`, so it works without scripts; in
//// a live view use `live_pagination` to get attributes such as a click
//// handler or `live.navigate` instead of an `href`.

import gleam/int
import gleam/list
import howdy/ui/style.{class}
import howdy/ui/theme/tokens
import lustre/attribute.{type Attribute}
import lustre/element.{type Element, text}
import lustre/element/html
import sketch/css.{type Class}
import sketch/css/length.{rem}

/// Links to pages `1` to `total`, with `current` marked as the current
/// page.
pub fn pagination(
  current current: Int,
  total total: Int,
  href href: fn(Int) -> String,
) -> Element(msg) {
  live_pagination(current:, total:, attributes: fn(page) {
    [attribute.href(href(page))]
  })
}

/// Like `pagination`, with the attributes of each page's `<a>` chosen by
/// `attributes`.
pub fn live_pagination(
  current current: Int,
  total total: Int,
  attributes attributes: fn(Int) -> List(Attribute(msg)),
) -> Element(msg) {
  let current = int.clamp(current, 1, int.max(total, 1))
  let previous = case current > 1 {
    True ->
      html.a(
        [class(link_class()), attribute.rel("prev"), ..attributes(current - 1)],
        [
          html.span([attribute.aria_hidden(True)], [text("‹ ")]),
          text("Previous"),
        ],
      )
    False ->
      disabled([
        html.span([attribute.aria_hidden(True)], [text("‹")]),
        text("Previous"),
      ])
  }
  let next = case current < total {
    True ->
      html.a(
        [class(link_class()), attribute.rel("next"), ..attributes(current + 1)],
        [text("Next"), html.span([attribute.aria_hidden(True)], [text("›")])],
      )
    False ->
      disabled([
        text("Next"),
        html.span([attribute.aria_hidden(True)], [text("›")]),
      ])
  }
  let pages =
    pages(current, total)
    |> list.map(fn(page) {
      case page {
        Ellipsis ->
          html.li([], [
            html.span([class(ellipsis_class()), attribute.aria_hidden(True)], [
              text("…"),
            ]),
          ])
        Page(page) if page == current ->
          html.li([], [
            html.a(
              [
                class(link_class()),
                attribute.aria_current("page"),
                ..attributes(page)
              ],
              [text(int.to_string(page))],
            ),
          ])
        Page(page) ->
          html.li([], [
            html.a([class(link_class()), ..attributes(page)], [
              text(int.to_string(page)),
            ]),
          ])
      }
    })
  html.nav([class(nav_class()), attribute.aria_label("Pagination")], [
    html.ul(
      [class(list_class())],
      list.flatten([
        [html.li([], [previous])],
        pages,
        [html.li([], [next])],
      ]),
    ),
  ])
}

fn disabled(children: List(Element(msg))) -> Element(msg) {
  html.span([class(link_class()), attribute.aria_disabled(True)], children)
}

type Slot {
  Page(Int)
  Ellipsis
}

/// The first, the last, and the current page with one either side. A gap
/// of one page is filled with that page rather than an ellipsis.
fn pages(current: Int, total: Int) -> List(Slot) {
  let wanted =
    [1, total, current - 1, current, current + 1]
    |> list.filter(fn(page) { page >= 1 && page <= total })
    |> list.unique
    |> list.sort(int.compare)
  let #(slots, _) =
    list.fold(wanted, #([], 0), fn(acc, page) {
      let #(slots, last) = acc
      let slots = case page - last {
        1 -> slots
        2 -> [Page(last + 1), ..slots]
        _ if last == 0 -> slots
        _ -> [Ellipsis, ..slots]
      }
      #([Page(page), ..slots], page)
    })
  list.reverse(slots)
}

/// Every class this module uses, for `howdy/ui/export`.
pub fn classes() -> List(Class) {
  [nav_class(), list_class(), link_class(), ellipsis_class()]
}

pub fn nav_class() -> Class {
  css.class([css.display("flex"), css.justify_content("center")])
}

pub fn list_class() -> Class {
  css.class([
    css.display("flex"),
    css.flex_wrap("wrap"),
    css.align_items("center"),
    css.gap(rem(0.25)),
    css.margin(rem(0.0)),
    css.padding(rem(0.0)),
    css.list_style("none"),
  ])
}

pub fn link_class() -> Class {
  css.class([
    css.selector(":dir(rtl) > span[aria-hidden=\"true\"]", [
      css.display("inline-block"),
      css.transform_("scaleX(-1)"),
    ]),
    css.display("inline-flex"),
    css.align_items("center"),
    css.justify_content("center"),
    css.gap(rem(0.25)),
    css.property("min-width", "2.25rem"),
    css.padding_(tokens.space_2 <> " " <> tokens.space_3),
    css.border("1px solid transparent"),
    style.radius(tokens.radius_medium),
    css.color(tokens.text),
    css.font_size(rem(0.875)),
    css.font_weight("500"),
    css.line_height("1.25"),
    css.text_decoration("none"),
    css.white_space("nowrap"),
    css.cursor("pointer"),
    css.hover([css.background(tokens.muted)]),
    style.focus_ring(),
    css.selector("[aria-current=\"page\"]", [
      css.property("border-color", tokens.border),
      css.background(tokens.surface),
    ]),
    css.selector("[aria-disabled=\"true\"]", [
      css.property("opacity", "0.5"),
      css.cursor("default"),
      css.property("pointer-events", "none"),
    ]),
  ])
}

pub fn ellipsis_class() -> Class {
  css.class([
    css.display("inline-flex"),
    css.justify_content("center"),
    css.property("min-width", "2.25rem"),
    css.color(tokens.text_muted),
  ])
}