Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -1,201 +1,120 @@
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;
}
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Button } from '../Button';
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 */
/**
* Icon component to display (from lucide-react)
*/
icon: LucideIcon;
/**
* Main title text
*/
title: string;
/**
* Description text (can be a string or React node for complex content)
*/
description?: string | React.ReactNode;
/**
* Optional action button label
*/
actionLabel?: string;
/**
* Optional action button click handler
*/
onAction?: () => void;
/**
* Optional icon for the action button
*/
actionIcon?: LucideIcon;
/**
* Optional action button variant
*/
actionVariant?: 'primary' | 'secondary' | 'outline' | 'ghost';
/**
* Optional action button size
*/
actionSize?: 'sm' | 'md' | 'lg';
/**
* Additional CSS classes for the container
*/
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,
/**
* EmptyState Component
*
* A reusable component for displaying empty states across the application
* with consistent styling and behavior.
*
* @example
* ```tsx
* <EmptyState
* icon={Package}
* title="No items found"
* description="Try adjusting your search or add a new item"
* actionLabel="Add Item"
* actionIcon={Plus}
* onAction={() => setShowModal(true)}
* />
* ```
*/
export const EmptyState: React.FC<EmptyStateProps> = ({
icon: 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
);
actionLabel,
onAction,
actionIcon: ActionIcon,
actionVariant = 'primary',
actionSize = 'md',
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>
)}
<div className={`text-center py-12 ${className}`}>
{/* Icon */}
<Icon className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
{/* Título */}
{displayTitle && (
<h3 className={clsx(
'font-semibold text-text-primary mb-2',
titleSizeClasses[size]
)}>
{displayTitle}
</h3>
)}
{/* Title */}
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
{title}
</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>
{/* Description */}
{description && (
<div className="text-[var(--text-secondary)] mb-4">
{typeof description === 'string' ? (
<p>{description}</p>
) : (
description
)}
</div>
)}
{/* Action Button */}
{actionLabel && onAction && (
<Button
onClick={onAction}
variant={actionVariant}
size={actionSize}
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
{ActionIcon && (
<ActionIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
)}
<span className="text-sm sm:text-base">{actionLabel}</span>
</Button>
)}
</div>
);
});
};
EmptyState.displayName = 'EmptyState';
export default EmptyState;
export default EmptyState;

View File

@@ -1,2 +1,2 @@
export { default as EmptyState } from './EmptyState';
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
export { EmptyState, type EmptyStateProps } from './EmptyState';
export { default } from './EmptyState';