/** * @title Pagination * @description 많은 양의 콘텐츠를 여러 페이지로 나누고 페이지 사이를 오가게 해주는 페이지네이션이에요. */import * as React from 'react';import { ChevronLeft, ChevronRight } from 'lucide-react';import { cn } from '@/lib/utils';import { Button } from '@/components/ui/button';// Atlassian @atlaskit/pagination — pagination, internal/utils/collapse-range, components/page·navigator·render-ellipsis 스펙// 페이지·이전·다음은 subtle 버튼, 현재 페이지는 selected 버튼이에요.type EllipsisArgs = { key: string; from: number; to: number };/** 원본 collapse-range 알고리즘 그대로: max 를 넘으면 앞·뒤·양쪽에 말줄임을 넣어요. */function collapseRange<T>( pages: T[], current: number, { max, ellipsis, transform, }: { max: number; ellipsis: (args: EllipsisArgs) => React.ReactNode; transform: (page: T, index: number) => React.ReactNode; },): React.ReactNode[] { const total = pages.length; const getPages = (start = 0, end = total) => pages.slice(start, end).map((page, i) => transform(page, start + i)); const needEllipsis = total > max; if (!needEllipsis) return getPages(0, total); const hasStartEllipsis = max - 4 < current; const hasEndEllipsis = current < total - max + 3; if (hasStartEllipsis && !hasEndEllipsis) { const pageCount = max - 2; return [ ...getPages(0, 1), ellipsis({ key: 'ellipsis-1', from: 2, to: total - pageCount }), ...getPages(total - pageCount), ]; } if (!hasStartEllipsis && hasEndEllipsis) { const pageCount = max - 2; return [ ...getPages(0, pageCount), ellipsis({ key: 'ellipsis-1', from: pageCount + 1, to: total - 1 }), ...getPages(total - 1), ]; } const pageCount = max - 4; return [ ...getPages(0, 1), ellipsis({ key: 'ellipsis-1', from: 2, to: current - Math.floor(pageCount / 2) }), ...getPages(current - Math.floor(pageCount / 2), current + pageCount - 1), ellipsis({ key: 'ellipsis-2', from: current + 3, to: total - 1 }), ...getPages(total - 1), ];}function defaultRenderEllipsis({ key, from, to }: EllipsisArgs) { return ( <li key={key} data-slot="pagination-ellipsis" className="relative flex px-ds-100 ds-font-body text-ds-text"> <span> <span className="sr-only"> {from}페이지부터 {to}페이지까지 생략됨 </span> … </span> </li> );}export interface PaginationProps<T = unknown> extends Omit<React.ComponentProps<'nav'>, 'onChange' | 'children'> { /** 표시할 페이지 배열 (필수) */ pages: T[]; /** 제어 모드에서 선택된 페이지 index */ selectedIndex?: number; /** 비제어 모드 초기 선택 index. 기본 0 */ defaultSelectedIndex?: number; /** 한 번에 보여줄 최대 페이지 수 (말줄임 포함). 기본 7 */ max?: number; /** 페이지가 바뀔 때 호출돼요. */ onChange?: (event: React.SyntheticEvent, page: T) => void; /** 페이지 버튼에 표시할 텍스트. 페이지가 객체일 때 유용해요. */ getPageLabel?: (page: T, pageIndex: number) => number | string; /** 말줄임 대신 렌더링할 노드 */ renderEllipsis?: (args: EllipsisArgs) => React.ReactElement; /** 기본 페이지·이전·다음 버튼을 바꿔 끼워요 (링크 등). */ components?: { Page?: React.ElementType; Previous?: React.ElementType; Next?: React.ElementType; }; /** nav 의 aria-label */ label?: string; /** 페이지 버튼 aria-label 앞부분 ("페이지 3") */ pageLabel?: string; previousLabel?: string; nextLabel?: string; isDisabled?: boolean; testId?: string;}const pageButtonClass = 'px-[10px]';function Pagination<T = unknown>({ pages, selectedIndex, defaultSelectedIndex = 0, max = 7, onChange, getPageLabel, renderEllipsis = defaultRenderEllipsis, components = {}, label = '페이지 탐색', pageLabel = '페이지', previousLabel = '이전', nextLabel = '다음', isDisabled = false, testId, className, ...props}: PaginationProps<T>) { const [selectedState, setSelectedState] = React.useState(defaultSelectedIndex); const isControlled = selectedIndex !== undefined; const selected = isControlled ? selectedIndex : selectedState; const change = (event: React.SyntheticEvent, index: number) => { if (index < 0 || index >= pages.length) return; if (!isControlled) setSelectedState(index); onChange?.(event, pages[index]); }; const PageComponent = components.Page; const PreviousComponent = components.Previous; const NextComponent = components.Next; const transform = (page: T, index: number) => { const text = getPageLabel ? getPageLabel(page, index) : String(page); const isCurrent = index === selected; const pageProps = { 'aria-current': isCurrent ? ('page' as const) : undefined, 'aria-label': `${pageLabel} ${text}`, 'aria-pressed': undefined, 'data-testid': testId && `${testId}--${isCurrent ? 'current-' : ''}page-${index}`, onClick: (event: React.MouseEvent<HTMLButtonElement>) => change(event, index), }; return ( <li key={`page-${getPageLabel ? text : index}`} className="mt-ds-0 flex font-ds-body"> {PageComponent ? ( <PageComponent {...pageProps} page={page} isSelected={isCurrent} isDisabled={isDisabled}> {text} </PageComponent> ) : ( <Button appearance="subtle" isSelected={isCurrent} isDisabled={isDisabled} className={pageButtonClass} {...pageProps}> {text} </Button> )} </li> ); }; const navigator = (direction: 'previous' | 'next') => { const isPrev = direction === 'previous'; const Custom = isPrev ? PreviousComponent : NextComponent; const disabled = isDisabled || (isPrev ? selected === 0 : selected === pages.length - 1); const navProps = { 'aria-label': isPrev ? previousLabel : nextLabel, 'data-testid': testId && `${testId}--${isPrev ? 'left' : 'right'}-navigator`, onClick: (event: React.MouseEvent<HTMLButtonElement>) => change(event, selected + (isPrev ? -1 : 1)), }; const Icon = isPrev ? ChevronLeft : ChevronRight; const icon = ( <span className="inline-flex px-ds-075"> <Icon aria-hidden /> </span> ); return Custom ? ( <Custom {...navProps} isDisabled={disabled}> {icon} </Custom> ) : ( <Button appearance="subtle" isDisabled={disabled} className="px-ds-025" {...navProps}> {icon} </Button> ); }; return ( <nav data-slot="pagination" data-testid={testId} aria-label={label} className={cn(className)} {...props}> <div className="flex items-center gap-ds-0"> {navigator('previous')} <ul className="m-ds-0 flex items-baseline gap-ds-0 p-ds-0"> {collapseRange(pages, selected, { max, ellipsis: renderEllipsis, transform })} </ul> {navigator('next')} </div> </nav> );}export { Pagination, collapseRange };