From c9df07e2b335490c8133ccf8bc3fc46c244f703a Mon Sep 17 00:00:00 2001 From: "Lukas.J.Han" Date: Tue, 20 Aug 2024 21:31:48 +0900 Subject: [PATCH] feat: add pagination component (#84) Signed-off-by: Lukas.J.Han --- packages/core/lib/components/Pagination.tsx | 238 ++++++++++++++++++++ packages/core/lib/index.ts | 2 + stories/core/Pagination.stories.ts | 92 ++++++++ 3 files changed, 332 insertions(+) create mode 100644 packages/core/lib/components/Pagination.tsx create mode 100644 stories/core/Pagination.stories.ts diff --git a/packages/core/lib/components/Pagination.tsx b/packages/core/lib/components/Pagination.tsx new file mode 100644 index 0000000..03bdaa9 --- /dev/null +++ b/packages/core/lib/components/Pagination.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import { Label } from './Label'; +import { Button } from './Button'; + +function generateSequence(current: number, count: number): number[] { + const result: number[] = []; + const start = current - Math.floor((count - 1) / 2); + + for (let i = 0; i < count; i++) { + result.push(start + i); + } + + return result; +} + +function generateRangeGuaranteedSequence( + current: number, + count: number, + min: number, + max: number +): (number | string)[] { + const initialSequence = generateSequence(current, count); + const filteredSequence = initialSequence.filter( + (num) => num >= min && num <= max + ); + const result: (number | 'ellipsis')[] = [...filteredSequence]; + + if (!filteredSequence.includes(min)) { + if (filteredSequence[0] - min > 1) { + result.unshift(min, 'ellipsis'); + } else { + result.unshift(min); + } + } + + if (!filteredSequence.includes(max)) { + if (max - filteredSequence[filteredSequence.length - 1] > 1) { + result.push('ellipsis', max); + } else { + result.push(max); + } + } + + return result; +} + +export const PrevIcon: React.FC = () => ( + + + +); + +export const NextIcon: React.FC = () => ( + + + +); + +export const EllipsisIcon: React.FC = () => ( + +); + +interface PaginationProps { + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; + visiblePages?: number; + allowDirectInput?: boolean; + twoLines?: boolean; +} + +export const Pagination: React.FC = ({ + totalPages, + currentPage, + onPageChange, + visiblePages: propVisiblePages = 5, + allowDirectInput = false, + twoLines = false, +}) => { + const [inputPage, setInputPage] = useState(currentPage); + const visiblePages = Math.max( + propVisiblePages % 2 === 0 ? propVisiblePages + 1 : propVisiblePages, + 1 + ); + const showTwoLines = twoLines && !allowDirectInput; + const pageNumbers = generateRangeGuaranteedSequence( + currentPage, + visiblePages, + 1, + totalPages + ); + + const handleDirectInput = () => { + if (inputPage >= 1 && inputPage <= totalPages) { + onPageChange(inputPage); + } else { + onPageChange(currentPage); + } + }; + + const renderPageNumbers = () => ( +
    + {pageNumbers.map((page, index) => ( +
  • + {page === 'ellipsis' ? ( + + + + ) : ( + + )} +
  • + ))} +
+ ); + + const prevButton = ( + + ); + + const nextButton = ( + + ); + + const inputPageNumber = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + onPageChange(inputPage); + return; + } + }; + + return ( + + ); +}; diff --git a/packages/core/lib/index.ts b/packages/core/lib/index.ts index c3c0718..5a41ad4 100644 --- a/packages/core/lib/index.ts +++ b/packages/core/lib/index.ts @@ -24,6 +24,7 @@ import { Tabs } from './components/Tab'; import { Modal } from './components/Modal'; import { Accordion } from './components/Accordion'; import { Disclosure } from './components/Disclosure'; +import { Pagination } from './components/Pagination'; export { Display, Heading, Title, Body, Detail, Label, Link, colors }; export { @@ -43,4 +44,5 @@ export { Modal, Accordion, Disclosure, + Pagination, }; diff --git a/stories/core/Pagination.stories.ts b/stories/core/Pagination.stories.ts new file mode 100644 index 0000000..adc9165 --- /dev/null +++ b/stories/core/Pagination.stories.ts @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Pagination } from '../../packages/core/lib'; + +const meta = { + title: 'Components/Pagination', + component: Pagination, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + totalPages: { + control: { + type: 'number', + }, + }, + currentPage: { + control: { + type: 'number', + }, + }, + onPageChange: { action: 'clicked' }, + visiblePages: { + control: { + type: 'number', + }, + description: + '페이지 번호 중 표시할 페이지 수: 항상 홀수개로 표시됩니다. 짝수를 넣으면 +1로 계산됩니다.', + }, + allowDirectInput: { + control: 'boolean', + }, + twoLines: { + control: 'boolean', + description: '직접 입력 버튼을 표시하면 적용되지 않습니다.', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + totalPages: 20, + currentPage: 9, + onPageChange: (page: number) => console.log(`Clicked page ${page}`), + visiblePages: 5, + allowDirectInput: false, + twoLines: false, + }, +}; + +export const TwoLines: Story = { + args: { + totalPages: 20, + currentPage: 9, + onPageChange: (page: number) => console.log(`Clicked page ${page}`), + visiblePages: 5, + allowDirectInput: false, + twoLines: true, + }, +}; + +export const WithInput: Story = { + args: { + totalPages: 20, + currentPage: 9, + onPageChange: (page: number) => console.log(`Clicked page ${page}`), + visiblePages: 5, + allowDirectInput: true, + }, +}; + +export const Short: Story = { + args: { + totalPages: 20, + currentPage: 4, + onPageChange: (page: number) => console.log(`Clicked page ${page}`), + visiblePages: 5, + }, +}; + +export const All: Story = { + args: { + totalPages: 10, + currentPage: 4, + onPageChange: (page: number) => console.log(`Clicked page ${page}`), + visiblePages: 10, + }, +};