201 lines
6.0 KiB
TypeScript
201 lines
6.0 KiB
TypeScript
|
|
import React, { forwardRef } from 'react';
|
||
|
|
import { clsx } from 'clsx';
|
||
|
|
import { Button } from '../../ui';
|
||
|
|
import type { ButtonProps } from '../../ui';
|
||
|
|
|
||
|
|
export interface EmptyStateAction {
|
||
|
|
/** Texto del botón */
|
||
|
|
label: string;
|
||
|
|
/** Función al hacer click */
|
||
|
|
onClick: () => void;
|
||
|
|
/** Variante del botón */
|
||
|
|
variant?: ButtonProps['variant'];
|
||
|
|
/** Icono del botón */
|
||
|
|
icon?: React.ReactNode;
|
||
|
|
/** Mostrar loading en el botón */
|
||
|
|
isLoading?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EmptyStateProps {
|
||
|
|
/** Icono o ilustración */
|
||
|
|
icon?: React.ReactNode;
|
||
|
|
/** Título del estado vacío */
|
||
|
|
title?: string;
|
||
|
|
/** Descripción del estado vacío */
|
||
|
|
description?: string;
|
||
|
|
/** Variante del estado vacío */
|
||
|
|
variant?: 'no-data' | 'error' | 'search' | 'filter';
|
||
|
|
/** Acción principal */
|
||
|
|
primaryAction?: EmptyStateAction;
|
||
|
|
/** Acción secundaria */
|
||
|
|
secondaryAction?: EmptyStateAction;
|
||
|
|
/** Componente personalizado para ilustración */
|
||
|
|
illustration?: React.ReactNode;
|
||
|
|
/** Clase CSS adicional */
|
||
|
|
className?: string;
|
||
|
|
/** Tamaño del componente */
|
||
|
|
size?: 'sm' | 'md' | 'lg';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Iconos SVG por defecto para cada variante
|
||
|
|
const DefaultIcons = {
|
||
|
|
'no-data': (
|
||
|
|
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
|
|
</svg>
|
||
|
|
),
|
||
|
|
'error': (
|
||
|
|
<svg className="w-16 h-16 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||
|
|
</svg>
|
||
|
|
),
|
||
|
|
'search': (
|
||
|
|
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
|
|
</svg>
|
||
|
|
),
|
||
|
|
'filter': (
|
||
|
|
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.707A1 1 0 013 7V4z" />
|
||
|
|
</svg>
|
||
|
|
)
|
||
|
|
};
|
||
|
|
|
||
|
|
// Mensajes por defecto en español para cada variante
|
||
|
|
const DefaultMessages = {
|
||
|
|
'no-data': {
|
||
|
|
title: 'No hay datos disponibles',
|
||
|
|
description: 'Aún no se han registrado elementos en esta sección. Comience agregando su primer elemento.'
|
||
|
|
},
|
||
|
|
'error': {
|
||
|
|
title: 'Ha ocurrido un error',
|
||
|
|
description: 'No se pudieron cargar los datos. Por favor, inténtelo de nuevo más tarde.'
|
||
|
|
},
|
||
|
|
'search': {
|
||
|
|
title: 'Sin resultados de búsqueda',
|
||
|
|
description: 'No se encontraron elementos que coincidan con su búsqueda. Intente con términos diferentes.'
|
||
|
|
},
|
||
|
|
'filter': {
|
||
|
|
title: 'Sin resultados con estos filtros',
|
||
|
|
description: 'No se encontraron elementos que coincidan con los filtros aplicados. Ajuste los filtros para ver más resultados.'
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(({
|
||
|
|
icon,
|
||
|
|
title,
|
||
|
|
description,
|
||
|
|
variant = 'no-data',
|
||
|
|
primaryAction,
|
||
|
|
secondaryAction,
|
||
|
|
illustration,
|
||
|
|
className,
|
||
|
|
size = 'md',
|
||
|
|
...props
|
||
|
|
}, ref) => {
|
||
|
|
const defaultMessage = DefaultMessages[variant];
|
||
|
|
const displayTitle = title || defaultMessage.title;
|
||
|
|
const displayDescription = description || defaultMessage.description;
|
||
|
|
const displayIcon = illustration || icon || DefaultIcons[variant];
|
||
|
|
|
||
|
|
const sizeClasses = {
|
||
|
|
sm: 'py-8 px-4',
|
||
|
|
md: 'py-12 px-6',
|
||
|
|
lg: 'py-20 px-8'
|
||
|
|
};
|
||
|
|
|
||
|
|
const titleSizeClasses = {
|
||
|
|
sm: 'text-lg',
|
||
|
|
md: 'text-xl',
|
||
|
|
lg: 'text-2xl'
|
||
|
|
};
|
||
|
|
|
||
|
|
const descriptionSizeClasses = {
|
||
|
|
sm: 'text-sm',
|
||
|
|
md: 'text-base',
|
||
|
|
lg: 'text-lg'
|
||
|
|
};
|
||
|
|
|
||
|
|
const iconContainerClasses = {
|
||
|
|
sm: 'mb-4',
|
||
|
|
md: 'mb-6',
|
||
|
|
lg: 'mb-8'
|
||
|
|
};
|
||
|
|
|
||
|
|
const containerClasses = clsx(
|
||
|
|
'flex flex-col items-center justify-center text-center',
|
||
|
|
'min-h-[200px] max-w-md mx-auto',
|
||
|
|
sizeClasses[size],
|
||
|
|
className
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={containerClasses}
|
||
|
|
role="status"
|
||
|
|
aria-live="polite"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{/* Icono o Ilustración */}
|
||
|
|
{displayIcon && (
|
||
|
|
<div className={clsx('flex-shrink-0', iconContainerClasses[size])}>
|
||
|
|
{displayIcon}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Título */}
|
||
|
|
{displayTitle && (
|
||
|
|
<h3 className={clsx(
|
||
|
|
'font-semibold text-text-primary mb-2',
|
||
|
|
titleSizeClasses[size]
|
||
|
|
)}>
|
||
|
|
{displayTitle}
|
||
|
|
</h3>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Descripción */}
|
||
|
|
{displayDescription && (
|
||
|
|
<p className={clsx(
|
||
|
|
'text-text-secondary mb-6 leading-relaxed',
|
||
|
|
descriptionSizeClasses[size]
|
||
|
|
)}>
|
||
|
|
{displayDescription}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Acciones */}
|
||
|
|
{(primaryAction || secondaryAction) && (
|
||
|
|
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||
|
|
{primaryAction && (
|
||
|
|
<Button
|
||
|
|
variant={primaryAction.variant || 'primary'}
|
||
|
|
onClick={primaryAction.onClick}
|
||
|
|
isLoading={primaryAction.isLoading}
|
||
|
|
leftIcon={primaryAction.icon}
|
||
|
|
className="w-full sm:w-auto"
|
||
|
|
>
|
||
|
|
{primaryAction.label}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{secondaryAction && (
|
||
|
|
<Button
|
||
|
|
variant={secondaryAction.variant || 'outline'}
|
||
|
|
onClick={secondaryAction.onClick}
|
||
|
|
isLoading={secondaryAction.isLoading}
|
||
|
|
leftIcon={secondaryAction.icon}
|
||
|
|
className="w-full sm:w-auto"
|
||
|
|
>
|
||
|
|
{secondaryAction.label}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
EmptyState.displayName = 'EmptyState';
|
||
|
|
|
||
|
|
export default EmptyState;
|