refactor(web): split TrackListPagination into module (info, nav, utils, skeleton)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-07 05:03:53 +01:00
parent 7edd6267fc
commit fa2563558c
10 changed files with 351 additions and 325 deletions

View file

@ -1,63 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TrackListPagination } from './TrackListPagination';
import {
TrackListPagination,
TrackListPaginationSkeleton,
} from './track-list-pagination';
const meta: Meta<typeof TrackListPagination> = {
title: 'Components/Features/Tracks/TrackListPagination',
component: TrackListPagination,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
onPageChange: { action: 'onPageChange' },
},
title: 'Components/Features/Tracks/TrackListPagination',
component: TrackListPagination,
parameters: { layout: 'centered' },
tags: ['autodocs'],
argTypes: {
onPageChange: { action: 'onPageChange' },
},
};
export default meta;
type Story = StoryObj<typeof TrackListPagination>;
export const Default: Story = {
args: {
currentPage: 1,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
},
args: {
currentPage: 1,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
},
};
export const MiddlePage: Story = {
args: {
currentPage: 5,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
},
args: {
currentPage: 5,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
},
};
export const LastPage: Story = {
args: {
currentPage: 10,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
},
args: {
currentPage: 10,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
},
};
export const ManyPages: Story = {
args: {
currentPage: 1,
totalPages: 50,
totalItems: 500,
itemsPerPage: 10,
},
args: {
currentPage: 1,
totalPages: 50,
totalItems: 500,
itemsPerPage: 10,
},
};
export const Disabled: Story = {
args: {
currentPage: 1,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
disabled: true,
},
args: {
currentPage: 1,
totalPages: 10,
totalItems: 100,
itemsPerPage: 10,
disabled: true,
},
};
export const Loading: Story = {
name: 'Chargement',
render: () => <TrackListPaginationSkeleton />,
};

View file

@ -70,7 +70,7 @@ describe('TrackListPagination', () => {
);
const currentPageButton = screen.getByLabelText('Aller à la page 3');
expect(currentPageButton).toHaveAttribute('aria-current', 'page');
expect(currentPageButton).toHaveClass('bg-blue-600');
expect(currentPageButton).toHaveClass('bg-primary');
});
it('should call onPageChange when page number is clicked', async () => {

View file

@ -1,283 +1,5 @@
/**
* Composant TrackListPagination
* Pagination pour la liste de pistes avec contrôles de navigation
*/
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export interface TrackListPaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
className?: string;
showPageNumbers?: boolean;
maxVisiblePages?: number;
showItemsInfo?: boolean;
disabled?: boolean;
}
export function TrackListPagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange,
className,
showPageNumbers = true,
maxVisiblePages = 5,
showItemsInfo = true,
disabled = false,
}: TrackListPaginationProps) {
const canGoPrevious = currentPage > 1 && !disabled;
const canGoNext = currentPage < totalPages && !disabled;
const canGoFirst = currentPage > 1 && !disabled;
const canGoLast = currentPage < totalPages && !disabled;
const handleFirstPage = () => {
if (canGoFirst) {
onPageChange(1);
}
};
const handlePreviousPage = () => {
if (canGoPrevious) {
onPageChange(currentPage - 1);
}
};
const handleNextPage = () => {
if (canGoNext) {
onPageChange(currentPage + 1);
}
};
const handleLastPage = () => {
if (canGoLast) {
onPageChange(totalPages);
}
};
const handlePageClick = (page: number) => {
if (!disabled && page !== currentPage && page >= 1 && page <= totalPages) {
onPageChange(page);
}
};
// Calculer les numéros de page à afficher
const getVisiblePages = (): number[] => {
if (totalPages <= maxVisiblePages) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const half = Math.floor(maxVisiblePages / 2);
let start = Math.max(1, currentPage - half);
const end = Math.min(totalPages, start + maxVisiblePages - 1);
// Ajuster le début si on est proche de la fin
if (end - start < maxVisiblePages - 1) {
start = Math.max(1, end - maxVisiblePages + 1);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
};
const visiblePages = getVisiblePages();
const startItem = totalItems > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
if (totalPages <= 1 && !showItemsInfo) {
return null;
}
return (
<div
className={cn(
'flex flex-col sm:flex-row items-center justify-between gap-4',
'px-4 py-4 border-t border-kodo-steel dark:border-kodo-steel',
className,
)}
role="navigation"
aria-label="Pagination de la liste de pistes"
>
{/* Informations sur les items */}
{showItemsInfo && (
<div className="text-sm text-kodo-content-dim dark:text-kodo-content-dim">
Affichage de{' '}
<span className="font-medium text-kodo-text-main dark:text-white">
{startItem}
</span>{' '}
à{' '}
<span className="font-medium text-kodo-text-main dark:text-white">
{endItem}
</span>{' '}
sur{' '}
<span className="font-medium text-kodo-text-main dark:text-white">
{totalItems}
</span>{' '}
pistes
</div>
)}
{/* Contrôles de pagination */}
<div className="flex items-center gap-2">
{/* Première page */}
<button
type="button"
onClick={handleFirstPage}
disabled={!canGoFirst || disabled}
className={cn(
'p-2 rounded-md transition-colors',
'text-kodo-content-dim dark:text-kodo-content-dim',
'hover:bg-kodo-void dark:hover:bg-kodo-graphite',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
(!canGoFirst || disabled) && 'opacity-50 cursor-not-allowed',
)}
aria-label="Aller à la première page"
>
<ChevronsLeft className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Première page</span>
</button>
{/* Page précédente */}
<button
type="button"
onClick={handlePreviousPage}
disabled={!canGoPrevious || disabled}
className={cn(
'p-2 rounded-md transition-colors',
'text-kodo-content-dim dark:text-kodo-content-dim',
'hover:bg-kodo-void dark:hover:bg-kodo-graphite',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
(!canGoPrevious || disabled) && 'opacity-50 cursor-not-allowed',
)}
aria-label="Page précédente"
>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Page précédente</span>
</button>
{/* Numéros de page */}
{showPageNumbers && (
<div className="flex items-center gap-1">
{visiblePages[0] > 1 && (
<>
<button
type="button"
onClick={() => handlePageClick(1)}
disabled={disabled}
className={cn(
'px-4 py-1.5 rounded-md text-sm font-medium transition-colors',
'text-kodo-text-main dark:text-kodo-text-main',
'hover:bg-kodo-void dark:hover:bg-kodo-graphite',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
disabled && 'opacity-50 cursor-not-allowed',
)}
aria-label="Aller à la page 1"
>
1
</button>
{visiblePages[0] > 2 && (
<span className="px-2 text-kodo-content-dim dark:text-kodo-content-dim">
...
</span>
)}
</>
)}
{visiblePages.map((page) => (
<button
key={page}
type="button"
onClick={() => handlePageClick(page)}
disabled={disabled}
className={cn(
'px-4 py-1.5 rounded-md text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
page === currentPage
? 'bg-kodo-cyan text-white dark:bg-kodo-cyan'
: 'text-kodo-text-main dark:text-kodo-text-main hover:bg-kodo-void dark:hover:bg-kodo-graphite',
disabled && 'opacity-50 cursor-not-allowed',
)}
aria-label={`Aller à la page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
))}
{visiblePages[visiblePages.length - 1] < totalPages && (
<>
{visiblePages[visiblePages.length - 1] < totalPages - 1 && (
<span className="px-2 text-kodo-content-dim dark:text-kodo-content-dim">
...
</span>
)}
<button
type="button"
onClick={() => handlePageClick(totalPages)}
disabled={disabled}
className={cn(
'px-4 py-1.5 rounded-md text-sm font-medium transition-colors',
'text-kodo-text-main dark:text-kodo-text-main',
'hover:bg-kodo-void dark:hover:bg-kodo-graphite',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
disabled && 'opacity-50 cursor-not-allowed',
)}
aria-label={`Aller à la page ${totalPages}`}
>
{totalPages}
</button>
</>
)}
</div>
)}
{/* Page suivante */}
<button
type="button"
onClick={handleNextPage}
disabled={!canGoNext || disabled}
className={cn(
'p-2 rounded-md transition-colors',
'text-kodo-content-dim dark:text-kodo-content-dim',
'hover:bg-kodo-void dark:hover:bg-kodo-graphite',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
(!canGoNext || disabled) && 'opacity-50 cursor-not-allowed',
)}
aria-label="Page suivante"
>
<ChevronRight className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Page suivante</span>
</button>
{/* Dernière page */}
<button
type="button"
onClick={handleLastPage}
disabled={!canGoLast || disabled}
className={cn(
'p-2 rounded-md transition-colors',
'text-kodo-content-dim dark:text-kodo-content-dim',
'hover:bg-kodo-void dark:hover:bg-kodo-graphite',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
(!canGoLast || disabled) && 'opacity-50 cursor-not-allowed',
)}
aria-label="Aller à la dernière page"
>
<ChevronsRight className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Dernière page</span>
</button>
</div>
</div>
);
}
export default TrackListPagination;
export {
TrackListPagination,
TrackListPaginationSkeleton,
} from './track-list-pagination';
export type { TrackListPaginationProps } from './track-list-pagination';

View file

@ -0,0 +1,57 @@
import { cn } from '@/lib/utils';
import { getVisiblePages, getItemsRange } from './utils';
import { TrackListPaginationInfo } from './TrackListPaginationInfo';
import { TrackListPaginationNav } from './TrackListPaginationNav';
import type { TrackListPaginationProps } from './types';
export function TrackListPagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange,
className,
showPageNumbers = true,
maxVisiblePages = 5,
showItemsInfo = true,
disabled = false,
}: TrackListPaginationProps) {
const visiblePages = getVisiblePages(currentPage, totalPages, maxVisiblePages);
const { startItem, endItem } = getItemsRange(
currentPage,
itemsPerPage,
totalItems
);
if (totalPages <= 1 && !showItemsInfo) {
return null;
}
return (
<nav
className={cn(
'flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-4 border-t border-border',
className
)}
role="navigation"
aria-label="Pagination de la liste de pistes"
>
{showItemsInfo && (
<TrackListPaginationInfo
startItem={startItem}
endItem={endItem}
totalItems={totalItems}
/>
)}
<TrackListPaginationNav
currentPage={currentPage}
totalPages={totalPages}
visiblePages={visiblePages}
maxVisiblePages={maxVisiblePages}
disabled={disabled}
showPageNumbers={showPageNumbers}
onPageChange={onPageChange}
/>
</nav>
);
}

View file

@ -0,0 +1,22 @@
interface TrackListPaginationInfoProps {
startItem: number;
endItem: number;
totalItems: number;
label?: string;
}
export function TrackListPaginationInfo({
startItem,
endItem,
totalItems,
label = 'pistes',
}: TrackListPaginationInfoProps) {
return (
<div className="text-sm text-muted-foreground">
Affichage de{' '}
<span className="font-medium text-foreground">{startItem}</span> à{' '}
<span className="font-medium text-foreground">{endItem}</span> sur{' '}
<span className="font-medium text-foreground">{totalItems}</span> {label}
</div>
);
}

View file

@ -0,0 +1,140 @@
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface TrackListPaginationNavProps {
currentPage: number;
totalPages: number;
visiblePages: number[];
maxVisiblePages: number;
disabled?: boolean;
showPageNumbers?: boolean;
onPageChange: (page: number) => void;
}
const navButtonClass =
'p-2 rounded-md transition-colors text-muted-foreground hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed';
const pageButtonClass =
'px-4 py-1.5 rounded-md text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed';
export function TrackListPaginationNav({
currentPage,
totalPages,
visiblePages,
disabled = false,
showPageNumbers = true,
onPageChange,
}: TrackListPaginationNavProps) {
const canGoPrevious = currentPage > 1 && !disabled;
const canGoNext = currentPage < totalPages && !disabled;
const canGoFirst = currentPage > 1 && !disabled;
const canGoLast = currentPage < totalPages && !disabled;
const handlePageClick = (page: number) => {
if (!disabled && page !== currentPage && page >= 1 && page <= totalPages) {
onPageChange(page);
}
};
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => canGoFirst && onPageChange(1)}
disabled={!canGoFirst || disabled}
className={cn(navButtonClass, (!canGoFirst || disabled) && 'opacity-50 cursor-not-allowed')}
aria-label="Aller à la première page"
>
<ChevronsLeft className="h-5 w-5" aria-hidden />
</button>
<button
type="button"
onClick={() => canGoPrevious && onPageChange(currentPage - 1)}
disabled={!canGoPrevious || disabled}
className={cn(navButtonClass, (!canGoPrevious || disabled) && 'opacity-50 cursor-not-allowed')}
aria-label="Page précédente"
>
<ChevronLeft className="h-5 w-5" aria-hidden />
</button>
{showPageNumbers && (
<div className="flex items-center gap-1">
{visiblePages[0] > 1 && (
<>
<button
type="button"
onClick={() => handlePageClick(1)}
disabled={disabled}
className={cn(pageButtonClass, 'text-foreground hover:bg-muted')}
aria-label="Aller à la page 1"
>
1
</button>
{visiblePages[0] > 2 && (
<span className="px-2 text-muted-foreground">...</span>
)}
</>
)}
{visiblePages.map((page) => (
<button
key={page}
type="button"
onClick={() => handlePageClick(page)}
disabled={disabled}
className={cn(
pageButtonClass,
page === currentPage
? 'bg-primary text-primary-foreground'
: 'text-foreground hover:bg-muted'
)}
aria-label={`Aller à la page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
))}
{visiblePages[visiblePages.length - 1] < totalPages && (
<>
{visiblePages[visiblePages.length - 1] < totalPages - 1 && (
<span className="px-2 text-muted-foreground">...</span>
)}
<button
type="button"
onClick={() => handlePageClick(totalPages)}
disabled={disabled}
className={cn(pageButtonClass, 'text-foreground hover:bg-muted')}
aria-label={`Aller à la page ${totalPages}`}
>
{totalPages}
</button>
</>
)}
</div>
)}
<button
type="button"
onClick={() => canGoNext && onPageChange(currentPage + 1)}
disabled={!canGoNext || disabled}
className={cn(navButtonClass, (!canGoNext || disabled) && 'opacity-50 cursor-not-allowed')}
aria-label="Page suivante"
>
<ChevronRight className="h-5 w-5" aria-hidden />
</button>
<button
type="button"
onClick={() => canGoLast && onPageChange(totalPages)}
disabled={!canGoLast || disabled}
className={cn(navButtonClass, (!canGoLast || disabled) && 'opacity-50 cursor-not-allowed')}
aria-label="Aller à la dernière page"
>
<ChevronsRight className="h-5 w-5" aria-hidden />
</button>
</div>
);
}

View file

@ -0,0 +1,32 @@
import { cn } from '@/lib/utils';
interface TrackListPaginationSkeletonProps {
className?: string;
}
export function TrackListPaginationSkeleton({
className,
}: TrackListPaginationSkeletonProps) {
return (
<div
className={cn(
'flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-4 border-t border-border',
className
)}
role="presentation"
>
<div className="h-4 w-48 bg-muted animate-pulse rounded" />
<div className="flex items-center gap-2">
<div className="h-10 w-10 bg-muted animate-pulse rounded-md" />
<div className="h-10 w-10 bg-muted animate-pulse rounded-md" />
<div className="flex gap-1">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="h-9 w-10 bg-muted animate-pulse rounded-md" />
))}
</div>
<div className="h-10 w-10 bg-muted animate-pulse rounded-md" />
<div className="h-10 w-10 bg-muted animate-pulse rounded-md" />
</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
export { TrackListPagination } from './TrackListPagination';
export { TrackListPaginationSkeleton } from './TrackListPaginationSkeleton';
export { TrackListPaginationInfo } from './TrackListPaginationInfo';
export { TrackListPaginationNav } from './TrackListPaginationNav';
export { getVisiblePages, getItemsRange } from './utils';
export type { TrackListPaginationProps } from './types';

View file

@ -0,0 +1,12 @@
export interface TrackListPaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
className?: string;
showPageNumbers?: boolean;
maxVisiblePages?: number;
showItemsInfo?: boolean;
disabled?: boolean;
}

View file

@ -0,0 +1,29 @@
/**
* Retourne les numéros de page visibles autour de la page courante.
*/
export function getVisiblePages(
currentPage: number,
totalPages: number,
maxVisiblePages: number
): number[] {
if (totalPages <= maxVisiblePages) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const half = Math.floor(maxVisiblePages / 2);
let start = Math.max(1, currentPage - half);
const end = Math.min(totalPages, start + maxVisiblePages - 1);
if (end - start < maxVisiblePages - 1) {
start = Math.max(1, end - maxVisiblePages + 1);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
export function getItemsRange(
currentPage: number,
itemsPerPage: number,
totalItems: number
): { startItem: number; endItem: number } {
const startItem = totalItems > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return { startItem, endItem };
}