refactor: Extract reusable UI components and add TDD documentation

- Add comprehensive TDD documentation to CLAUDE.md with coverage requirements and examples
- Extract reusable UI components to frontend/src/components/ui/ (Modal, FormInput, Button, Alert, etc.)
- Add shared constants (schedulePresets) and utility hooks (useCrudMutation, useFormValidation)
- Update frontend/CLAUDE.md with component documentation and usage examples
- Refactor CreateTaskModal to use shared components and constants
- Fix test assertions to be more robust and accurate across all test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-10 15:27:27 -05:00
parent 18c9a69d75
commit 8c52d6a275
48 changed files with 2780 additions and 444 deletions

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react';
type AlertVariant = 'error' | 'success' | 'warning' | 'info';
interface AlertProps {
variant: AlertVariant;
message: string | React.ReactNode;
title?: string;
onDismiss?: () => void;
className?: string;
/** Compact mode for inline alerts */
compact?: boolean;
}
const variantConfig: Record<AlertVariant, {
icon: React.ReactNode;
containerClass: string;
textClass: string;
titleClass: string;
}> = {
error: {
icon: <AlertCircle size={20} />,
containerClass: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
textClass: 'text-red-800 dark:text-red-200',
titleClass: 'text-red-900 dark:text-red-100',
},
success: {
icon: <CheckCircle size={20} />,
containerClass: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
textClass: 'text-green-800 dark:text-green-200',
titleClass: 'text-green-900 dark:text-green-100',
},
warning: {
icon: <AlertTriangle size={20} />,
containerClass: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800',
textClass: 'text-amber-800 dark:text-amber-200',
titleClass: 'text-amber-900 dark:text-amber-100',
},
info: {
icon: <Info size={20} />,
containerClass: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
textClass: 'text-blue-800 dark:text-blue-200',
titleClass: 'text-blue-900 dark:text-blue-100',
},
};
export const Alert: React.FC<AlertProps> = ({
variant,
message,
title,
onDismiss,
className = '',
compact = false,
}) => {
const config = variantConfig[variant];
return (
<div
className={`${compact ? 'p-2' : 'p-3'} border rounded-lg ${config.containerClass} ${className}`}
role="alert"
>
<div className="flex items-start gap-3">
<span className={`flex-shrink-0 ${config.textClass}`}>{config.icon}</span>
<div className="flex-1 min-w-0">
{title && (
<p className={`font-medium ${config.titleClass} ${compact ? 'text-sm' : ''}`}>
{title}
</p>
)}
<div className={`${compact ? 'text-xs' : 'text-sm'} ${config.textClass} ${title ? 'mt-1' : ''}`}>
{typeof message === 'string' ? <p>{message}</p> : message}
</div>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className={`flex-shrink-0 p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${config.textClass}`}
aria-label="Dismiss"
>
<X size={16} />
</button>
)}
</div>
</div>
);
};
/** Convenience components */
export const ErrorMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="error" {...props} />
);
export const SuccessMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="success" {...props} />
);
export const WarningMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="warning" {...props} />
);
export const InfoMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="info" {...props} />
);
export default Alert;

View File

@@ -0,0 +1,61 @@
import React from 'react';
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
type BadgeSize = 'sm' | 'md' | 'lg';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
size?: BadgeSize;
/** Rounded pill style */
pill?: boolean;
/** Dot indicator before text */
dot?: boolean;
className?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
primary: 'bg-brand-100 dark:bg-brand-900/30 text-brand-800 dark:text-brand-200',
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
warning: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200',
danger: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200',
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
};
const dotColors: Record<BadgeVariant, string> = {
default: 'bg-gray-400',
primary: 'bg-brand-500',
success: 'bg-green-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
info: 'bg-blue-500',
};
const sizeClasses: Record<BadgeSize, string> = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-xs',
lg: 'px-2.5 py-1.5 text-sm',
};
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
pill = false,
dot = false,
className = '',
}) => {
const roundedClass = pill ? 'rounded-full' : 'rounded';
return (
<span
className={`inline-flex items-center gap-1.5 font-medium ${roundedClass} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
>
{dot && <span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`} />}
{children}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,108 @@
import React, { forwardRef } from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
loadingText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent',
outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
success: 'bg-green-600 hover:bg-green-700 text-white border-transparent',
warning: 'bg-amber-600 hover:bg-amber-700 text-white border-transparent',
};
const sizeClasses: Record<ButtonSize, string> = {
xs: 'px-2 py-1 text-xs',
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
const iconSizes: Record<ButtonSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
isLoading = false,
loadingText,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
children,
className = '',
...props
},
ref
) => {
const isDisabled = disabled || isLoading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`
inline-flex items-center justify-center gap-2
font-medium rounded-lg border
transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]}
${sizeClasses[size]}
${fullWidth ? 'w-full' : ''}
${className}
`}
{...props}
>
{isLoading ? (
<>
<Loader2 className={`animate-spin ${iconSizes[size]}`} />
{loadingText || children}
</>
) : (
<>
{leftIcon && <span className={iconSizes[size]}>{leftIcon}</span>}
{children}
{rightIcon && <span className={iconSizes[size]}>{rightIcon}</span>}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
/** Convenience component for submit buttons */
export const SubmitButton: React.FC<Omit<ButtonProps, 'type'> & { submitText?: string }> = ({
isLoading,
submitText = 'Save',
loadingText = 'Saving...',
children,
...props
}) => (
<Button type="submit" isLoading={isLoading} loadingText={loadingText} {...props}>
{children || submitText}
</Button>
);
export default Button;

View File

@@ -0,0 +1,88 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
/** Card padding */
padding?: 'none' | 'sm' | 'md' | 'lg';
/** Show border */
bordered?: boolean;
/** Hover effect */
hoverable?: boolean;
/** Click handler */
onClick?: () => void;
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
/** Action buttons for the header */
actions?: React.ReactNode;
}
interface CardBodyProps {
children: React.ReactNode;
className?: string;
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export const Card: React.FC<CardProps> = ({
children,
className = '',
padding = 'md',
bordered = true,
hoverable = false,
onClick,
}) => {
const baseClasses = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm';
const borderClass = bordered ? 'border border-gray-200 dark:border-gray-700' : '';
const hoverClass = hoverable
? 'hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600 transition-all cursor-pointer'
: '';
const paddingClass = paddingClasses[padding];
return (
<div
className={`${baseClasses} ${borderClass} ${hoverClass} ${paddingClass} ${className}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
{children}
</div>
);
};
export const CardHeader: React.FC<CardHeaderProps> = ({
children,
className = '',
actions,
}) => (
<div className={`flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700 ${className}`}>
<div className="font-semibold text-gray-900 dark:text-white">{children}</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
export const CardBody: React.FC<CardBodyProps> = ({ children, className = '' }) => (
<div className={`py-4 ${className}`}>{children}</div>
);
export const CardFooter: React.FC<CardFooterProps> = ({ children, className = '' }) => (
<div className={`pt-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
{children}
</div>
);
export default Card;

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef } from 'react';
interface CurrencyInputProps {
value: number; // Value in cents (integer)
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
className?: string;
min?: number;
max?: number;
}
/**
* ATM-style currency input where digits are entered as cents.
* As more digits are entered, they shift from cents to dollars.
* Only accepts integer values (digits 0-9).
*
* Example: typing "1234" displays "$12.34"
* - Type "1" → $0.01
* - Type "2" → $0.12
* - Type "3" → $1.23
* - Type "4" → $12.34
*/
const CurrencyInput: React.FC<CurrencyInputProps> = ({
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
className = '',
min,
max,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
// Ensure value is always an integer
const safeValue = Math.floor(Math.abs(value)) || 0;
// Format cents as dollars string (e.g., 1234 → "$12.34")
const formatCentsAsDollars = (cents: number): string => {
if (cents === 0 && !isFocused) return '';
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
};
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
// Process a new digit being added
const addDigit = (digit: number) => {
let newValue = safeValue * 10 + digit;
// Enforce max if specified
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
};
// Remove the last digit
const removeDigit = () => {
const newValue = Math.floor(safeValue / 10);
onChange(newValue);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Allow navigation keys without preventing default
if (
e.key === 'Tab' ||
e.key === 'Escape' ||
e.key === 'Enter' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'Home' ||
e.key === 'End'
) {
return;
}
// Handle backspace/delete
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
removeDigit();
return;
}
// Only allow digits 0-9
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
addDigit(parseInt(e.key, 10));
return;
}
// Block everything else
e.preventDefault();
};
// Catch input from mobile keyboards, IME, voice input, etc.
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
const inputEvent = e.nativeEvent as InputEvent;
const data = inputEvent.data;
// Always prevent default - we handle all input ourselves
e.preventDefault();
if (!data) return;
// Extract only digits from the input
const digits = data.replace(/\D/g, '');
// Add each digit one at a time
for (const char of digits) {
addDigit(parseInt(char, 10));
}
};
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
// Enforce min on blur if specified
if (min !== undefined && safeValue < min && safeValue > 0) {
onChange(min);
}
};
// Handle paste - extract digits only
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const digits = pastedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
// Handle drop - extract digits only
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault();
const droppedText = e.dataTransfer.getData('text');
const digits = droppedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
return (
<input
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={displayValue}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onDrop={handleDrop}
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
disabled={disabled}
required={required}
placeholder={placeholder}
className={className}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
);
};
export default CurrencyInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Inbox } from 'lucide-react';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
className = '',
}) => {
return (
<div className={`text-center py-12 px-4 ${className}`}>
<div className="flex justify-center mb-4">
{icon || <Inbox className="h-12 w-12 text-gray-400" />}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto mb-4">
{description}
</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import CurrencyInput from './CurrencyInput';
interface FormCurrencyInputProps {
label?: string;
error?: string;
hint?: string;
value: number;
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
min?: number;
max?: number;
/** Container class name */
containerClassName?: string;
/** Input class name */
className?: string;
}
/**
* Form wrapper for CurrencyInput that adds label, error, and hint support.
* Uses the ATM-style currency input where digits are entered as cents.
*/
export const FormCurrencyInput: React.FC<FormCurrencyInputProps> = ({
label,
error,
hint,
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
min,
max,
containerClassName = '',
className = '',
}) => {
const baseInputClasses =
'w-full px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
return (
<div className={containerClassName}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<CurrencyInput
value={value}
onChange={onChange}
disabled={disabled}
required={required}
placeholder={placeholder}
min={min}
max={max}
className={`${baseInputClasses} ${stateClasses} ${disabledClasses} ${className}`}
/>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
};
export default FormCurrencyInput;

View File

@@ -0,0 +1,104 @@
import React, { forwardRef } from 'react';
interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
/** Size variant */
inputSize?: 'sm' | 'md' | 'lg';
/** Full width */
fullWidth?: boolean;
/** Icon to display on the left */
leftIcon?: React.ReactNode;
/** Icon to display on the right */
rightIcon?: React.ReactNode;
/** Container class name */
containerClassName?: string;
}
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2',
lg: 'px-4 py-3 text-lg',
};
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
(
{
label,
error,
hint,
inputSize = 'md',
fullWidth = true,
leftIcon,
rightIcon,
containerClassName = '',
className = '',
id,
...props
},
ref
) => {
const inputId = id || props.name || `input-${Math.random().toString(36).substr(2, 9)}`;
const baseClasses =
'border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className={`relative ${widthClass}`}>
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{leftIcon}
</div>
)}
<input
ref={ref}
id={inputId}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[inputSize]} ${widthClass} ${leftIcon ? 'pl-10' : ''} ${rightIcon ? 'pr-10' : ''} ${className}`}
{...props}
/>
{rightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
{rightIcon}
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
}
);
FormInput.displayName = 'FormInput';
export default FormInput;

View File

@@ -0,0 +1,115 @@
import React, { forwardRef } from 'react';
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
options: SelectOption[];
/** Size variant */
selectSize?: 'sm' | 'md' | 'lg';
/** Full width */
fullWidth?: boolean;
/** Placeholder option */
placeholder?: string;
/** Container class name */
containerClassName?: string;
}
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2',
lg: 'px-4 py-3 text-lg',
};
export const FormSelect = forwardRef<HTMLSelectElement, FormSelectProps>(
(
{
label,
error,
hint,
options,
selectSize = 'md',
fullWidth = true,
placeholder,
containerClassName = '',
className = '',
id,
...props
},
ref
) => {
const selectId = id || props.name || `select-${Math.random().toString(36).substr(2, 9)}`;
const baseClasses =
'border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors appearance-none cursor-pointer';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className={`relative ${widthClass}`}>
<select
ref={ref}
id={selectId}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[selectSize]} ${widthClass} pr-10 ${className}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
{/* Custom dropdown arrow */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
}
);
FormSelect.displayName = 'FormSelect';
export default FormSelect;

View File

@@ -0,0 +1,94 @@
import React, { forwardRef } from 'react';
interface FormTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
/** Full width */
fullWidth?: boolean;
/** Container class name */
containerClassName?: string;
/** Show character count */
showCharCount?: boolean;
/** Max characters for count display */
maxChars?: number;
}
export const FormTextarea = forwardRef<HTMLTextAreaElement, FormTextareaProps>(
(
{
label,
error,
hint,
fullWidth = true,
containerClassName = '',
className = '',
id,
showCharCount = false,
maxChars,
value,
...props
},
ref
) => {
const textareaId = id || props.name || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const charCount = typeof value === 'string' ? value.length : 0;
const baseClasses =
'px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors resize-y';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<textarea
ref={ref}
id={textareaId}
value={value}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${widthClass} ${className}`}
{...props}
/>
<div className="flex justify-between items-center mt-1">
<div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
{showCharCount && (
<p className={`text-sm ${maxChars && charCount > maxChars ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
{charCount}{maxChars ? `/${maxChars}` : ''}
</p>
)}
</div>
</div>
);
}
);
FormTextarea.displayName = 'FormTextarea';
export default FormTextarea;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingSpinnerProps {
/** Size of the spinner */
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Color of the spinner */
color?: 'default' | 'white' | 'brand' | 'blue';
/** Optional label to display below spinner */
label?: string;
/** Center spinner in container */
centered?: boolean;
/** Additional class name */
className?: string;
}
const sizeClasses = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-12 w-12',
};
const colorClasses = {
default: 'text-gray-500 dark:text-gray-400',
white: 'text-white',
brand: 'text-brand-600 dark:text-brand-400',
blue: 'text-blue-600 dark:text-blue-400',
};
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
color = 'default',
label,
centered = false,
className = '',
}) => {
const spinner = (
<div className={`flex flex-col items-center gap-2 ${className}`}>
<Loader2 className={`animate-spin ${sizeClasses[size]} ${colorClasses[color]}`} />
{label && (
<span className={`text-sm ${colorClasses[color]}`}>{label}</span>
)}
</div>
);
if (centered) {
return (
<div className="flex items-center justify-center py-12">
{spinner}
</div>
);
}
return spinner;
};
/** Full page loading state */
export const PageLoading: React.FC<{ label?: string }> = ({ label = 'Loading...' }) => (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" color="brand" label={label} />
</div>
);
/** Inline loading indicator */
export const InlineLoading: React.FC<{ label?: string }> = ({ label }) => (
<span className="inline-flex items-center gap-2">
<LoadingSpinner size="sm" />
{label && <span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>}
</span>
);
export default LoadingSpinner;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string | React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: ModalSize;
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
className?: string;
contentClassName?: string;
/** If true, prevents body scroll when modal is open */
preventScroll?: boolean;
}
const sizeClasses: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
full: 'max-w-full mx-4',
};
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
footer,
size = 'md',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
className = '',
contentClassName = '',
preventScroll = true,
}) => {
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (closeOnEscape && e.key === 'Escape') {
onClose();
}
},
[closeOnEscape, onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
if (preventScroll) {
document.body.style.overflow = 'hidden';
}
}
return () => {
document.removeEventListener('keydown', handleEscape);
if (preventScroll) {
document.body.style.overflow = '';
}
};
}, [isOpen, handleEscape, preventScroll]);
if (!isOpen) return null;
const handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
};
const modalContent = (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm"
onClick={handleOverlayClick}
>
<div
className={`bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col ${className}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ml-auto"
aria-label="Close modal"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
)}
</div>
)}
{/* Content */}
<div className={`flex-1 overflow-y-auto p-6 ${contentClassName}`}>
{children}
</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
{footer}
</div>
)}
</div>
</div>
);
// Use portal to render modal at document body level
return createPortal(modalContent, document.body);
};
export default Modal;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
interface ModalFooterProps {
onCancel?: () => void;
onSubmit?: () => void;
onBack?: () => void;
submitText?: string;
cancelText?: string;
backText?: string;
isLoading?: boolean;
isDisabled?: boolean;
showBackButton?: boolean;
submitVariant?: ButtonVariant;
/** Custom content to render instead of default buttons */
children?: React.ReactNode;
/** Additional class names */
className?: string;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
danger: 'bg-red-600 hover:bg-red-700 text-white',
success: 'bg-green-600 hover:bg-green-700 text-white',
warning: 'bg-amber-600 hover:bg-amber-700 text-white',
};
export const ModalFooter: React.FC<ModalFooterProps> = ({
onCancel,
onSubmit,
onBack,
submitText = 'Save',
cancelText = 'Cancel',
backText = 'Back',
isLoading = false,
isDisabled = false,
showBackButton = false,
submitVariant = 'primary',
children,
className = '',
}) => {
if (children) {
return <div className={`flex items-center gap-3 ${className}`}>{children}</div>;
}
return (
<div className={`flex items-center gap-3 ${className}`}>
{showBackButton && onBack && (
<button
onClick={onBack}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
{backText}
</button>
)}
<div className="flex-1" />
{onCancel && (
<button
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{cancelText}
</button>
)}
{onSubmit && (
<button
onClick={onSubmit}
disabled={isLoading || isDisabled}
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${variantClasses[submitVariant]}`}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{submitText}
</button>
)}
</div>
);
};
export default ModalFooter;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Check } from 'lucide-react';
interface Step {
id: string | number;
label: string;
description?: string;
}
interface StepIndicatorProps {
steps: Step[];
currentStep: number;
/** Color for completed/active steps */
color?: 'blue' | 'brand' | 'green' | 'purple';
/** Show connector lines between steps */
showConnectors?: boolean;
/** Additional class name */
className?: string;
}
const colorClasses = {
blue: {
active: 'bg-blue-600 text-white',
completed: 'bg-blue-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-blue-600 dark:text-blue-400',
textPending: 'text-gray-400',
connector: 'bg-blue-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
brand: {
active: 'bg-brand-600 text-white',
completed: 'bg-brand-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-brand-600 dark:text-brand-400',
textPending: 'text-gray-400',
connector: 'bg-brand-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
green: {
active: 'bg-green-600 text-white',
completed: 'bg-green-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-green-600 dark:text-green-400',
textPending: 'text-gray-400',
connector: 'bg-green-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
purple: {
active: 'bg-purple-600 text-white',
completed: 'bg-purple-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-purple-600 dark:text-purple-400',
textPending: 'text-gray-400',
connector: 'bg-purple-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
};
export const StepIndicator: React.FC<StepIndicatorProps> = ({
steps,
currentStep,
color = 'blue',
showConnectors = true,
className = '',
}) => {
const colors = colorClasses[color];
return (
<div className={`flex items-center justify-center ${className}`}>
{steps.map((step, index) => {
const stepNumber = index + 1;
const isCompleted = stepNumber < currentStep;
const isActive = stepNumber === currentStep;
const isPending = stepNumber > currentStep;
return (
<React.Fragment key={step.id}>
<div className="flex items-center gap-2">
{/* Step circle */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm transition-colors ${
isCompleted
? colors.completed
: isActive
? colors.active
: colors.pending
}`}
>
{isCompleted ? <Check size={16} /> : stepNumber}
</div>
{/* Step label */}
<span
className={`font-medium text-sm ${
isActive || isCompleted ? colors.textActive : colors.textPending
}`}
>
{step.label}
</span>
</div>
{/* Connector */}
{showConnectors && index < steps.length - 1 && (
<div
className={`w-16 h-0.5 mx-4 ${
stepNumber < currentStep ? colors.connector : colors.connectorPending
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,150 @@
import React from 'react';
interface Tab {
id: string;
label: string | React.ReactNode;
icon?: React.ReactNode;
disabled?: boolean;
}
interface TabGroupProps {
tabs: Tab[];
activeTab: string;
onChange: (tabId: string) => void;
/** Visual variant */
variant?: 'default' | 'pills' | 'underline';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Full width tabs */
fullWidth?: boolean;
/** Additional class name */
className?: string;
/** Color for active state */
activeColor?: 'blue' | 'purple' | 'green' | 'brand';
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
const activeColorClasses = {
blue: {
active: 'bg-blue-600 text-white',
pills: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
underline: 'border-blue-600 text-blue-600 dark:text-blue-400',
},
purple: {
active: 'bg-purple-600 text-white',
pills: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
underline: 'border-purple-600 text-purple-600 dark:text-purple-400',
},
green: {
active: 'bg-green-600 text-white',
pills: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
underline: 'border-green-600 text-green-600 dark:text-green-400',
},
brand: {
active: 'bg-brand-600 text-white',
pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300',
underline: 'border-brand-600 text-brand-600 dark:text-brand-400',
},
};
export const TabGroup: React.FC<TabGroupProps> = ({
tabs,
activeTab,
onChange,
variant = 'default',
size = 'md',
fullWidth = true,
className = '',
activeColor = 'blue',
}) => {
const colorClasses = activeColorClasses[activeColor];
if (variant === 'underline') {
return (
<div className={`flex border-b border-gray-200 dark:border-gray-700 ${className}`}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium border-b-2 -mb-px transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.underline
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
}
if (variant === 'pills') {
return (
<div className={`flex gap-2 ${className}`}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium rounded-full transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.pills
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
}
// Default variant - segmented control style
return (
<div
className={`flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.active
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
};
export default TabGroup;

View File

@@ -0,0 +1,28 @@
// Modal components
export { Modal, type ModalSize } from './Modal';
export { ModalFooter } from './ModalFooter';
// Form components
export { FormInput } from './FormInput';
export { FormSelect, type SelectOption } from './FormSelect';
export { FormTextarea } from './FormTextarea';
export { FormCurrencyInput } from './FormCurrencyInput';
export { default as CurrencyInput } from './CurrencyInput';
// Button components
export { Button, SubmitButton } from './Button';
// Alert/Message components
export { Alert, ErrorMessage, SuccessMessage, WarningMessage, InfoMessage } from './Alert';
// Navigation components
export { TabGroup } from './TabGroup';
export { StepIndicator } from './StepIndicator';
// Loading components
export { LoadingSpinner, PageLoading, InlineLoading } from './LoadingSpinner';
// Layout components
export { Card, CardHeader, CardBody, CardFooter } from './Card';
export { EmptyState } from './EmptyState';
export { Badge } from './Badge';