veza/apps/web/src/components/ui/DataList.tsx

398 lines
8.3 KiB
TypeScript

import React from 'react';
import { cn } from '@/lib/utils';
/**
* DataListProps - Propriétés du composant DataList
*
* @interface DataListProps
* @template T - Type des éléments de la liste
*/
interface DataListProps<T> {
/**
* Tableau d'éléments à afficher
*/
items: T[];
/**
* Fonction pour rendre chaque élément de la liste
*
* @param {T} item - Élément à rendre
* @param {number} index - Index de l'élément dans la liste
* @returns {React.ReactNode} Élément React à afficher
*
* @example
* ```tsx
* <DataList
* items={users}
* renderItem={(user) => <div>{user.name}</div>}
* keyExtractor={(user) => user.id}
* />
* ```
*/
renderItem: (item: T, index: number) => React.ReactNode;
/**
* Fonction pour extraire une clé unique de chaque élément
*
* @param {T} item - Élément
* @returns {string} Clé unique
*/
keyExtractor: (item: T) => string;
/**
* Message à afficher lorsque la liste est vide
*
* @default 'No items found'
*/
emptyMessage?: string;
/**
* Si `true`, affiche un état de chargement avec des skeletons
*
* @default false
*/
loading?: boolean;
/**
* Message d'erreur à afficher si une erreur survient
*/
error?: string;
/**
* Classes CSS personnalisées pour le conteneur
*/
className?: string;
/**
* Classes CSS personnalisées pour chaque élément
*/
itemClassName?: string;
}
/**
* DataList - Composant de liste générique avec gestion des états
*
* Composant réutilisable pour afficher des listes d'éléments avec support pour :
* - État de chargement (skeletons)
* - État d'erreur
* - État vide
* - Rendu personnalisé des éléments
*
* @example
* ```tsx
* // Liste simple
* <DataList
* items={users}
* renderItem={(user) => (
* <div className="p-4 border">
* <h3>{user.name}</h3>
* <p>{user.email}</p>
* </div>
* )}
* keyExtractor={(user) => user.id}
* />
* ```
*
* @example
* ```tsx
* // Avec états de chargement et d'erreur
* <DataList
* items={tracks}
* renderItem={(track) => <TrackCard track={track} />}
* keyExtractor={(track) => track.id}
* loading={isLoading}
* error={error}
* emptyMessage="Aucune piste trouvée"
* />
* ```
*
* @component
* @template T - Type des éléments de la liste
* @param {DataListProps<T>} props - Propriétés du composant
* @returns {JSX.Element} Liste d'éléments avec gestion des états
*/
export function DataList<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'No items found',
loading = false,
error,
className,
itemClassName,
}: DataListProps<T>) {
if (loading) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="animate-pulse bg-kodo-slate dark:bg-kodo-steel rounded h-16"
/>
))}
</div>
);
}
if (error) {
return (
<div className={cn('text-center py-8', className)}>
<p className="text-kodo-red dark:text-kodo-red">{error}</p>
</div>
);
}
if (items.length === 0) {
return (
<div className={cn('text-center py-8', className)}>
<p className="text-kodo-content-dim dark:text-kodo-content-dim">{emptyMessage}</p>
</div>
);
}
return (
<div className={cn('space-y-2', className)}>
{items.map((item, index) => (
<div key={keyExtractor(item)} className={itemClassName}>
{renderItem(item, index)}
</div>
))}
</div>
);
}
/**
* ModalProps - Propriétés du composant Modal
*
* @interface ModalProps
*/
interface ModalProps {
/**
* Si `true`, le modal est ouvert
*/
isOpen: boolean;
/**
* Fonction appelée pour fermer le modal
*/
onClose: () => void;
/**
* Titre du modal (affiché dans le header)
*/
title?: string;
/**
* Contenu du modal
*/
children: React.ReactNode;
/**
* Taille du modal
*
* - `sm`: max-w-md
* - `md`: max-w-lg - par défaut
* - `lg`: max-w-2xl
* - `xl`: max-w-4xl
*
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg' | 'xl';
/**
* Si `true`, affiche le bouton de fermeture dans le header
*
* @default true
*/
showCloseButton?: boolean;
}
/**
* Modal - Composant de modal avec backdrop et fermeture
*
* Composant modal simple avec backdrop cliquable et bouton de fermeture.
*
* @example
* ```tsx
* <Modal
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
* title="Titre du modal"
* >
* <p>Contenu du modal</p>
* </Modal>
* ```
*
* @component
*/
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
}) => {
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal */}
<div
className={cn(
'relative bg-white dark:bg-kodo-graphite rounded-lg shadow-xl',
'w-full mx-4',
sizeClasses[size],
)}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-6 border-b border-kodo-steel dark:border-kodo-steel">
{title && (
<h2 className="text-lg font-semibold text-kodo-text-main dark:text-white">
{title}
</h2>
)}
{showCloseButton && (
<button
onClick={onClose}
className="text-kodo-content-dim hover:text-kodo-content-dim dark:hover:text-kodo-text-main"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
);
};
/**
* DropdownProps - Propriétés du composant Dropdown
*
* @interface DropdownProps
*/
interface DropdownProps {
/**
* Élément déclencheur du dropdown (bouton, etc.)
*/
trigger: React.ReactNode;
/**
* Contenu du dropdown (menu items, etc.)
*/
children: React.ReactNode;
/**
* Si `true`, le dropdown est ouvert
*/
isOpen: boolean;
/**
* Fonction appelée pour basculer l'état ouvert/fermé
*/
onToggle: () => void;
/**
* Alignement du dropdown par rapport au trigger
*
* - `left`: Aligné à gauche
* - `right`: Aligné à droite
*
* @default 'left'
*/
align?: 'left' | 'right';
/**
* Classes CSS personnalisées pour le dropdown
*/
className?: string;
}
/**
* Dropdown - Composant de menu déroulant basique
*
* Composant de dropdown avec backdrop et positionnement relatif au trigger.
*
* @example
* ```tsx
* <Dropdown
* trigger={<Button>Menu</Button>}
* isOpen={isOpen}
* onToggle={() => setIsOpen(!isOpen)}
* align="right"
* >
* <div className="p-2">
* <button>Option 1</button>
* <button>Option 2</button>
* </div>
* </Dropdown>
* ```
*
* @component
*/
export const Dropdown: React.FC<DropdownProps> = ({
trigger,
children,
isOpen,
onToggle,
align = 'left',
className,
}) => {
return (
<div className="relative">
<div onClick={onToggle}>{trigger}</div>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-10" onClick={onToggle} />
{/* Dropdown */}
<div
className={cn(
'absolute z-20 mt-1 bg-white dark:bg-kodo-graphite rounded-md shadow-lg',
'border border-kodo-steel dark:border-kodo-steel',
align === 'right' ? 'right-0' : 'left-0',
className,
)}
>
{children}
</div>
</>
)}
</div>
);
};