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

@@ -1,8 +1,16 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays, ChevronDown, ChevronUp } from 'lucide-react';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays } from 'lucide-react';
import toast from 'react-hot-toast';
import {
SCHEDULE_PRESETS,
TRIGGER_OPTIONS,
OFFSET_PRESETS,
getScheduleDescription,
getEventTimingDescription,
} from '../constants/schedulePresets';
import { ErrorMessage } from './ui';
interface PluginInstallation {
id: string;
@@ -14,11 +22,11 @@ interface PluginInstallation {
version: string;
author_name: string;
logo_url?: string;
template_variables: Record<string, any>;
template_variables: Record<string, unknown>;
scheduled_task?: number;
scheduled_task_name?: string;
installed_at: string;
config_values: Record<string, any>;
config_values: Record<string, unknown>;
has_update: boolean;
}
@@ -28,65 +36,6 @@ interface CreateTaskModalProps {
onSuccess: () => void;
}
// Schedule presets for visual selection
interface SchedulePreset {
id: string;
label: string;
description: string;
type: 'INTERVAL' | 'CRON';
interval_minutes?: number;
cron_expression?: string;
}
const SCHEDULE_PRESETS: SchedulePreset[] = [
// Interval-based
{ id: 'every_15min', label: 'Every 15 minutes', description: 'Runs 4 times per hour', type: 'INTERVAL', interval_minutes: 15 },
{ id: 'every_30min', label: 'Every 30 minutes', description: 'Runs twice per hour', type: 'INTERVAL', interval_minutes: 30 },
{ id: 'every_hour', label: 'Every hour', description: 'Runs 24 times per day', type: 'INTERVAL', interval_minutes: 60 },
{ id: 'every_2hours', label: 'Every 2 hours', description: 'Runs 12 times per day', type: 'INTERVAL', interval_minutes: 120 },
{ id: 'every_4hours', label: 'Every 4 hours', description: 'Runs 6 times per day', type: 'INTERVAL', interval_minutes: 240 },
{ id: 'every_6hours', label: 'Every 6 hours', description: 'Runs 4 times per day', type: 'INTERVAL', interval_minutes: 360 },
{ id: 'every_12hours', label: 'Twice daily', description: 'Runs at midnight and noon', type: 'INTERVAL', interval_minutes: 720 },
// Cron-based (specific times)
{ id: 'daily_midnight', label: 'Daily at midnight', description: 'Runs once per day at 12:00 AM', type: 'CRON', cron_expression: '0 0 * * *' },
{ id: 'daily_9am', label: 'Daily at 9 AM', description: 'Runs once per day at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * *' },
{ id: 'daily_6pm', label: 'Daily at 6 PM', description: 'Runs once per day at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * *' },
{ id: 'weekdays_9am', label: 'Weekdays at 9 AM', description: 'Mon-Fri at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1-5' },
{ id: 'weekdays_6pm', label: 'Weekdays at 6 PM', description: 'Mon-Fri at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * 1-5' },
{ id: 'weekly_sunday', label: 'Weekly on Sunday', description: 'Every Sunday at midnight', type: 'CRON', cron_expression: '0 0 * * 0' },
{ id: 'weekly_monday', label: 'Weekly on Monday', description: 'Every Monday at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1' },
{ id: 'monthly_1st', label: 'Monthly on the 1st', description: 'First day of each month', type: 'CRON', cron_expression: '0 0 1 * *' },
];
// Event trigger options (same as EventAutomations component)
interface TriggerOption {
value: string;
label: string;
}
interface OffsetPreset {
value: number;
label: string;
}
const TRIGGER_OPTIONS: TriggerOption[] = [
{ value: 'before_start', label: 'Before Start' },
{ value: 'at_start', label: 'At Start' },
{ value: 'after_start', label: 'After Start' },
{ value: 'after_end', label: 'After End' },
{ value: 'on_complete', label: 'When Completed' },
{ value: 'on_cancel', label: 'When Canceled' },
];
const OFFSET_PRESETS: OffsetPreset[] = [
{ value: 0, label: 'Immediately' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
// Task type: scheduled or event-based
type TaskType = 'scheduled' | 'event';
@@ -154,41 +103,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
setStep(2);
};
const getScheduleDescription = () => {
if (scheduleMode === 'onetime') {
if (runAtDate && runAtTime) {
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
}
return 'Select date and time';
}
if (scheduleMode === 'advanced') {
return `Custom: ${customCron}`;
}
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
return preset?.description || 'Select a schedule';
};
// Use shared helper functions from constants
const scheduleDescriptionText = getScheduleDescription(
scheduleMode,
selectedPreset,
runAtDate,
runAtTime,
customCron
);
const getEventTimingDescription = () => {
const trigger = TRIGGER_OPTIONS.find(t => t.value === selectedTrigger);
if (!trigger) return 'Select timing';
if (selectedTrigger === 'on_complete') return 'When event is completed';
if (selectedTrigger === 'on_cancel') return 'When event is canceled';
if (selectedOffset === 0) {
if (selectedTrigger === 'before_start') return 'At event start';
if (selectedTrigger === 'at_start') return 'At event start';
if (selectedTrigger === 'after_start') return 'At event start';
if (selectedTrigger === 'after_end') return 'At event end';
}
const offsetLabel = OFFSET_PRESETS.find(o => o.value === selectedOffset)?.label || `${selectedOffset} min`;
if (selectedTrigger === 'before_start') return `${offsetLabel} before event starts`;
if (selectedTrigger === 'at_start' || selectedTrigger === 'after_start') return `${offsetLabel} after event starts`;
if (selectedTrigger === 'after_end') return `${offsetLabel} after event ends`;
return trigger.label;
};
const eventTimingDescriptionText = getEventTimingDescription(selectedTrigger, selectedOffset);
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
@@ -543,7 +467,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-800 dark:text-green-200">
<strong>Schedule:</strong> {getScheduleDescription()}
<strong>Schedule:</strong> {scheduleDescriptionText}
</span>
</div>
</div>
@@ -657,7 +581,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm text-purple-800 dark:text-purple-200">
<strong>Runs:</strong> {getEventTimingDescription()}
<strong>Runs:</strong> {eventTimingDescriptionText}
</span>
</div>
</div>
@@ -665,11 +589,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
{error && <ErrorMessage message={error} />}
</div>
)}
</div>

View File

@@ -217,8 +217,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const drSmith = screen.getByText('Dr. Smith').closest('div');
const confRoom = screen.getByText('Conference Room A').closest('div');
// The height style is on the resource row container (3 levels up from the text)
const drSmith = screen.getByText('Dr. Smith').closest('[style*="height"]');
const confRoom = screen.getByText('Conference Room A').closest('[style*="height"]');
expect(drSmith).toHaveStyle({ height: '100px' });
expect(confRoom).toHaveStyle({ height: '120px' });
@@ -420,7 +421,8 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointment = screen.getByText('John Doe').closest('div');
// Navigate up to the draggable container which has the svg
const appointment = screen.getByText('John Doe').closest('.cursor-grab');
const svg = appointment?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
@@ -544,8 +546,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('cursor-grab');
// Use the specific class selector since .closest('div') returns the inner div
const appointmentCard = screen.getByText('John Doe').closest('.cursor-grab');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply active cursor-grabbing class to draggable items', () => {
@@ -558,8 +561,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('active:cursor-grabbing');
// Verify the draggable container has the active:cursor-grabbing class
const appointmentCard = screen.getByText('John Doe').closest('[class*="active:cursor-grabbing"]');
expect(appointmentCard).toBeInTheDocument();
});
it('should render pending items with orange left border', () => {
@@ -572,8 +576,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('border-l-orange-400');
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('.border-l-orange-400');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply shadow on hover for draggable items', () => {
@@ -586,8 +591,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('hover:shadow-md');
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]');
expect(appointmentCard).toBeInTheDocument();
});
});
@@ -649,7 +655,8 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const header = screen.getByText('Resources').parentElement;
// The height style is on the header div itself
const header = screen.getByText('Resources').closest('[style*="height"]');
expect(header).toHaveStyle({ height: '48px' });
});

View File

@@ -841,8 +841,17 @@ describe('ChartWidget', () => {
it('should support different color schemes', () => {
const colors = ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6'];
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={colors[0]}
/>
);
colors.forEach((color) => {
const { container, rerender } = render(
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
@@ -853,17 +862,6 @@ describe('ChartWidget', () => {
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', color);
if (color !== colors[colors.length - 1]) {
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={colors[colors.indexOf(color) + 1]}
/>
);
}
});
});

View File

@@ -139,7 +139,7 @@ describe('CodeBlock', () => {
expect(checkIcon).toBeInTheDocument();
});
it('reverts to copy icon after 2 seconds', () => {
it('reverts to copy icon after 2 seconds', async () => {
const code = 'test code';
mockWriteText.mockResolvedValue(undefined);
@@ -148,14 +148,18 @@ describe('CodeBlock', () => {
const copyButton = screen.getByRole('button', { name: /copy code/i });
// Click to copy
fireEvent.click(copyButton);
await act(async () => {
fireEvent.click(copyButton);
});
// Should show Check icon
let checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).toBeInTheDocument();
// Fast-forward 2 seconds using act to wrap state updates
vi.advanceTimersByTime(2000);
await act(async () => {
vi.advanceTimersByTime(2000);
});
// Should revert to Copy icon (check icon should be gone)
checkIcon = container.querySelector('.text-green-400');

View File

@@ -435,7 +435,9 @@ describe('Navbar', () => {
});
it('should close mobile menu on route change', () => {
// Test that mobile menu state resets when component receives new location
// Test that clicking a navigation link closes the mobile menu
// In production, clicking a link triggers a route change which closes the menu via useEffect
// In tests with MemoryRouter, the route change happens and the useEffect fires
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper('/'),
});
@@ -447,14 +449,12 @@ describe('Navbar', () => {
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-96');
// Click a navigation link (simulates route change behavior)
// Click a navigation link - this triggers navigation to /features
// The useEffect with location.pathname dependency should close the menu
const featuresLink = screen.getAllByRole('link', { name: 'Features' })[1]; // Mobile menu link
fireEvent.click(featuresLink);
// The useEffect with location.pathname dependency should close the menu
// In actual usage, clicking a link triggers navigation which changes location.pathname
// For this test, we verify the menu can be manually closed
fireEvent.click(menuButton);
// After navigation, menu should be closed
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-0');
});

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,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';