Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# testing
/coverage
example.http

# next.js
/.next/
Expand Down
110 changes: 110 additions & 0 deletions components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import PaginationButton from './PaginationButton';

interface PaginationProps {
totalCount: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
}

/**
* 페이지네이션 컴포넌트
* @param totalCount - 전체 데이터 개수
* @param currentPage - 현재 페이지 번호
* @param pageSize - 페이지 당 데이터 개수
* @param onPageChange - 페이지 변경 핸들러(상태 리프팅, setter 함수를 받음)
* @example
* <Pagination
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
*/
export default function Pagination({
totalCount,
currentPage,
pageSize,
onPageChange,
}: PaginationProps) {
const totalPages = Math.ceil(totalCount / pageSize);
// 노출 될 페이지네이션의 총 개수 (현재 5개만 노출)
const maxPages = 5;

// 이전 페이지로 이동할 때, 최소 1 페이지를 유지
const handlePrev = () => {
onPageChange(Math.max(currentPage - 1, 1));
};

// 다음 페이지로 이동할 때, 최대 totalPages 페이지를 유지
const handleNext = () => {
onPageChange(Math.min(currentPage + 1, totalPages));
};

// 총 페이지 번호를 계산하여 배열로 리턴하는 함수
const getPages = () => {
const pages = []; // 반환할 페이지 번호 배열
const half = Math.floor(maxPages / 2); // 현재 페이지 중심 버튼 배치 기준점(중앙 배치를 위해 /2)

let start = Math.max(currentPage - half, 1); // 시작 페이지 계산
let end = Math.min(start + maxPages - 1, totalPages); // 끝 페이지 계산

/**
* 시작 페이지 조정
* - end - start < maxPages - 1: 끝 페이지가 maxPages보다 작을 경우 조건 실행
* - (끝 페이지 - 총 페이지 + 1) 과 1 중 큰 값을 선택하여 시작 페이지 재할당
*/
if (end - start < maxPages - 1) {
start = Math.max(end - maxPages + 1, 1);
}

// 페이지 배열 생성
for (let i = start; i <= end; i++) {
pages.push(i);
}

return pages;
};

const arrowStyles = 'h-[24px] w-[24px] mo:h-[18px] mo:w-[18px]';

return (
<div className="flex items-center justify-center gap-[15px] mo:gap-[10px]">
{/* 이전 페이지 버튼 - 현재 페이지가 1일 경우 disabled*/}
<PaginationButton onClick={handlePrev} disabled={currentPage === 1}>
<img
src="/icon/icon-arrow.svg"
alt="이전 페이지로 이동"
className={arrowStyles}
/>
</PaginationButton>

{/* 페이지 버튼 목록 */}
<div className="flex items-center justify-center gap-[10px] mo:gap-[5px]">
{getPages().map((pageNumber) => (
<PaginationButton
key={pageNumber}
onClick={() => onPageChange(pageNumber)}
className={
pageNumber === currentPage ? 'text-green-200' : 'text-gray-400'
}
>
{pageNumber}
</PaginationButton>
))}
</div>

{/* 다음 페이지 버튼 - 현재 페이지가 totalPages와 같으면 disabled*/}
<PaginationButton
onClick={handleNext}
disabled={currentPage === totalPages}
>
<img
src="/icon/icon-arrow.svg"
alt="다음 페이지로 이동"
className={`${arrowStyles} rotate-180`}
/>
</PaginationButton>
</div>
);
}
30 changes: 30 additions & 0 deletions components/Pagination/PaginationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
interface PaginationButtonProps {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
className?: string;
}

/**
* Pagination button component
* @param children - 버튼 컨텐츠(아이콘, 텍스트 등)
* @param onClick - 버튼 클릭 핸들러
* @param className - 커스텀 클래스
* @param disabled - 버튼 비활성화 여부
*/
export default function PaginationButton({
children,
onClick,
className,
disabled,
}: PaginationButtonProps) {
return (
<button
className={`${className} flex h-[45px] w-[45px] items-center justify-center rounded-custom bg-background text-18 text-gray-400 shadow-custom dark:shadow-custom-dark`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
69 changes: 69 additions & 0 deletions pages/test/pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';

import Pagination from '@/components/Pagination/Pagination';

interface Post {
writer: {
name: string;
id: number;
};
title: string;
id: number;
}

/**
* 테스트를 위한 더미 데이터 생성
*/
const dummyData: Post[] = Array.from({ length: 100 }, (_, i) => ({
writer: {
name: `작성자 ${i + 1}`,
id: i + 1,
},
title: `게시글 제목 ${i + 1}`,
id: i + 1,
}));

export default function PaginationTest() {
const [data, setData] = useState<Post[]>([]); // 게시글 데이터
const [totalCount, setTotalCount] = useState(0); // 전체 페이지 카운트
const [currentPage, setCurrentPage] = useState(1); // 현재 페이지
const pageSize = 10; // 페이지 당 데이터 개수

// 실제 서버 요청 대신 더미 데이터 사용
useEffect(() => {
const fetchData = async () => {
// 페이지를 넘겼을때 시작 데이터 위치 계산
const startIndex = (currentPage - 1) * pageSize;
// 페이지를 넘겼을때 마지막 데이터 위치 계산
const endIndex = startIndex + pageSize;
// pageSize만큼 잘라서 데이터 노출
const currentData = dummyData.slice(startIndex, endIndex);
setData(currentData);
setTotalCount(dummyData.length);
};

fetchData();
}, [currentPage]);

return (
<>
{/* 더미 게시글 UI */}
<ul>
{data.map((item) => (
<li key={item.id} className="flex justify-between border-b px-4 py-2">
<h3>{item.title}</h3>
<p>{item.writer.name}</p>
</li>
))}
</ul>

{/* Pagination 컴포넌트 사용 부분 */}
<Pagination
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</>
);
}
3 changes: 3 additions & 0 deletions public/icon/icon-arrow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default {
},
boxShadow: {
custom: '0px 4px 20px 0px rgba(0, 0, 0, 0.08)',
'custom-dark': '0px 4px 20px 0px rgba(255, 255, 255, 0.08)',
},
fontSize: {
// 12px
Expand Down
Loading