File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import type { ReactNode } from "react";
import { cx } from "../../utils";
import { FoxCaretLeftIcon, FoxCaretRightIcon } from "../../icons";
export interface FoxPaginationProps {
/** 1부터 센다. */
currentPage: number;
totalPages: number;
onPageChange?: (page: number) => void;
/**
* 페이지 번호를 링크로 만들 때. 주면 `<a>`가 되고, 없으면 `<button>`이 된다.
* 라우팅은 호출부 몫이다.
*/
buildHref?: (page: number) => string;
/** 한 번에 보여줄 번호 개수. 기본 5. */
windowSize?: number;
/**
* 창 밖에 페이지가 더 있으면 `…`을 붙인다. 시안의 pagination-item에 있는 모양이다.
* 기본값은 꺼짐 — 시안의 pagination 조합에는 없다.
*/
ellipsis?: boolean;
previousLabel?: string;
nextLabel?: string;
/** 스크린리더가 읽을 이름. 한 화면에 둘 이상 두면 서로 다르게 준다. */
label?: string;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
/** 현재 페이지를 가운데 두고 `size`개를 [1, total] 안에서 민다. */
function pageWindow(current: number, total: number, size: number): number[] {
if (total < 1) {
return [];
}
const start = Math.max(1, Math.min(current - Math.floor(size / 2), total - size + 1));
const end = Math.min(total, start + size - 1);
const pages: number[] = [];
for (let page = Math.max(1, start); page <= end; page += 1) {
pages.push(page);
}
return pages;
}
/**
* @fox 페이지네이션.
*
* PC는 [이전] [번호들] [다음]이 한 줄이고, 모바일은 이전·다음이 위로 빠지고 번호가 아래
* 줄에 선다(시안의 device 축). 그 전환은 CSS가 화면 폭을 보고 하므로 프롭이 없다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxPagination({
currentPage,
totalPages,
onPageChange,
buildHref,
windowSize = 5,
ellipsis = false,
previousLabel = "이전",
nextLabel = "다음",
label = "페이지",
hidden = false,
className,
}: FoxPaginationProps) {
if (hidden || totalPages < 1) {
return null;
}
const pages = pageWindow(currentPage, totalPages, windowSize);
const first = pages[0] ?? 1;
const last = pages[pages.length - 1] ?? 1;
const step = (target: number, disabled: boolean, direction: "prev" | "next", text: string) => {
const icon = (
<span className="fox-pagination__step-icon" aria-hidden="true">
{direction === "prev" ? <FoxCaretLeftIcon /> : <FoxCaretRightIcon />}
</span>
);
const body: ReactNode =
direction === "prev" ? (
<>
{icon}
{text}
</>
) : (
<>
{text}
{icon}
</>
);
const itemClass = cx("fox-pagination__item", "fox-pagination__step");
// 갈 곳이 없으면 링크로 두지 않는다 — 눌리지 않는 링크는 스크린리더에서도 걸린다.
if (disabled) {
return (
<span className={itemClass} aria-disabled="true">
{body}
</span>
);
}
return buildHref ? (
<a className={itemClass} href={buildHref(target)} onClick={() => onPageChange?.(target)}>
{body}
</a>
) : (
<button className={itemClass} type="button" onClick={() => onPageChange?.(target)}>
{body}
</button>
);
};
const number = (page: number) => {
const current = page === currentPage;
const itemClass = cx(
"fox-pagination__item",
"fox-pagination__number",
current && "fox-pagination__number--current"
);
if (current) {
return (
<span className={itemClass} aria-current="page">
{page}
</span>
);
}
return buildHref ? (
<a
className={itemClass}
href={buildHref(page)}
aria-label={`${page}페이지`}
onClick={() => onPageChange?.(page)}
>
{page}
</a>
) : (
<button
className={itemClass}
type="button"
aria-label={`${page}페이지`}
onClick={() => onPageChange?.(page)}
>
{page}
</button>
);
};
const more = (
<span className="fox-pagination__item fox-pagination__ellipsis" aria-hidden="true">
…
</span>
);
// 이전·목록·다음이 형제다. 모바일에서 목록만 한 줄을 통째로 차지해 아래로 밀린다.
return (
<nav className={cx("fox-pagination", className)} aria-label={label}>
{step(currentPage - 1, currentPage <= 1, "prev", previousLabel)}
<ul className="fox-pagination__list">
{ellipsis && first > 1 && <li>{more}</li>}
{pages.map((page) => (
<li key={page}>{number(page)}</li>
))}
{ellipsis && last < totalPages && <li>{more}</li>}
</ul>
{step(currentPage + 1, currentPage >= totalPages, "next", nextLabel)}
</nav>
);
}