Pagination

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJS-moduleApache-2.0
Installation mix mishka.ui.gen.headless pagination
Source View Source

Overview of the Phoenix and LiveView Headless Pagination Component

The Mishka Chelekom Headless Pagination is an unstyled Phoenix function component that works out which page numbers to show. Give it total and page and it keeps boundaries pages at each end, siblings pages on each side of the current one, and an ellipsis for whatever is left. The window never changes width as you move through the pages, so the controls do not jump under the cursor. It renders a nav with a list of buttons, links or radio inputs.

The current page is a disabled button with aria-current="page" instead of a link to where you already are. The previous and next controls disable themselves at the ends instead of wrapping around. Every part takes a *_class attribute: list_class, item_class, control_class and ellipsis_class. The pages can push a LiveView event, link to real URLs, or post with a form.

Base UI Pagination example - a computed window over ten pages (Phoenix headless)

Base UI Pagination example - a computed window over ten pages (Phoenix headless)

heex
<.pagination
  id="baseui-pagination-hero"
  total={10}
  page={4}
  list_class="flex flex-wrap items-center gap-1"
  control_class="inline-flex min-w-8 items-center justify-center rounded-md border px-2 py-1 text-sm border-neutral-200 text-neutral-950 dark:border-neutral-800 dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 aria-[current=page]:bg-neutral-950 aria-[current=page]:text-white dark:aria-[current=page]:bg-white dark:aria-[current=page]:text-neutral-950 disabled:text-neutral-400 dark:disabled:text-neutral-600"
/>

Base UI Pagination example - a hundred pages in seven controls (Phoenix headless)

Base UI Pagination example - a hundred pages in seven controls (Phoenix headless)

heex
<.pagination
  id="baseui-pagination-window"
  total={100}
  page={50}
  show_edges
  list_class="flex flex-wrap items-center gap-1"
  control_class="inline-flex min-w-8 items-center justify-center rounded-md border px-2 py-1 text-sm border-neutral-200 text-neutral-950 dark:border-neutral-800 dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 aria-[current=page]:bg-neutral-950 aria-[current=page]:text-white dark:aria-[current=page]:bg-white dark:aria-[current=page]:text-neutral-950 disabled:text-neutral-400 dark:disabled:text-neutral-600"
/>

Choosing how pages are rendered

There are three ways to render a page, and they are mutually exclusive. The previews above set none of them, so their buttons do not respond.

on_select makes every page a button. It takes a LiveView event name, which receives %{"page" => page} with an integer page, or a Phoenix.LiveView.JS command.

heex
<.pagination id="posts-pages" total={@total_pages} page={@page} on_select="go_to_page" />
elixir
def handle_event("go_to_page", %{"page" => page}, socket) do
  {:noreply, assign(socket, :page, page)}
end

href takes a function of the page number that returns a path. The pages become real links, so they can be crawled and work without JavaScript. The current page stays a button, and the previous and next controls become links too, except where they are disabled.

heex
<.pagination
  id="posts-pages"
  total={@total_pages}
  page={@page}
  href={fn page -> ~p"/posts?page=#{page}" end}
/>

name renders radio inputs under that name, for a pagination that posts with a surrounding form and needs no JavaScript.

heex
<.form for={@form} id="filter-form" phx-change="filter">
  <.pagination id="posts-pages" total={@total_pages} page={@page} name="page" />
</.form>

The page window

siblings (default 1) is how many pages show on each side of the current one. boundaries (default 1) is how many pages stay pinned at each end. The window is as wide as siblings * 2 + 3 + boundaries * 2 pages, which is seven with the defaults. When total fits in that width, every page is shown and nothing is elided. page is clamped between 1 and total.

The generated module exposes the arithmetic as a public window/4 function that takes total, page, siblings and boundaries. It returns the page numbers with :ellipsis where a run is skipped. Use it if you build your own control, such as a jump box.

elixir
window(10, 4, 1, 1)
#=> [1, 2, 3, 4, 5, :ellipsis, 10]

window(100, 50, 1, 1)
#=> [1, :ellipsis, 49, 50, 51, :ellipsis, 100]

window(100, 100, 1, 1)
#=> [1, :ellipsis, 96, 97, 98, 99, 100]

Controls and labels

show_controls (default true) renders the previous and next controls. show_edges (default false) adds first and last controls. At the ends of the range a control is disabled and stays in place, so the row keeps its width. disabled disables every control at once.

previous_label, next_label, first_label, last_label and ellipsis_label set the visible content of those parts. They default to «, », ««, »» and …, and take any text. The accessible names are fixed English strings: First page, Previous page, Next page, Last page and Page 4 for a numbered page. label is the accessible name of the nav landmark and defaults to Pagination.

Parts & data attributes

The root is a nav with data-part="root", data-total and data-page. Inside it, list is a ul and every entry is an item, a li. A numbered page is marked data-part="page" with data-page and data-current. The controls are first, previous, next and last, and a skipped run is an ellipsis hidden from assistive technology. control_class applies to every control, including the numbered pages. item_class applies to every li.

Accessibility

The nav is a landmark named by label. The current page has aria-current="page", and every numbered page has an aria-label such as Page 4. Tab moves between the controls. Buttons activate with Enter or Space and links with Enter. In radio mode the arrow keys move between the pages natively.