Add improved production UI

This commit is contained in:
Urtzi Alfaro
2025-09-23 12:49:35 +02:00
parent 8d54202e91
commit 4ae8e14e55
35 changed files with 6848 additions and 415 deletions

View File

@@ -13,12 +13,13 @@ export interface ProgressBarProps {
value: number;
max?: number;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'pending' | 'inProgress' | 'completed';
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'pending' | 'inProgress' | 'completed' | 'indeterminate';
showLabel?: boolean;
label?: string;
className?: string;
animated?: boolean;
customColor?: string;
glow?: boolean;
}
const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
@@ -31,9 +32,11 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
className,
animated = false,
customColor,
glow = false,
...props
}, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
const isIndeterminate = variant === 'indeterminate';
const sizeClasses = {
sm: 'h-2',
@@ -61,8 +64,11 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
// Check if it's a base color variant (has color scales)
if (variant in baseColorMap) {
const colors = baseColorMap[variant as keyof typeof baseColorMap];
// Enhanced gradient with multiple color stops for a more modern look
return {
background: `linear-gradient(to right, ${colors[400]}, ${colors[500]})`,
background: `linear-gradient(90deg, ${colors[300]}, ${colors[400]}, ${colors[500]}, ${colors[600]})`,
backgroundSize: '200% 100%',
animation: animated ? 'gradient-shift 2s ease infinite' : undefined,
};
}
@@ -76,13 +82,27 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
// Default fallback
return {
background: `linear-gradient(to right, ${baseColors.primary[400]}, ${baseColors.primary[500]})`,
background: `linear-gradient(90deg, ${baseColors.primary[300]}, ${baseColors.primary[400]}, ${baseColors.primary[500]}, ${baseColors.primary[600]})`,
backgroundSize: '200% 100%',
animation: animated ? 'gradient-shift 2s ease infinite' : undefined,
};
};
// Determine if we should show animation
const shouldAnimate = animated && percentage < 100 && !isIndeterminate;
return (
<div ref={ref} className={clsx('w-full', className)} {...props}>
{(showLabel || label) && (
<div
ref={ref}
className={clsx('w-full', className)}
role="progressbar"
aria-valuenow={isIndeterminate ? undefined : value}
aria-valuemin={isIndeterminate ? undefined : 0}
aria-valuemax={isIndeterminate ? undefined : max}
aria-label={label || (isIndeterminate ? "Loading" : `${Math.round(percentage)}% complete`)}
{...props}
>
{(showLabel || label) && !isIndeterminate && (
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-medium text-[var(--text-primary)]">
{label || `${value}/${max}`}
@@ -95,26 +115,46 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
<div
className={clsx(
'w-full bg-[var(--bg-quaternary)] rounded-full overflow-hidden',
sizeClasses[size]
sizeClasses[size],
{
'animate-pulse': isIndeterminate,
}
)}
>
<div
className={clsx(
'h-full rounded-full transition-all duration-300 ease-out relative overflow-hidden',
'h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden',
{
'animate-pulse': animated && percentage < 100,
'animate-progress-indeterminate': isIndeterminate,
'shadow-sm': glow && !isIndeterminate,
}
)}
style={{
width: `${percentage}%`,
...(customColor ? { backgroundColor: customColor } : getVariantStyle(variant))
width: isIndeterminate ? '100%' : `${percentage}%`,
...(customColor ? { backgroundColor: customColor } : getVariantStyle(variant)),
...(glow && !isIndeterminate ? {
boxShadow: `0 0 8px ${customColor || (variant in statusColors ? statusColors[variant as keyof typeof statusColors]?.primary : baseColors[variant as keyof typeof baseColors]?.[500] || baseColors.primary[500])}`
} : {})
}}
>
{animated && percentage < 100 && (
{/* Animated shimmer effect for active progress */}
{shouldAnimate && (
<div
className="absolute inset-0 opacity-20 animate-pulse"
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(to right, transparent, var(--text-inverse), transparent)'
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent)',
animation: 'progress-indeterminate 2s ease infinite',
}}
/>
)}
{/* Enhanced indeterminate animation */}
{isIndeterminate && (
<div
className="absolute inset-0 opacity-40"
style={{
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent)',
animation: 'progress-indeterminate 1.5s ease infinite',
}}
/>
)}

View File

@@ -223,6 +223,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
animated={progress.percentage < 100}
customColor={progress.color || statusIndicator.color}
className="w-full"
glow={progress.percentage > 10 && progress.percentage < 90}
/>
</div>
)}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { createContext, useContext, useState } from 'react';
import { Card } from '../Card';
export interface TabItem {
@@ -8,15 +8,37 @@ export interface TabItem {
}
export interface TabsProps {
items: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
items?: TabItem[];
activeTab?: string;
onTabChange?: (tabId: string) => void;
variant?: 'default' | 'pills' | 'underline';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
className?: string;
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children?: React.ReactNode;
}
// Context for compound component pattern
interface TabsContextType {
activeTab: string;
onTabChange: (tabId: string) => void;
variant: 'default' | 'pills' | 'underline';
size: 'sm' | 'md' | 'lg';
}
const TabsContext = createContext<TabsContextType | null>(null);
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs compound components must be used within a Tabs component');
}
return context;
};
const Tabs: React.FC<TabsProps> = ({
items,
activeTab,
@@ -24,8 +46,40 @@ const Tabs: React.FC<TabsProps> = ({
variant = 'pills',
size = 'md',
fullWidth = false,
className = ''
className = '',
defaultValue,
value,
onValueChange,
children
}) => {
// Handle both controlled and uncontrolled state
const [internalActiveTab, setInternalActiveTab] = useState(defaultValue || '');
const currentActiveTab = value || activeTab || internalActiveTab;
const handleTabChange = (tabId: string) => {
if (onValueChange) {
onValueChange(tabId);
} else if (onTabChange) {
onTabChange(tabId);
} else {
setInternalActiveTab(tabId);
}
};
// If children are provided, use compound component pattern
if (children) {
return (
<TabsContext.Provider value={{
activeTab: currentActiveTab,
onTabChange: handleTabChange,
variant,
size
}}>
<div className={className}>
{children}
</div>
</TabsContext.Provider>
);
}
// Size classes
const sizeClasses = {
sm: 'px-2 py-1.5 text-xs',
@@ -119,4 +173,113 @@ const Tabs: React.FC<TabsProps> = ({
);
};
// Compound Components
export interface TabsListProps {
className?: string;
children: React.ReactNode;
}
export const TabsList: React.FC<TabsListProps> = ({ className = '', children }) => {
const { variant } = useTabsContext();
const baseClasses = variant === 'underline'
? 'border-b border-[var(--border-primary)]'
: 'p-1 bg-[var(--bg-secondary)] rounded-lg';
return (
<div className={`flex ${baseClasses} ${className}`}>
{children}
</div>
);
};
export interface TabsTriggerProps {
value: string;
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
export const TabsTrigger: React.FC<TabsTriggerProps> = ({
value,
children,
className = '',
disabled = false
}) => {
const { activeTab, onTabChange, variant, size } = useTabsContext();
const isActive = activeTab === value;
const sizeClasses = {
sm: 'px-2 py-1.5 text-xs',
md: 'px-3 py-2 text-sm',
lg: 'px-4 py-3 text-base'
};
const getVariantClasses = () => {
if (disabled) {
return 'text-[var(--text-tertiary)] cursor-not-allowed opacity-50';
}
switch (variant) {
case 'pills':
return isActive
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]';
case 'underline':
return isActive
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] border-b-2 border-transparent';
default:
return isActive
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]';
}
};
const buttonClasses = `
font-medium transition-colors
${variant === 'pills' ? 'rounded-md' : ''}
${sizeClasses[size]}
${getVariantClasses()}
${className}
`.trim();
return (
<button
onClick={() => !disabled && onTabChange(value)}
disabled={disabled}
className={buttonClasses}
type="button"
>
{children}
</button>
);
};
export interface TabsContentProps {
value: string;
children: React.ReactNode;
className?: string;
}
export const TabsContent: React.FC<TabsContentProps> = ({
value,
children,
className = ''
}) => {
const { activeTab } = useTabsContext();
if (activeTab !== value) {
return null;
}
return (
<div className={`mt-4 ${className}`}>
{children}
</div>
);
};
export default Tabs;

View File

@@ -1,2 +1,8 @@
export { default as Tabs } from './Tabs';
export type { TabsProps, TabItem } from './Tabs';
export { default as Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
export type {
TabsProps,
TabItem,
TabsListProps,
TabsTriggerProps,
TabsContentProps
} from './Tabs';

View File

@@ -0,0 +1,129 @@
import React, { forwardRef, TextareaHTMLAttributes } from 'react';
import { clsx } from 'clsx';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
helperText?: string;
isRequired?: boolean;
isInvalid?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'outline' | 'filled' | 'unstyled';
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
label,
error,
helperText,
isRequired = false,
isInvalid = false,
size = 'md',
variant = 'outline',
resize = 'vertical',
className,
id,
rows = 3,
...props
}, ref) => {
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const hasError = isInvalid || !!error;
const baseTextareaClasses = [
'w-full transition-colors duration-200',
'focus:outline-none',
'disabled:opacity-50 disabled:cursor-not-allowed',
'placeholder:text-[var(--text-tertiary)]'
];
const variantClasses = {
outline: [
'bg-[var(--bg-primary)] border border-[var(--border-secondary)]',
'focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)]',
hasError ? 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]' : ''
],
filled: [
'bg-[var(--bg-secondary)] border border-transparent',
'focus:bg-[var(--bg-primary)] focus:border-[var(--color-primary)]',
hasError ? 'border-[var(--color-error)]' : ''
],
unstyled: [
'bg-transparent border-none',
'focus:ring-0'
]
};
const sizeClasses = {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-3 text-base',
lg: 'px-5 py-4 text-lg'
};
const resizeClasses = {
none: 'resize-none',
vertical: 'resize-y',
horizontal: 'resize-x',
both: 'resize'
};
const textareaClasses = clsx(
baseTextareaClasses,
variantClasses[variant],
sizeClasses[size],
resizeClasses[resize],
'rounded-lg',
className
);
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-[var(--text-primary)] mb-2"
>
{label}
{isRequired && (
<span className="text-[var(--color-error)] ml-1">*</span>
)}
</label>
)}
<textarea
ref={ref}
id={textareaId}
rows={rows}
className={textareaClasses}
aria-invalid={hasError}
aria-describedby={
error ? `${textareaId}-error` :
helperText ? `${textareaId}-helper` :
undefined
}
{...props}
/>
{error && (
<p
id={`${textareaId}-error`}
className="mt-2 text-sm text-[var(--color-error)]"
>
{error}
</p>
)}
{helperText && !error && (
<p
id={`${textareaId}-helper`}
className="mt-2 text-sm text-[var(--text-secondary)]"
>
{helperText}
</p>
)}
</div>
);
});
Textarea.displayName = 'Textarea';
export default Textarea;

View File

@@ -0,0 +1,2 @@
export { default as Textarea } from './Textarea';
export type { TextareaProps } from './Textarea';

View File

@@ -1,6 +1,7 @@
// UI Components - Design System
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Textarea } from './Textarea/Textarea';
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
export { default as Table } from './Table';
@@ -22,6 +23,7 @@ export { TenantSwitcher } from './TenantSwitcher';
// Export types
export type { ButtonProps } from './Button';
export type { InputProps } from './Input';
export type { TextareaProps } from './Textarea';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
export type { TableProps, TableColumn, TableRow } from './Table';