컴포넌트

Pagination

긴 목록을 페이지 단위로 이동합니다.

설치

pnpm dlx ply-ui add pagination

사용법

pageCount는 전체 페이지 수이며, page는 1부터 시작합니다. onPageChange에서 표시할 데이터와 현재 페이지를 갱신합니다. 페이지가 많으면 처음·끝·현재 주변 페이지를 남기고 중간을 생략합니다.

이 컴포넌트는 페이지 선택 UI입니다. 서버 요청과 URL 변경은 사용하는 앱에서 처리합니다. 필터가 바뀌어 현재 페이지가 범위를 벗어나면 선택을 보정하고 onPageChange에 유효한 페이지를 전달합니다. 제어 방식에서는 이 콜백으로 page를 갱신합니다. 페이지가 0개면 탐색 UI를 숨깁니다.

내부에서 페이지 관리

page 없이 defaultPage를 지정하면 선택 상태를 내부에서 관리합니다.

비활성

요청 중 중복 이동을 막으려면 disabled를 지정합니다. 처음과 마지막 페이지에서는 해당 방향의 이동 버튼만 자동으로 비활성화됩니다.

접근성

현재 페이지에는 aria-current="page"를 지정합니다. Tab으로 버튼을 이동하고 Enter 또는 Space로 선택합니다. 페이지 이동 영역이 여러 개라면 각각 다른 aria-label을 지정합니다. getPageLabel로 번호 버튼의 읽기 이름을, previousLabel·nextLabel·pageInputLabel로 이동 버튼과 입력칸의 이름을 바꿀 수 있습니다. 기본 문구는 한국어입니다.

속성

속성

타입

좁은 영역에서의 동작

주어진 영역에 숫자 버튼을 모두 배치할 수 없으면 페이지 입력칸으로 전환합니다. Enter 또는 포커스 이동으로 페이지를 확정하고, Escape로 편집을 취소합니다. 반응형 예제에서 좁은 영역의 동작을 확인할 수 있습니다.

구현 코드

components/ui/pagination.tsx
pagination.tsx
"use client";

import {
  useState,
  useEffect,
  useLayoutEffect,
  useRef,
  type ComponentProps,
} from "react";
import { useMergedRef } from "../../lib/use-merged-ref";
import { cx } from "../../lib/cx";
import { paginationItems } from "../../lib/pagination";
import { Button } from "./button";
import { Icon } from "./icon";
import "./pagination.css";

export type PaginationProps = Omit<ComponentProps<"nav">, "children"> & {
  /** 전체 페이지 수입니다. 0이면 페이지 이동 UI를 표시하지 않습니다. */
  pageCount: number;
  /** 현재 페이지입니다. 1부터 시작합니다. */
  page?: number;
  /** 비제어 방식의 시작 페이지입니다. @defaultValue 1 */
  defaultPage?: number;
  onPageChange?: (page: number) => void;
  /** 데이터를 불러오는 동안 이동을 막을 때 사용합니다. @defaultValue false */
  disabled?: boolean;
  /** 페이지 버튼의 접근 가능한 이름입니다. @defaultValue (page) => `${page}페이지` */
  getPageLabel?: (page: number) => string;
  /** 이전 페이지 버튼의 접근 가능한 이름입니다. @defaultValue "이전 페이지" */
  previousLabel?: string;
  /** 다음 페이지 버튼의 접근 가능한 이름입니다. @defaultValue "다음 페이지" */
  nextLabel?: string;
  /** 좁은 영역에서 표시하는 페이지 입력칸의 이름입니다. @defaultValue "이동할 페이지" */
  pageInputLabel?: string;
};

/** 페이지 선택만 담당합니다. 데이터 요청과 URL 갱신은 onPageChange에서 처리합니다. */
export function Pagination({
  pageCount,
  page,
  defaultPage = 1,
  onPageChange,
  disabled = false,
  getPageLabel = (value) => `${value}페이지`,
  previousLabel = "이전 페이지",
  nextLabel = "다음 페이지",
  pageInputLabel = "이동할 페이지",
  className,
  ref,
  ...props
}: PaginationProps) {
  const [internalPage, setInternalPage] = useState(defaultPage);
  const root = useRef<HTMLElement>(null);
  const mergedRef = useMergedRef(root, ref);
  const [compact, setCompact] = useState(false);
  const restoreFocus = useRef(false);
  const lastCorrection = useRef<string | null>(null);
  const count = Number.isFinite(pageCount)
    ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(pageCount)))
    : 0;
  const value = page ?? internalPage;
  const current = Number.isFinite(value)
    ? Math.max(1, Math.min(count, Math.floor(value)))
    : 1;
  const items = paginationItems(current, count);
  // 화면 너비가 아니라 이 탐색 영역에 실제로 주어진 공간을 기준으로 바꿉니다.
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const update = () => {
      const button = element.querySelector("button");
      if (!button) return;
      const style = getComputedStyle(button);
      const canvas = element.ownerDocument
        .createElement("canvas")
        .getContext("2d");
      if (canvas) canvas.font = style.font;
      const inset =
        parseFloat(style.paddingLeft) +
        parseFloat(style.paddingRight) +
        parseFloat(style.borderLeftWidth) +
        parseFloat(style.borderRightWidth);
      const gap = parseFloat(getComputedStyle(element).columnGap) || 0;
      const required =
        2 * button.getBoundingClientRect().width +
        gap * (items.length + 1) +
        items.reduce<number>(
          (sum, item) =>
            sum +
            Math.max(
              32,
              (canvas?.measureText(
                String(typeof item === "number" ? item : "…"),
              ).width ?? 16) + inset,
            ),
          0,
        );
      const next = element.clientWidth < required;
      const focused = element.ownerDocument.activeElement;
      const willRemoveFocus = next
        ? focused?.hasAttribute("data-page-number")
        : focused?.matches(".rbx-pagination-summary input");
      if (
        next !== element.hasAttribute("data-compact") &&
        willRemoveFocus &&
        element.contains(focused)
      )
        restoreFocus.current = true;
      setCompact(next);
    };
    update();
    const observer = new ResizeObserver(update);
    observer.observe(element);
    element.ownerDocument.fonts.addEventListener("loadingdone", update);
    return () => {
      observer.disconnect();
      element.ownerDocument.fonts.removeEventListener("loadingdone", update);
    };
  }, [current, count]);
  useLayoutEffect(() => {
    if (!restoreFocus.current) return;
    restoreFocus.current = false;
    root.current?.querySelector<HTMLElement>('[aria-current="page"]')?.focus();
  }, [compact]);
  // 필터 변경으로 범위를 벗어나면 UI와 내부 상태·호출자의 데이터가 같은 페이지를 가리키게 합니다.
  useEffect(() => {
    if (!count || value === current) {
      lastCorrection.current = null;
      return;
    }
    const correction = String(value) + ":" + current;
    if (lastCorrection.current === correction) return;
    lastCorrection.current = correction;
    if (page === undefined) setInternalPage(current);
    onPageChange?.(current);
  }, [count, value, current, page, onPageChange]);
  function select(next: number) {
    if (disabled || next === current || next < 1 || next > count) return;
    if (page === undefined) setInternalPage(next);
    onPageChange?.(next);
  }
  if (count === 0) return null;
  return (
    <nav
      aria-label="페이지 이동"
      ref={mergedRef}
      data-compact={compact || undefined}
      {...props}
      className={cx("rbx-pagination", className)}
    >
      <Button
        size="sm"
        variant="utility"
        aria-label={previousLabel}
        disabled={disabled || current === 1}
        onClick={() => select(current - 1)}
      >
        <Icon name="icon-regular-chevron-large-left" size={16} />
      </Button>
      {!compact &&
        items.map((item) =>
          typeof item === "number" ? (
            <Button
              key={item}
              data-page-number={item}
              size="sm"
              variant={item === current ? "standard" : "utility"}
              aria-label={getPageLabel(item)}
              aria-current={item === current ? "page" : undefined}
              disabled={disabled}
              onClick={() => select(item)}
            >
              {item}
            </Button>
          ) : (
            <span key={item} className="rbx-pagination-gap" aria-hidden="true">
              …
            </span>
          ),
        )}
      {compact && (
        <PageInput
          page={current}
          count={count}
          disabled={disabled}
          label={pageInputLabel}
          onCommit={select}
        />
      )}
      <Button
        size="sm"
        variant="utility"
        aria-label={nextLabel}
        disabled={disabled || current === count}
        onClick={() => select(current + 1)}
      >
        <Icon name="icon-regular-chevron-large-right" size={16} />
      </Button>
    </nav>
  );
}

/** 좁은 화면에서도 여러 페이지를 한 번에 이동할 수 있게 직접 입력을 유지합니다. */
function PageInput({
  page,
  count,
  disabled,
  label,
  onCommit,
}: {
  page: number;
  count: number;
  disabled: boolean;
  label: string;
  onCommit: (page: number) => void;
}) {
  const [draft, setDraft] = useState(String(page));
  useEffect(() => {
    setDraft(String(page));
  }, [page, count]);
  const commit = () => {
    const number = Number(draft);
    if (draft.trim() && Number.isFinite(number))
      onCommit(Math.max(1, Math.min(count, Math.floor(number))));
    setDraft(String(page));
  };
  return (
    <span className="rbx-pagination-summary">
      <input
        aria-label={label}
        aria-current="page"
        inputMode="numeric"
        value={draft}
        disabled={disabled}
        style={{ width: Math.min(String(count).length + 1, 8) + "ch" }}
        onChange={(event) => setDraft(event.target.value)}
        onBlur={commit}
        onKeyDown={(event) => {
          if (event.key === "Enter") {
            event.preventDefault();
            commit();
          } else if (event.key === "Escape") {
            setDraft(String(page));
            event.stopPropagation();
          }
        }}
      />
      <span> / {count}</span>
    </span>
  );
}
components/ui/pagination.css
pagination.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@import "./button.css";
@layer components.ply {
  .rbx-pagination {
    display: flex;
    flex-wrap: nowrap;
    max-width: 100%;
    min-width: 0;
    align-items: center;
    gap: 4px;
  }
  .rbx-pagination > .rbx-button,
  .rbx-pagination-gap {
    min-width: 32px;
    padding-inline: 6px;
    text-align: center;
  }
  .rbx-pagination .rbx-button[aria-current="page"] {
    box-shadow: inset 0 0 0 1px var(--rbx-color-stroke-contrast-alpha);
  }
  .rbx-pagination-gap {
    color: var(--rbx-color-content-default);
  }
  .rbx-pagination:dir(rtl) > .rbx-button .rbx-icon {
    transform: rotate(180deg);
  }

  .rbx-pagination > .rbx-button {
    flex-shrink: 0;
  }
  .rbx-pagination-summary {
    min-width: 0;
    flex: 1;
    text-align: center;
    overflow-wrap: anywhere;
    font: var(--rbx-typography-body-small-font);
    font-variant-numeric: tabular-nums;
  }
  .rbx-pagination-summary {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 2px;
  }
  .rbx-pagination-summary input {
    min-width: 2ch;
    max-width: 100%;
    min-height: 32px;
    padding: 2px;
    border: 1px solid var(--rbx-color-stroke-default);
    border-radius: 4px;
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: center;
  }
  .rbx-pagination-summary input:focus-visible {
    outline: 2px solid var(--rbx-focus-ring);
    outline-offset: 1px;
  }
}

목차