Skip to main content
Glama
Pagination.tsx7.89 kB
'use client'; import { cva, type VariantProps } from 'class-variance-authority'; import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'; import { type ComponentProps, type FC, type HTMLAttributes, type RefObject, useEffect, useRef, } from 'react'; import { useItemSelector } from '../../hooks'; import { cn } from '../../utils/cn'; import { Button, ButtonColor, ButtonSize, ButtonVariant } from '../Button'; export const paginationVariants = cva( 'flex items-center justify-center gap-1', { variants: { size: { sm: 'gap-1', md: 'gap-2', lg: 'gap-3', }, color: { text: 'background-text', primary: 'background-primary', secondary: 'background-secondary', neutral: 'background-neutral', destructive: 'background-destructive', }, variant: { default: '', bordered: 'rounded-lg border border-border p-2', ghost: 'bg-transparent', }, }, defaultVariants: { size: 'md', variant: 'default', }, } ); export enum PaginationSize { SM = 'sm', MD = 'md', LG = 'lg', } export enum PaginationVariant { DEFAULT = 'default', BORDERED = 'bordered', GHOST = 'ghost', } export type PaginationProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof paginationVariants> & { currentPage: number; totalPages: number; onPageChange: (page: number) => void; showFirstLast?: boolean; showPrevNext?: boolean; maxVisiblePages?: number; disabled?: boolean; }; const generatePageNumbers = ( currentPage: number, totalPages: number, maxVisiblePages: number ): (number | 'ellipsis')[] => { if (totalPages <= maxVisiblePages) { return Array.from({ length: totalPages }, (_, i) => i + 1); } const pages: (number | 'ellipsis')[] = []; const halfVisible = Math.floor(maxVisiblePages / 2); pages.push(1); if (currentPage <= halfVisible + 2) { for (let i = 2; i <= Math.min(maxVisiblePages - 1, totalPages - 1); i++) { pages.push(i); } if (totalPages > maxVisiblePages) { pages.push('ellipsis'); } if (totalPages > 1) { pages.push(totalPages); } } else if (currentPage >= totalPages - halfVisible - 1) { if (totalPages > maxVisiblePages) { pages.push('ellipsis'); } for ( let i = Math.max(2, totalPages - maxVisiblePages + 2); i <= totalPages; i++ ) { pages.push(i); } } else { pages.push('ellipsis'); const start = currentPage - halfVisible; const end = currentPage + halfVisible; for (let i = start; i <= end; i++) { pages.push(i); } pages.push('ellipsis'); pages.push(totalPages); } return pages; }; const selector = (option: HTMLElement) => option?.getAttribute('aria-current') === 'true'; const getButtonSize = (size?: PaginationSize | `${PaginationSize}` | null) => { if (size === PaginationSize.SM) { return ButtonSize.ICON_SM; } else if (size === PaginationSize.LG) { return ButtonSize.ICON_LG; } else { return ButtonSize.ICON_MD; } }; const InputIndicator: FC<ComponentProps<'div'>> = (props) => ( <div className="absolute top-0 z-0 h-full w-auto rounded-xl bg-text/20 ring-4 ring-text/10 transition-[left,width] duration-300 ease-in-out [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-2xl motion-reduce:transition-none" {...props} /> ); export const Pagination: FC<PaginationProps> = ({ currentPage, totalPages, onPageChange, showFirstLast = false, showPrevNext = true, maxVisiblePages = 5, disabled = false, size = PaginationSize.MD, variant = PaginationVariant.DEFAULT, color = ButtonColor.TEXT, className, ...props }) => { const pageNumbers = generatePageNumbers( currentPage, totalPages, maxVisiblePages ); const buttonSize = getButtonSize(size); const isFirstPage = currentPage === 1; const isLastPage = currentPage === totalPages; const optionsRefs = useRef<HTMLElement[]>([]); const indicatorRef = useRef<HTMLDivElement | null>(null); const { choiceIndicatorPosition, calculatePosition } = useItemSelector( optionsRefs, { selector, isHoverable: true, } ); useEffect(() => { const timer = setTimeout(() => { calculatePosition(); }, 300); return () => clearTimeout(timer); }, [currentPage, calculatePosition]); if (totalPages <= 1) return null; const handlePageChange = (page: number) => { if (!disabled && page >= 1 && page <= totalPages && page !== currentPage) { onPageChange(page); } }; return ( <div className={cn(paginationVariants({ size, variant }), className)} {...props} > <div className="relative flex items-center gap-1"> {choiceIndicatorPosition && ( <InputIndicator style={choiceIndicatorPosition} ref={indicatorRef} /> )} {showPrevNext && ( <Button variant={ButtonVariant.OUTLINE} size={buttonSize} color={ButtonColor.TEXT} onClick={() => handlePageChange(currentPage - 1)} disabled={disabled || isFirstPage} label="Go to previous page" Icon={ChevronLeft} ref={(el) => { if (el) optionsRefs.current[0] = el; }} className="min-w-0 px-2" /> )} <div className="flex items-center gap-1 max-md:gap-0.5"> {pageNumbers.map((page, index) => { if (page === 'ellipsis') { return ( <div key={`ellipsis-${page}-${index}`} className="flex h-8 min-w-8 items-center justify-center px-1" > <MoreHorizontal className="h-4 w-4 text-muted-foreground" /> </div> ); } const isActive = page === currentPage; // Calculate ref index: offset by 1 if showPrevNext, then count only non-ellipsis items const refIndex = (showPrevNext ? 1 : 0) + pageNumbers.slice(0, index).filter((p) => p !== 'ellipsis') .length; return ( <Button key={page} variant={ isActive ? ButtonVariant.DEFAULT : ButtonVariant.OUTLINE } size={buttonSize} color={ButtonColor.TEXT} onClick={() => handlePageChange(page)} disabled={disabled} label={`Go to page ${page}`} aria-current={isActive ? 'true' : 'false'} ref={(el) => { if (el) optionsRefs.current[refIndex] = el; }} className={cn( 'flex aspect-square h-8 w-8 min-w-0 items-center justify-center p-0 text-sm', size === 'sm' && 'h-6 w-6 text-xs', size === 'lg' && 'h-10 w-10 text-base', isActive && 'font-semibold' )} > {page} </Button> ); })} </div> {showPrevNext && ( <Button variant={ButtonVariant.OUTLINE} size={buttonSize} color={ButtonColor.TEXT} onClick={() => handlePageChange(currentPage + 1)} disabled={disabled || isLastPage} label="Go to next page" Icon={ChevronRight} ref={(el) => { const lastRefIndex = (showPrevNext ? 1 : 0) + pageNumbers.filter((p) => p !== 'ellipsis').length; if (el) optionsRefs.current[lastRefIndex] = el; }} className="min-w-0 px-2" /> )} </div> </div> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server