File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-18
08-18
08-14
08-18
08-18
08-18
08-18
08-18
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>
);
}