Saltar al contenido
ssntl/ui

Pagination

Anterior, números de página y Siguiente a una misma altura de 48px, con la página actual rellena y anunciada.

Orders 81–100 of 240

Instalación

$ pnpm dlx shadcn@latest add @ssntl/pagination

Uso

tsx
import { Pagination } from "@/components/ui/pagination"

// Enlaces, para páginas que se comparten y que siguen los buscadores
<Pagination page={5} total={12} getHref={(p) => `/pedidos?page=${p}`} labels={etiquetas} />

// Botones, para una lista que cambia en el cliente
<Pagination page={page} total={12} onPageChange={setPage} labels={etiquetas} />
  • Alineación de controles: Anterior, los números y Siguiente miden 48px, sobre una misma línea base.
  • La página actual va rellena y lleva aria-current="page"; cada número se nombra “Página 5”.
  • Muestra los extremos, los vecinos de la página actual y saltos (1 … 4 5 6 … 12). En teléfonos solo queda el número actual.
  • Prefiere getHref: los enlaces reales se abren en otra pestaña y se comparten. Pasa labels en el idioma de la persona.

Código

components/ui/pagination.tsx
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/registry/ssntl/ui/button"

// Control alignment: Previous, the page numbers and Next are all 48px tall
// and share one baseline, so the row reads as one control. The current page
// is filled and carries aria-current. On phones only the current number
// stays between Previous and Next, so the row never wraps.
export interface PaginationLabels {
  nav: string
  previous: string
  next: string
  page: (page: number) => string
}

const englishLabels: PaginationLabels = {
  nav: "Pagination",
  previous: "Previous",
  next: "Next",
  page: (page) => `Page ${page}`,
}

/** 1 … 4 5 6 … 12: the ends, the neighbours of the current page, and gaps. */
const pagesToShow = (page: number, total: number) => {
  const pages = new Set([1, total, page - 1, page, page + 1])
  const sorted = [...pages]
    .filter((p) => p >= 1 && p <= total)
    .sort((a, b) => a - b)
  return sorted.flatMap((p, i) =>
    i > 0 && p - sorted[i - 1]! > 1 ? (["gap", p] as const) : [p]
  )
}

function Pagination({
  page,
  total,
  getHref,
  onPageChange,
  labels = englishLabels,
  className,
  ...props
}: Omit<React.ComponentProps<"nav">, "children"> & {
  /** The current page, from 1. */
  page: number
  total: number
  /** Renders links (best for SEO and sharing). */
  getHref?: (page: number) => string
  /** Renders buttons, for client-side lists. */
  onPageChange?: (page: number) => void
  labels?: PaginationLabels
}) {
  // A link when there is somewhere to go, a disabled button at either end.
  const item = (
    target: number,
    disabled: boolean,
    buttonProps: React.ComponentProps<typeof Button>,
    children: React.ReactNode
  ) =>
    getHref && !disabled ? (
      <Button asChild {...buttonProps}>
        <a href={getHref(target)}>{children}</a>
      </Button>
    ) : (
      <Button
        {...buttonProps}
        disabled={disabled}
        onClick={() => onPageChange?.(target)}
      >
        {children}
      </Button>
    )

  return (
    <nav
      data-slot="pagination"
      aria-label={labels.nav}
      className={cn("w-full", className)}
      {...props}
    >
      <ul className="flex items-center justify-center gap-1">
        <li>
          {item(
            page - 1,
            page <= 1,
            { variant: "ghost" },
            <>
              <ChevronLeft data-icon="inline-start" strokeWidth={1.6} />
              {labels.previous}
            </>
          )}
        </li>
        {pagesToShow(page, total).map((p, i) =>
          p === "gap" ? (
            <li
              key={`gap-${i}`}
              aria-hidden
              className="grid size-12 place-items-center text-label text-muted-foreground max-sm:hidden"
            >
              …
            </li>
          ) : (
            <li key={p} className={cn(p !== page && "max-sm:hidden")}>
              {item(
                p,
                false,
                {
                  size: "icon",
                  variant: p === page ? "secondary" : "ghost",
                  "aria-current": p === page ? "page" : undefined,
                  "aria-label": labels.page(p),
                  className: cn(
                    "px-0 tabular-nums",
                    p === page && "text-foreground"
                  ),
                },
                p
              )}
            </li>
          )
        )}
        <li>
          {item(
            page + 1,
            page >= total,
            { variant: "ghost" },
            <>
              {labels.next}
              <ChevronRight data-icon="inline-end" strokeWidth={1.6} />
            </>
          )}
        </li>
      </ul>
    </nav>
  )
}

export { Pagination, pagesToShow }