/** * @title Pagination * @description 여러 페이지로 나뉜 목록에서 원하는 페이지로 이동할 때 사용하는 네비게이션 컴포넌트예요. */import * as React from 'react';import { ChevronLeft, ChevronRight, Ellipsis } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: pagination-button.yaml, pagination-page-item.yaml// 앱의 언어에 맞게 locale 과 기본 접근성 문구를 수정하세요.const numberFormatter = new Intl.NumberFormat('ko-KR');const paginationText = { rootAriaLabel: '페이지 탐색', previousPageAriaLabel: '이전 페이지', nextPageAriaLabel: '다음 페이지', pageAriaLabel: (page: number) => `${numberFormatter.format(page)}페이지`,} as const;/* ------------------------------------------------------------------------------------------------- * PaginationButton / PaginationPageItem * -----------------------------------------------------------------------------------------------*/const paginationItemBase = [ 'relative isolate inline-flex size-x10 shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-r2 border-none bg-transparent p-0 align-middle antialiased', 'before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-bg-transparent before:content-[""]', 'before:transition-[background-color] before:duration-(--rb-duration-color-transition) before:ease-easing', 'transition-[scale,outline-color] duration-(--rb-duration-pressed-scale) ease-pressed-scale', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'enabled:active:scale-[0.97] enabled:hover:before:bg-bg-transparent-pressed enabled:active:before:bg-bg-transparent-pressed', 'disabled:cursor-not-allowed',];/** 이전·다음 이동에 쓰는 아이콘 버튼이에요. Table Pagination 에서도 같이 써요. */function PaginationButton({ className, type = 'button', ...props }: React.ComponentProps<'button'>) { return ( <button type={type} data-slot="pagination-button" className={cn( paginationItemBase, 'text-fg-neutral disabled:text-fg-disabled disabled:before:bg-bg-transparent', '[&_svg]:pointer-events-none [&_svg]:size-x4 [&_svg]:shrink-0', className, )} {...props} /> );}interface PaginationPageItemProps extends React.ComponentProps<'button'> { /** 현재 페이지 여부예요. */ selected?: boolean;}/** 페이지 번호 버튼이에요. 현재 페이지는 Selected 상태로 표시돼요. */function PaginationPageItem({ className, selected = false, type = 'button', children, ...props }: PaginationPageItemProps) { return ( <button type={type} data-slot="pagination-page-item" data-selected={selected || undefined} aria-current={selected ? 'page' : undefined} className={cn( paginationItemBase, 'text-t4 font-bold text-fg-neutral', 'data-[selected]:text-fg-on-neutral-solid data-[selected]:before:bg-bg-neutral-solid', 'data-[selected]:enabled:hover:before:bg-bg-neutral-solid-pressed data-[selected]:enabled:active:before:bg-bg-neutral-solid-pressed', 'disabled:text-fg-disabled disabled:before:bg-bg-transparent', 'data-[selected]:disabled:text-fg-disabled data-[selected]:disabled:before:bg-bg-disabled', className, )} {...props} > <span className="block min-w-0 max-w-full truncate">{children}</span> </button> );}/* ------------------------------------------------------------------------------------------------- * usePagination — SEED react-headless/pagination 의 슬롯 계산을 그대로 옮겼어요. * -----------------------------------------------------------------------------------------------*/export type PaginationVisibleItemCount = 7 | 9;export type PaginationItem = { type: 'page'; page: number } | { type: 'ellipsis'; side: 'start' | 'end' };export type PaginationChangeReason = 'page-item' | 'previous' | 'next' | 'constraint';export interface PaginationChangeDetails { reason: PaginationChangeReason; previousPage: number;}function clampPage(page: number, totalPages: number) { if (totalPages === 0) return 1; return Math.min(Math.max(page, 1), totalPages);}function pageItems(start: number, end: number): PaginationItem[] { return Array.from({ length: end - start + 1 }, (_, index) => ({ type: 'page', page: start + index }));}function createCompactItems(page: number, totalPages: number): PaginationItem[] { if (page <= 3) return [...pageItems(1, 4), { type: 'ellipsis', side: 'end' }]; if (page >= totalPages - 2) return [{ type: 'ellipsis', side: 'start' }, ...pageItems(totalPages - 3, totalPages)]; return [{ type: 'ellipsis', side: 'start' }, ...pageItems(page - 1, page + 1), { type: 'ellipsis', side: 'end' }];}function createPaginationItems( page: number, totalPages: number, visibleItemCount: PaginationVisibleItemCount,): PaginationItem[] { const navigationItemCount = totalPages > 1 ? 2 : 0; const availableItemCount = visibleItemCount - navigationItemCount; if (totalPages <= availableItemCount) return pageItems(1, totalPages); if (availableItemCount === 5) return createCompactItems(page, totalPages); const siblingCount = Math.floor((availableItemCount - 5) / 2); const edgePageCount = availableItemCount - 2; const edgeThreshold = edgePageCount - siblingCount; if (page <= edgeThreshold) { return [...pageItems(1, edgePageCount), { type: 'ellipsis', side: 'end' }, { type: 'page', page: totalPages }]; } if (page >= totalPages - edgeThreshold + 1) { return [ { type: 'page', page: 1 }, { type: 'ellipsis', side: 'start' }, ...pageItems(totalPages - edgePageCount + 1, totalPages), ]; } return [ { type: 'page', page: 1 }, { type: 'ellipsis', side: 'start' }, ...pageItems(page - siblingCount, page + siblingCount), { type: 'ellipsis', side: 'end' }, { type: 'page', page: totalPages }, ];}const WIDE_QUERY = '(min-width: 480px)';/** 480px 이상이면 9 슬롯, 그보다 좁으면 7 슬롯이에요. */function useVisibleItemCount(): PaginationVisibleItemCount { const isWide = React.useSyncExternalStore( (callback) => { if (typeof window === 'undefined') return () => {}; const mql = window.matchMedia(WIDE_QUERY); mql.addEventListener('change', callback); return () => mql.removeEventListener('change', callback); }, () => window.matchMedia(WIDE_QUERY).matches, () => false, ); return isWide ? 9 : 7;}/* ------------------------------------------------------------------------------------------------- * Pagination * -----------------------------------------------------------------------------------------------*/export interface PaginationProps extends Omit<React.ComponentProps<'nav'>, 'children' | 'role'> { /** 제어할 현재 페이지예요. 1부터 시작해요. */ page?: number; /** 비제어 방식의 초기 페이지예요. */ defaultPage?: number; /** 페이지가 바뀔 때 호출돼요. 두 번째 인자로 변경 원인과 이전 페이지를 받아요. */ onPageChange?: (page: number, details: PaginationChangeDetails) => void; /** 0 이상의 전체 페이지 수예요. 0 또는 1 이면 Pagination 을 표시하지 않아요. */ totalPages: number; /** 모든 페이지 이동을 비활성화해요. */ disabled?: boolean;}function PaginationPlaceholder() { return <span aria-hidden className="size-x10 shrink-0" />;}function Pagination({ className, page: pageProp, defaultPage = 1, onPageChange, totalPages, disabled = false, 'aria-label': ariaLabel = paginationText.rootAriaLabel, ...props}: PaginationProps) { const [internalPage, setInternalPage] = React.useState(defaultPage); const rawPage = pageProp ?? internalPage; const page = clampPage(rawPage, totalPages); const visibleItemCount = useVisibleItemCount(); const setPage = React.useCallback( (next: number, details: PaginationChangeDetails) => { if (pageProp === undefined) setInternalPage(next); onPageChange?.(next, details); }, [onPageChange, pageProp], ); // 외부 페이지가 범위를 벗어나면 보정된 페이지를 먼저 렌더링하고 constraint 변경을 한 번 알려요. const constraintKeyRef = React.useRef<string | undefined>(undefined); React.useEffect(() => { if (rawPage === page) { constraintKeyRef.current = undefined; return; } const key = `${rawPage}:${page}`; if (constraintKeyRef.current === key) return; constraintKeyRef.current = key; setPage(page, { reason: 'constraint', previousPage: rawPage }); }, [page, rawPage, setPage]); const items = React.useMemo( () => createPaginationItems(page, totalPages, visibleItemCount), [page, totalPages, visibleItemCount], ); if (totalPages <= 1) return null; const hasPreviousPage = page > 1; const hasNextPage = page < totalPages; const changePage = (next: number, reason: Exclude<PaginationChangeReason, 'constraint'>) => { if (disabled) return; const constrained = clampPage(next, totalPages); if (constrained === page) return; setPage(constrained, { reason, previousPage: page }); }; return ( <nav data-slot="pagination" role="navigation" aria-label={ariaLabel} aria-disabled={disabled || undefined} data-disabled={disabled || undefined} className={cn('flex items-center', className)} {...props} > {hasPreviousPage ? ( <PaginationButton aria-label={paginationText.previousPageAriaLabel} disabled={disabled} onClick={() => changePage(page - 1, 'previous')} > <ChevronLeft /> </PaginationButton> ) : ( <PaginationPlaceholder /> )} {items.map((item) => { if (item.type === 'ellipsis') { return ( <span key={`ellipsis-${item.side}`} data-slot="pagination-ellipsis" aria-hidden className="flex size-x10 shrink-0 items-center justify-center text-fg-neutral [&_svg]:size-x4" > <Ellipsis /> </span> ); } const formatted = numberFormatter.format(item.page); const current = item.page === page; return ( <PaginationPageItem key={item.page} selected={current} disabled={disabled} aria-label={paginationText.pageAriaLabel(item.page)} title={formatted} onClick={() => { if (!current) changePage(item.page, 'page-item'); }} > {formatted} </PaginationPageItem> ); })} {hasNextPage ? ( <PaginationButton aria-label={paginationText.nextPageAriaLabel} disabled={disabled} onClick={() => changePage(page + 1, 'next')} > <ChevronRight /> </PaginationButton> ) : ( <PaginationPlaceholder /> )} </nav> );}export { Pagination, PaginationButton, PaginationPageItem, createPaginationItems };