Fix few issues
This commit is contained in:
@@ -1,40 +1,200 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
overlay?: boolean;
|
||||
export interface LoadingSpinnerProps {
|
||||
/** Tamaño del spinner */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Variante del spinner */
|
||||
variant?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
|
||||
/** Color personalizado */
|
||||
color?: 'primary' | 'secondary' | 'white' | 'gray';
|
||||
/** Texto de carga opcional */
|
||||
text?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Mostrar como overlay de pantalla completa */
|
||||
overlay?: boolean;
|
||||
/** Centrar el spinner */
|
||||
centered?: boolean;
|
||||
/** Clase CSS adicional */
|
||||
className?: string;
|
||||
/** Props adicionales para accesibilidad */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
overlay = false,
|
||||
const LoadingSpinner = forwardRef<HTMLDivElement, LoadingSpinnerProps>(({
|
||||
size = 'md',
|
||||
variant = 'spinner',
|
||||
color = 'primary',
|
||||
text,
|
||||
size = 'md'
|
||||
}) => {
|
||||
overlay = false,
|
||||
centered = false,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'Cargando',
|
||||
...props
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
xs: 'w-4 h-4',
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const spinner = (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className={`animate-spin rounded-full border-4 border-[var(--border-secondary)] border-t-[var(--color-primary)] ${sizeClasses[size]}`}></div>
|
||||
{text && (
|
||||
<p className="mt-4 text-[var(--text-secondary)] text-sm">{text}</p>
|
||||
const colorClasses = {
|
||||
primary: 'text-[var(--color-primary)]',
|
||||
secondary: 'text-[var(--color-secondary)]',
|
||||
white: 'text-white',
|
||||
gray: 'text-[var(--text-tertiary)]'
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl'
|
||||
};
|
||||
|
||||
const SpinnerIcon = () => (
|
||||
<svg
|
||||
className={clsx(
|
||||
'animate-spin',
|
||||
sizeClasses[size],
|
||||
colorClasses[color]
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DotsSpinner = () => {
|
||||
const dotSize = size === 'xs' ? 'w-1 h-1' : size === 'sm' ? 'w-1.5 h-1.5' : size === 'md' ? 'w-2 h-2' : size === 'lg' ? 'w-3 h-3' : 'w-4 h-4';
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1" role="img" aria-label={ariaLabel}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'rounded-full animate-pulse',
|
||||
dotSize,
|
||||
color === 'white' ? 'bg-white' :
|
||||
color === 'primary' ? 'bg-[var(--color-primary)]' :
|
||||
color === 'secondary' ? 'bg-[var(--color-secondary)]' :
|
||||
'bg-[var(--text-tertiary)]'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
animationDuration: '1.4s'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PulseSpinner = () => (
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-pulse rounded-full',
|
||||
sizeClasses[size],
|
||||
color === 'white' ? 'bg-white' :
|
||||
color === 'primary' ? 'bg-[var(--color-primary)]' :
|
||||
color === 'secondary' ? 'bg-[var(--color-secondary)]' :
|
||||
'bg-[var(--text-tertiary)]'
|
||||
)}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
const SkeletonSpinner = () => {
|
||||
const skeletonHeight = size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-6' : size === 'lg' ? 'h-8' : 'h-12';
|
||||
|
||||
return (
|
||||
<div className="space-y-2 animate-pulse" role="img" aria-label={ariaLabel}>
|
||||
<div className={clsx('bg-[var(--bg-quaternary)] rounded', skeletonHeight, 'w-full')} />
|
||||
<div className={clsx('bg-[var(--bg-quaternary)] rounded', skeletonHeight, 'w-3/4')} />
|
||||
<div className={clsx('bg-[var(--bg-quaternary)] rounded', skeletonHeight, 'w-1/2')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpinner = () => {
|
||||
switch (variant) {
|
||||
case 'dots':
|
||||
return <DotsSpinner />;
|
||||
case 'pulse':
|
||||
return <PulseSpinner />;
|
||||
case 'skeleton':
|
||||
return <SkeletonSpinner />;
|
||||
default:
|
||||
return <SpinnerIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = clsx(
|
||||
'flex items-center',
|
||||
{
|
||||
'justify-center': centered,
|
||||
'flex-col': text && variant !== 'skeleton',
|
||||
'gap-3': text,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className={containerClasses} ref={ref} {...props}>
|
||||
{renderSpinner()}
|
||||
{text && variant !== 'skeleton' && (
|
||||
<span
|
||||
className={clsx(
|
||||
'font-medium mt-4',
|
||||
textSizeClasses[size],
|
||||
colorClasses[color],
|
||||
{
|
||||
'text-white': overlay && color !== 'white',
|
||||
'text-[var(--text-secondary)]': !overlay && color !== 'white'
|
||||
}
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (overlay) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg p-6">
|
||||
{spinner}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return spinner;
|
||||
};
|
||||
return content;
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||
|
||||
export { LoadingSpinner };
|
||||
@@ -1,195 +0,0 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface LoadingSpinnerProps {
|
||||
/** Tamaño del spinner */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Variante del spinner */
|
||||
variant?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
|
||||
/** Color personalizado */
|
||||
color?: 'primary' | 'secondary' | 'white' | 'gray';
|
||||
/** Texto de carga opcional */
|
||||
text?: string;
|
||||
/** Mostrar como overlay de pantalla completa */
|
||||
overlay?: boolean;
|
||||
/** Centrar el spinner */
|
||||
centered?: boolean;
|
||||
/** Clase CSS adicional */
|
||||
className?: string;
|
||||
/** Props adicionales */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner = forwardRef<HTMLDivElement, LoadingSpinnerProps>(({
|
||||
size = 'md',
|
||||
variant = 'spinner',
|
||||
color = 'primary',
|
||||
text,
|
||||
overlay = false,
|
||||
centered = false,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'Cargando',
|
||||
...props
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
xs: 'w-4 h-4',
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'text-color-primary',
|
||||
secondary: 'text-color-secondary',
|
||||
white: 'text-white',
|
||||
gray: 'text-text-tertiary'
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl'
|
||||
};
|
||||
|
||||
// Componente Spinner (rotación)
|
||||
const SpinnerIcon = () => (
|
||||
<svg
|
||||
className={clsx(
|
||||
'animate-spin',
|
||||
sizeClasses[size],
|
||||
colorClasses[color]
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Componente Dots (puntos que aparecen y desaparecen)
|
||||
const DotsSpinner = () => {
|
||||
const dotSize = size === 'xs' ? 'w-1 h-1' : size === 'sm' ? 'w-1.5 h-1.5' : size === 'md' ? 'w-2 h-2' : size === 'lg' ? 'w-3 h-3' : 'w-4 h-4';
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1" role="img" aria-label={ariaLabel}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'rounded-full animate-pulse',
|
||||
dotSize,
|
||||
colorClasses[color] === 'text-white' ? 'bg-white' :
|
||||
colorClasses[color] === 'text-color-primary' ? 'bg-color-primary' :
|
||||
colorClasses[color] === 'text-color-secondary' ? 'bg-color-secondary' :
|
||||
'bg-text-tertiary'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
animationDuration: '1.4s'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Componente Pulse (respiración)
|
||||
const PulseSpinner = () => (
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-pulse rounded-full',
|
||||
sizeClasses[size],
|
||||
colorClasses[color] === 'text-white' ? 'bg-white' :
|
||||
colorClasses[color] === 'text-color-primary' ? 'bg-color-primary' :
|
||||
colorClasses[color] === 'text-color-secondary' ? 'bg-color-secondary' :
|
||||
'bg-text-tertiary'
|
||||
)}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
// Componente Skeleton (placeholder animado)
|
||||
const SkeletonSpinner = () => {
|
||||
const skeletonHeight = size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-6' : size === 'lg' ? 'h-8' : 'h-12';
|
||||
|
||||
return (
|
||||
<div className="space-y-2 animate-pulse" role="img" aria-label={ariaLabel}>
|
||||
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-full')} />
|
||||
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-3/4')} />
|
||||
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-1/2')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpinner = () => {
|
||||
switch (variant) {
|
||||
case 'dots':
|
||||
return <DotsSpinner />;
|
||||
case 'pulse':
|
||||
return <PulseSpinner />;
|
||||
case 'skeleton':
|
||||
return <SkeletonSpinner />;
|
||||
default:
|
||||
return <SpinnerIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = clsx(
|
||||
'flex items-center',
|
||||
{
|
||||
'justify-center': centered,
|
||||
'flex-col': text && variant !== 'skeleton',
|
||||
'gap-3': text,
|
||||
'fixed inset-0 bg-black/30 backdrop-blur-sm z-modal': overlay,
|
||||
'min-h-[200px]': overlay
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className={containerClasses} ref={ref} {...props}>
|
||||
{renderSpinner()}
|
||||
{text && variant !== 'skeleton' && (
|
||||
<span
|
||||
className={clsx(
|
||||
'font-medium',
|
||||
textSizeClasses[size],
|
||||
colorClasses[color],
|
||||
{
|
||||
'text-white': overlay && color !== 'white'
|
||||
}
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
89
frontend/src/components/ui/ResponsiveText/ResponsiveText.tsx
Normal file
89
frontend/src/components/ui/ResponsiveText/ResponsiveText.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ResponsiveText Component
|
||||
* Automatically adjusts text size and truncation based on screen size
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TextOverflowPrevention, getScreenSize, overflowClasses } from '../../../utils/textUtils';
|
||||
|
||||
export interface ResponsiveTextProps {
|
||||
text: string;
|
||||
maxLength?: {
|
||||
mobile?: number;
|
||||
tablet?: number;
|
||||
desktop?: number;
|
||||
};
|
||||
className?: string;
|
||||
title?: string;
|
||||
truncationType?: 'statusCard' | 'production' | 'mobile';
|
||||
textType?: 'title' | 'subtitle' | 'label' | 'metadata' | 'action';
|
||||
showTooltip?: boolean;
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
}
|
||||
|
||||
export const ResponsiveText: React.FC<ResponsiveTextProps> = ({
|
||||
text,
|
||||
maxLength,
|
||||
className = '',
|
||||
title,
|
||||
truncationType = 'statusCard',
|
||||
textType = 'title',
|
||||
showTooltip = true,
|
||||
as: Component = 'span'
|
||||
}) => {
|
||||
const screenSize = React.useMemo(() => getScreenSize(), []);
|
||||
|
||||
// Get truncation engine based on type
|
||||
const getTruncationEngine = () => {
|
||||
switch (truncationType) {
|
||||
case 'mobile':
|
||||
return TextOverflowPrevention.mobile;
|
||||
case 'production':
|
||||
return TextOverflowPrevention.production;
|
||||
default:
|
||||
return TextOverflowPrevention.statusCard;
|
||||
}
|
||||
};
|
||||
|
||||
const engine = getTruncationEngine();
|
||||
|
||||
// Get truncated text based on text type
|
||||
const getTruncatedText = () => {
|
||||
if (maxLength) {
|
||||
const currentMaxLength = maxLength[screenSize] || maxLength.desktop || text.length;
|
||||
return text.length > currentMaxLength
|
||||
? text.substring(0, currentMaxLength - 3) + '...'
|
||||
: text;
|
||||
}
|
||||
|
||||
// Use predefined truncation methods
|
||||
switch (textType) {
|
||||
case 'title':
|
||||
return engine.title ? engine.title(text) : text;
|
||||
case 'subtitle':
|
||||
return engine.subtitle ? engine.subtitle(text) : text;
|
||||
case 'label':
|
||||
return engine.primaryValueLabel ? engine.primaryValueLabel(text) : text;
|
||||
case 'metadata':
|
||||
return engine.metadataItem ? engine.metadataItem(text) : text;
|
||||
case 'action':
|
||||
return engine.actionLabel ? engine.actionLabel(text) : text;
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const truncatedText = getTruncatedText();
|
||||
const shouldShowTooltip = showTooltip && truncatedText !== text;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`${overflowClasses.truncate} ${className}`}
|
||||
title={shouldShowTooltip ? (title || text) : title}
|
||||
>
|
||||
{truncatedText}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsiveText;
|
||||
2
frontend/src/components/ui/ResponsiveText/index.ts
Normal file
2
frontend/src/components/ui/ResponsiveText/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ResponsiveText } from './ResponsiveText';
|
||||
export type { ResponsiveTextProps } from './ResponsiveText';
|
||||
176
frontend/src/components/ui/SearchAndFilter/SearchAndFilter.tsx
Normal file
176
frontend/src/components/ui/SearchAndFilter/SearchAndFilter.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { Input } from '../Input';
|
||||
import { Button } from '../Button';
|
||||
import { Card } from '../Card';
|
||||
import { Select } from '../Select';
|
||||
import { Toggle } from '../Toggle';
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'dropdown' | 'buttons' | 'checkbox';
|
||||
options?: FilterOption[];
|
||||
value: string | string[] | boolean;
|
||||
onChange: (value: string | string[] | boolean) => void;
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchAndFilterProps {
|
||||
// Search configuration
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
|
||||
// Filter configuration
|
||||
filters?: FilterConfig[];
|
||||
|
||||
// Layout configuration
|
||||
className?: string;
|
||||
showFilterIcon?: boolean;
|
||||
|
||||
// Mobile responsive behavior
|
||||
stackOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const SearchAndFilter: React.FC<SearchAndFilterProps> = ({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Buscar...",
|
||||
filters = [],
|
||||
className = "",
|
||||
showFilterIcon = true,
|
||||
stackOnMobile = true
|
||||
}) => {
|
||||
const renderDropdownFilter = (filter: FilterConfig) => {
|
||||
const currentValue = filter.value as string;
|
||||
|
||||
// Convert FilterOptions to SelectOptions format
|
||||
const selectOptions = [
|
||||
{ value: '', label: filter.placeholder || `Todos ${filter.label.toLowerCase()}` },
|
||||
...(filter.options?.map(option => ({
|
||||
value: option.value,
|
||||
label: option.count !== undefined ? `${option.label} (${option.count})` : option.label
|
||||
})) || [])
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={filter.key} className="min-w-[140px]">
|
||||
<Select
|
||||
value={currentValue}
|
||||
onChange={(value) => filter.onChange(value)}
|
||||
options={selectOptions}
|
||||
placeholder={filter.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderButtonFilter = (filter: FilterConfig) => {
|
||||
const currentValues = Array.isArray(filter.value) ? filter.value : [filter.value];
|
||||
|
||||
return (
|
||||
<div key={filter.key} className="flex items-center gap-2 flex-wrap">
|
||||
{filter.options?.map((option) => {
|
||||
const isActive = currentValues.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={isActive ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (filter.multiple) {
|
||||
const newValues = isActive
|
||||
? currentValues.filter(v => v !== option.value)
|
||||
: [...currentValues, option.value];
|
||||
filter.onChange(newValues);
|
||||
} else {
|
||||
filter.onChange(isActive ? "" : option.value);
|
||||
}
|
||||
}}
|
||||
className={`text-sm px-3 py-1.5 ${
|
||||
isActive
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
{option.count !== undefined && (
|
||||
<span className={`ml-1 ${isActive ? 'text-white/80' : 'text-[var(--text-tertiary)]'}`}>
|
||||
({option.count})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCheckboxFilter = (filter: FilterConfig) => {
|
||||
const isChecked = filter.value as boolean;
|
||||
|
||||
return (
|
||||
<div key={filter.key} className="flex items-center">
|
||||
<Toggle
|
||||
checked={isChecked}
|
||||
onChange={(checked) => filter.onChange(checked)}
|
||||
label={filter.label}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFilter = (filter: FilterConfig) => {
|
||||
switch (filter.type) {
|
||||
case 'dropdown':
|
||||
return renderDropdownFilter(filter);
|
||||
case 'buttons':
|
||||
return renderButtonFilter(filter);
|
||||
case 'checkbox':
|
||||
return renderCheckboxFilter(filter);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-4 ${className}`}>
|
||||
<div className={`flex gap-4 ${stackOnMobile ? 'flex-col sm:flex-row' : 'flex-row'}`}>
|
||||
{/* Search Input */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{filters.length > 0 && (
|
||||
<div className={`flex items-center gap-3 ${stackOnMobile ? 'flex-wrap' : ''}`}>
|
||||
{showFilterIcon && (
|
||||
<Filter className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
)}
|
||||
{filters.map(renderFilter)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchAndFilter;
|
||||
1
frontend/src/components/ui/SearchAndFilter/index.ts
Normal file
1
frontend/src/components/ui/SearchAndFilter/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SearchAndFilter, type SearchAndFilterProps, type FilterConfig, type FilterOption } from './SearchAndFilter';
|
||||
@@ -4,6 +4,7 @@ import { Card } from '../Card';
|
||||
import { Button } from '../Button';
|
||||
import { ProgressBar } from '../ProgressBar';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { TextOverflowPrevention, overflowClasses, getScreenSize, safeText } from '../../../utils/textUtils';
|
||||
|
||||
export interface StatusIndicatorConfig {
|
||||
color: string;
|
||||
@@ -95,6 +96,13 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
const StatusIcon = statusIndicator.icon;
|
||||
const hasInteraction = onClick || actions.length > 0;
|
||||
|
||||
// Detect screen size for responsive truncation
|
||||
const screenSize = React.useMemo(() => getScreenSize(), []);
|
||||
const isMobile = screenSize === 'mobile';
|
||||
|
||||
// Apply truncation based on screen size
|
||||
const truncationEngine = isMobile ? TextOverflowPrevention.mobile : TextOverflowPrevention.statusCard;
|
||||
|
||||
// Sort actions by priority
|
||||
const sortedActions = [...actions].sort((a, b) => {
|
||||
const priorityOrder = { primary: 0, secondary: 1, tertiary: 2 };
|
||||
@@ -149,8 +157,11 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 truncate">
|
||||
{title}
|
||||
<div
|
||||
className={`font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 ${overflowClasses.truncate}`}
|
||||
title={title}
|
||||
>
|
||||
{truncationEngine.title(title)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
@@ -160,7 +171,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
: statusIndicator.isHighlight
|
||||
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
|
||||
: 'ring-1 shadow-sm'
|
||||
}`}
|
||||
} max-w-[120px] sm:max-w-[150px]`}
|
||||
style={{
|
||||
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
@@ -172,28 +183,42 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
}}
|
||||
>
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="mr-2 text-sm">🚨</span>
|
||||
<span className="mr-1 text-sm flex-shrink-0">🚨</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="mr-1.5">⚠️</span>
|
||||
<span className="mr-1 flex-shrink-0">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.text}
|
||||
<span
|
||||
className={`${overflowClasses.truncate} flex-1`}
|
||||
title={statusIndicator.text}
|
||||
>
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 12 : 15)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-sm text-[var(--text-secondary)] truncate">
|
||||
{subtitle}
|
||||
<div
|
||||
className={`text-sm text-[var(--text-secondary)] ${overflowClasses.truncate}`}
|
||||
title={subtitle}
|
||||
>
|
||||
{truncationEngine.subtitle(subtitle)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4 min-w-0">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none truncate">
|
||||
{primaryValue}
|
||||
<div className="text-right flex-shrink-0 ml-4 min-w-0 max-w-[120px] sm:max-w-[150px]">
|
||||
<div
|
||||
className={`text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none ${overflowClasses.truncate}`}
|
||||
title={primaryValue?.toString()}
|
||||
>
|
||||
{safeText(primaryValue?.toString(), '0', isMobile ? 10 : 15)}
|
||||
</div>
|
||||
{primaryValueLabel && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 truncate">
|
||||
{primaryValueLabel}
|
||||
<div
|
||||
className={`text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 ${overflowClasses.truncate}`}
|
||||
title={primaryValueLabel}
|
||||
>
|
||||
{truncationEngine.primaryValueLabel(primaryValueLabel)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,12 +226,18 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
|
||||
{/* Secondary info - Mobile optimized */}
|
||||
{secondaryInfo && (
|
||||
<div className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="text-[var(--text-secondary)] truncate flex-shrink-0">
|
||||
{secondaryInfo.label}
|
||||
<div className="flex items-center justify-between text-sm gap-2 min-w-0">
|
||||
<span
|
||||
className={`text-[var(--text-secondary)] flex-shrink-0 ${overflowClasses.truncate} max-w-[100px] sm:max-w-[120px]`}
|
||||
title={secondaryInfo.label}
|
||||
>
|
||||
{truncationEngine.secondaryLabel(secondaryInfo.label)}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--text-primary)] truncate text-right">
|
||||
{secondaryInfo.value}
|
||||
<span
|
||||
className={`font-medium text-[var(--text-primary)] text-right flex-1 ${overflowClasses.truncate}`}
|
||||
title={secondaryInfo.value}
|
||||
>
|
||||
{truncationEngine.secondaryValue(secondaryInfo.value)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -220,7 +251,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
size="md"
|
||||
variant="default"
|
||||
showLabel={true}
|
||||
label={progress.label}
|
||||
label={truncationEngine.progressLabel(progress.label)}
|
||||
animated={progress.percentage < 100}
|
||||
customColor={progress.color || statusIndicator.color}
|
||||
className="w-full"
|
||||
@@ -230,12 +261,23 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
)}
|
||||
|
||||
|
||||
{/* Metadata - Improved mobile layout */}
|
||||
{/* Metadata - Improved mobile layout with overflow prevention */}
|
||||
{metadata.length > 0 && (
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
{metadata.map((item, index) => (
|
||||
<div key={index} className="truncate" title={item}>{item}</div>
|
||||
{metadata.slice(0, isMobile ? 3 : 4).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${overflowClasses.truncate} ${overflowClasses.breakWords}`}
|
||||
title={item}
|
||||
>
|
||||
{truncationEngine.metadataItem(item)}
|
||||
</div>
|
||||
))}
|
||||
{metadata.length > (isMobile ? 3 : 4) && (
|
||||
<div className="text-[var(--text-tertiary)] italic">
|
||||
+{metadata.length - (isMobile ? 3 : 4)} más...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -248,18 +290,24 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
{/* Primary action as a subtle text button */}
|
||||
{primaryActions.length > 0 && (
|
||||
<button
|
||||
onClick={primaryActions[0].onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
primaryActions[0].onClick();
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0
|
||||
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0 max-w-[120px] sm:max-w-[150px]
|
||||
${primaryActions[0].destructive
|
||||
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
|
||||
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
|
||||
}
|
||||
`}
|
||||
title={primaryActions[0].label}
|
||||
>
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
|
||||
<span className="truncate">{primaryActions[0].label}</span>
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" })}
|
||||
<span className={`${overflowClasses.truncate} flex-1`}>
|
||||
{truncationEngine.actionLabel(primaryActions[0].label)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -268,7 +316,10 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
{secondaryActions.map((action, index) => (
|
||||
<button
|
||||
key={`action-${index}`}
|
||||
onClick={action.onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
}}
|
||||
title={action.label}
|
||||
className={`
|
||||
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
|
||||
@@ -286,7 +337,10 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
{primaryActions.slice(1).map((action, index) => (
|
||||
<button
|
||||
key={`primary-icon-${index}`}
|
||||
onClick={action.onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
}}
|
||||
title={action.label}
|
||||
className={`
|
||||
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
|
||||
|
||||
@@ -25,6 +25,8 @@ export { TenantSwitcher } from './TenantSwitcher';
|
||||
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
|
||||
export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { ResponsiveText } from './ResponsiveText';
|
||||
export { SearchAndFilter } from './SearchAndFilter';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -50,4 +52,6 @@ export type { EditViewModalProps, EditViewModalField, EditViewModalSection, Edit
|
||||
export type { AddModalProps, AddModalField, AddModalSection } from './AddModal';
|
||||
export type { DialogModalProps, DialogModalAction } from './DialogModal';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ResponsiveTextProps } from './ResponsiveText';
|
||||
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
|
||||
Reference in New Issue
Block a user