/** * @title Table Pagination * @description 데이터 표의 페이지와 페이지당 표시 행 수를 함께 제어하는 컴포넌트예요. */import * as React from 'react';import { Select as SelectPrimitive } from 'radix-ui';import { Check, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';import { cn } from '@/lib/utils';import { PaginationButton } from '@/components/ui/pagination';// SEED: table-pagination 스니펫 + pagination-button.yaml, input-button.yaml(medium), menu.yaml, select-item.yaml(medium)const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;const MAX_AUTO_PAGE_OPTIONS = 200;// 앱의 언어에 맞게 locale 과 기본 접근성 문구를 수정하세요.const numberFormatter = new Intl.NumberFormat('ko-KR');const tablePaginationText = { rootAriaLabel: '표 페이지 탐색', pageRangeAriaLabel: '표시 범위', pageSizeAriaLabel: '페이지당 표시 개수', previousPageAriaLabel: '이전 페이지', nextPageAriaLabel: '다음 페이지', range: (start: number, end: number) => `${numberFormatter.format(start)}-${numberFormatter.format(end)}`, total: (totalItems: number) => `/ 총 ${numberFormatter.format(totalItems)}개`, pageSize: (size: number) => `${numberFormatter.format(size)}개`, pageSizeSuffix: '씩 보기',} as const;export interface TablePaginationValue { /** 1부터 시작하는 현재 페이지예요. */ page: number; /** 한 페이지에 표시하는 항목 수예요. */ pageSize: number;}export type TablePaginationChangeReason = 'previous' | 'next' | 'page-range' | 'page-size' | 'constraint';export interface TablePaginationChangeDetails { reason: TablePaginationChangeReason; previousValue: TablePaginationValue;}interface TablePaginationBaseProps extends Omit<React.ComponentProps<'div'>, 'children' | 'role' | 'defaultValue'> { /** 제어할 페이지와 페이지당 행 수예요. */ value?: TablePaginationValue; /** 비제어 방식의 초기 값이에요. 기본값은 `{ page: 1, pageSize: 10 }` 이에요. */ defaultValue?: TablePaginationValue; /** 페이지 또는 페이지당 행 수가 바뀔 때 호출돼요. */ onValueChange?: (value: TablePaginationValue, details: TablePaginationChangeDetails) => void; /** 선택할 수 있는 페이지당 행 수예요. */ pageSizeOptions?: readonly number[]; /** 모든 페이지 이동과 선택을 비활성화해요. */ disabled?: boolean;}interface TablePaginationKnownProps extends TablePaginationBaseProps { /** 전체 항목 수예요. */ totalItems: number; /** 페이지 범위 선택기에 노출할 페이지예요. */ pageOptions?: readonly number[]; hasPreviousPage?: never; hasNextPage?: never; currentPageItemCount?: never;}interface TablePaginationUnknownProps extends TablePaginationBaseProps { totalItems?: never; pageOptions?: never; /** 이전 페이지가 있는지 서버가 알려주는 값이에요. */ hasPreviousPage: boolean; /** 다음 페이지가 있는지 서버가 알려주는 값이에요. */ hasNextPage: boolean; /** 현재 페이지에 실제로 포함된 항목 수예요. 없으면 pageSize 를 사용해요. */ currentPageItemCount?: number;}export type TablePaginationProps = TablePaginationKnownProps | TablePaginationUnknownProps;function clampPage(page: number, totalPages: number | undefined) { if (totalPages === 0) return 1; return totalPages === undefined ? Math.max(page, 1) : Math.min(Math.max(page, 1), totalPages);}function createRange(page: number, pageSize: number, itemCount: number) { if (itemCount <= 0) return { start: 0, end: 0 }; const offset = (page - 1) * pageSize; return { start: offset + 1, end: offset + itemCount };}function createKnownRange(page: number, pageSize: number, totalItems: number) { if (totalItems === 0) return { start: 0, end: 0 }; const offset = (page - 1) * pageSize; return createRange(page, pageSize, Math.min(pageSize, totalItems - offset));}function createAutoPageOptions(page: number, totalPages: number) { if (totalPages <= MAX_AUTO_PAGE_OPTIONS) return Array.from({ length: totalPages }, (_, index) => index + 1); const windowSize = MAX_AUTO_PAGE_OPTIONS - 2; const centeredStart = page - Math.floor((windowSize - 1) / 2); const windowStart = Math.min(Math.max(centeredStart, 2), totalPages - windowSize); return [1, ...Array.from({ length: windowSize }, (_, index) => windowStart + index), totalPages];}/* ------------------------------------------------------------------------------------------------- * 내부 Select — SEED Input Button(medium) 트리거 + Menu 콘텐츠 스타일 * -----------------------------------------------------------------------------------------------*/interface TablePaginationSelectProps { value: string; onValueChange: (value: string) => void; disabled?: boolean; 'aria-label': string; options: { value: string; label: string }[]; maxHeight?: number;}function TablePaginationSelect({ value, onValueChange, disabled, options, maxHeight = 480, ...props }: TablePaginationSelectProps) { const selected = options.find((option) => option.value === value); return ( <SelectPrimitive.Root value={value} onValueChange={onValueChange} disabled={disabled}> <SelectPrimitive.Trigger data-slot="table-pagination-select-trigger" aria-label={props['aria-label']} className={cn( 'inline-flex h-x10 min-w-[96px] shrink-0 cursor-pointer items-center justify-between gap-x2 rounded-r2 bg-bg-transparent px-x3_5', 'shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-weak)]', 'text-t4 font-regular text-fg-neutral', 'transition-[background-color,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'enabled:active:bg-bg-transparent-pressed data-[state=open]:bg-bg-transparent-pressed', 'disabled:cursor-not-allowed disabled:bg-bg-disabled disabled:text-fg-disabled', '[&_svg]:size-x4 [&_svg]:shrink-0 [&_svg]:text-fg-neutral-muted disabled:[&_svg]:text-fg-disabled', )} > <span className="whitespace-nowrap tabular-nums"> <SelectPrimitive.Value>{selected?.label}</SelectPrimitive.Value> </span> <SelectPrimitive.Icon asChild> <ChevronDown aria-hidden /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> <SelectPrimitive.Portal> <SelectPrimitive.Content data-slot="table-pagination-select-content" position="popper" sideOffset={8} collisionPadding={8} style={{ maxHeight }} className={cn( 'z-50 min-w-(--radix-select-trigger-width) overflow-hidden rounded-r5 bg-bg-layer-floating shadow-s3', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', )} > <SelectPrimitive.Viewport className="max-h-[inherit] py-x2"> {options.map((option) => ( <SelectPrimitive.Item key={option.value} value={option.value} className={cn( 'relative flex cursor-pointer select-none items-center justify-between gap-x2 rounded-r3 px-x4 py-x2_5 outline-none', 'text-t4 font-regular tabular-nums text-fg-neutral', 'transition-[background-color,margin] duration-(--rb-duration-d3) ease-easing', 'data-[highlighted]:mx-x2 data-[highlighted]:bg-bg-transparent-pressed data-[highlighted]:px-x2', 'data-[disabled]:cursor-not-allowed data-[disabled]:text-fg-disabled', )} > <SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText> <SelectPrimitive.ItemIndicator className="text-fg-neutral [&_svg]:size-x3"> <Check aria-hidden /> </SelectPrimitive.ItemIndicator> </SelectPrimitive.Item> ))} </SelectPrimitive.Viewport> </SelectPrimitive.Content> </SelectPrimitive.Portal> </SelectPrimitive.Root> );}/* ------------------------------------------------------------------------------------------------- * TablePagination * -----------------------------------------------------------------------------------------------*/function TablePagination(props: TablePaginationProps) { const { className, value: valueProp, defaultValue = { page: 1, pageSize: 10 }, onValueChange, pageSizeOptions: pageSizeOptionsProp = DEFAULT_PAGE_SIZE_OPTIONS, disabled = false, totalItems, pageOptions, hasPreviousPage: hasPreviousPageProp, hasNextPage: hasNextPageProp, currentPageItemCount, 'aria-label': ariaLabel = tablePaginationText.rootAriaLabel, ...rootProps } = props; const [internalValue, setInternalValue] = React.useState<TablePaginationValue>(defaultValue); const rawValue = valueProp ?? internalValue; const pageSize = rawValue.pageSize; const isTotalKnown = totalItems !== undefined; const totalPages = isTotalKnown ? Math.ceil(totalItems / pageSize) : undefined; const page = clampPage(rawValue.page, totalPages); const value = React.useMemo( () => (rawValue.page === page ? rawValue : { page, pageSize }), [page, pageSize, rawValue], ); const setValue = React.useCallback( (next: TablePaginationValue, details: TablePaginationChangeDetails) => { if (valueProp === undefined) setInternalValue(next); onValueChange?.(next, details); }, [onValueChange, valueProp], ); const constraintKeyRef = React.useRef<string | undefined>(undefined); React.useEffect(() => { if (rawValue.page === page) { constraintKeyRef.current = undefined; return; } const key = `${rawValue.page}:${rawValue.pageSize}:${page}:${pageSize}`; if (constraintKeyRef.current === key) return; constraintKeyRef.current = key; setValue(value, { reason: 'constraint', previousValue: rawValue }); }, [page, pageSize, rawValue, setValue, value]); const resolvedPageSizeOptions = React.useMemo(() => { const options = new Set(pageSizeOptionsProp); options.add(pageSize); return [...options].sort((a, b) => a - b); }, [pageSize, pageSizeOptionsProp]); const pageRangeOptions = React.useMemo(() => { if (totalItems === undefined || totalPages === undefined) return []; const options = pageOptions ?? createAutoPageOptions(page, totalPages); const pages = new Set(options.filter((option) => option <= totalPages)); pages.add(page); return [...pages] .sort((a, b) => a - b) .map((optionPage) => ({ page: optionPage, ...createKnownRange(optionPage, pageSize, totalItems) })); }, [page, pageSize, pageOptions, totalItems, totalPages]); let range: { start: number; end: number }; let hasPreviousPage: boolean; let hasNextPage: boolean; if (totalItems !== undefined && totalPages !== undefined) { range = createKnownRange(page, pageSize, totalItems); hasPreviousPage = totalItems > 0 && page > 1; hasNextPage = totalItems > 0 && page < totalPages; } else { range = createRange(page, pageSize, Math.min(currentPageItemCount ?? pageSize, pageSize)); hasPreviousPage = page > 1 && Boolean(hasPreviousPageProp); hasNextPage = Boolean(hasNextPageProp); } const changePage = (next: number, reason: 'previous' | 'next' | 'page-range') => { if (disabled) return; const constrained = clampPage(next, totalPages); if (constrained === page) return; setValue({ page: constrained, pageSize }, { reason, previousValue: value }); }; const setPageSize = (nextPageSize: number) => { if (disabled || nextPageSize === pageSize) return; setValue({ page: 1, pageSize: nextPageSize }, { reason: 'page-size', previousValue: value }); }; const pageRangeSelectDisabled = !isTotalKnown || disabled || pageRangeOptions.length <= 1; return ( <div data-slot="table-pagination" role="group" aria-label={ariaLabel} aria-disabled={disabled || undefined} data-disabled={disabled || undefined} className={cn('flex h-x10 w-full min-w-max flex-nowrap items-center justify-between gap-x4', className)} {...rootProps} > <div data-slot="table-pagination-page-size" className="flex min-w-max shrink-0 items-center gap-x2"> <TablePaginationSelect aria-label={tablePaginationText.pageSizeAriaLabel} value={String(pageSize)} onValueChange={(next) => { const nextPageSize = Number(next); if (Number.isSafeInteger(nextPageSize)) setPageSize(nextPageSize); }} disabled={disabled} options={resolvedPageSizeOptions.map((size) => ({ value: String(size), label: tablePaginationText.pageSize(size), }))} /> <span className="whitespace-nowrap text-t4 font-regular text-fg-neutral"> {tablePaginationText.pageSizeSuffix} </span> </div> <div className="flex min-w-max shrink-0 items-center gap-x4"> {isTotalKnown ? ( <div data-slot="table-pagination-page-range" className="flex min-w-max shrink-0 items-center gap-x2"> <TablePaginationSelect aria-label={tablePaginationText.pageRangeAriaLabel} value={String(page)} onValueChange={(next) => { const nextPage = Number(next); if (Number.isSafeInteger(nextPage)) changePage(nextPage, 'page-range'); }} disabled={pageRangeSelectDisabled} maxHeight={240} options={ pageRangeOptions.length > 0 ? pageRangeOptions.map((option) => ({ value: String(option.page), label: tablePaginationText.range(option.start, option.end), })) : [{ value: String(page), label: tablePaginationText.range(range.start, range.end) }] } /> <span className="whitespace-nowrap text-t4 font-regular text-fg-neutral"> {tablePaginationText.total(totalItems)} </span> </div> ) : ( <span className="whitespace-nowrap text-t4 font-regular tabular-nums text-fg-neutral"> {tablePaginationText.range(range.start, range.end)} </span> )} <div className="flex items-center"> <PaginationButton aria-label={tablePaginationText.previousPageAriaLabel} disabled={disabled || !hasPreviousPage} onClick={() => { if (hasPreviousPage) changePage(page - 1, 'previous'); }} > <ChevronLeft /> </PaginationButton> <PaginationButton aria-label={tablePaginationText.nextPageAriaLabel} disabled={disabled || !hasNextPage} onClick={() => { if (hasNextPage) changePage(page + 1, 'next'); }} > <ChevronRight /> </PaginationButton> </div> </div> </div> );}export { TablePagination };