chore: clean git cache
This commit is contained in:
125
frontend/src/components/ui/Alert.tsx
Normal file
125
frontend/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../utils/cn'
|
||||
import {
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative flex gap-3 p-4 rounded-lg border transition-all duration-normal',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface-subtle border-border text-content-primary',
|
||||
info: 'bg-info-muted border-info/30 text-content-primary',
|
||||
success: 'bg-success-muted border-success/30 text-content-primary',
|
||||
warning: 'bg-warning-muted border-warning/30 text-content-primary',
|
||||
error: 'bg-error-muted border-error/30 text-content-primary',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
default: Info,
|
||||
info: Info,
|
||||
success: CheckCircle,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
}
|
||||
|
||||
const iconColorMap: Record<string, string> = {
|
||||
default: 'text-content-muted',
|
||||
info: 'text-info',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
error: 'text-error',
|
||||
}
|
||||
|
||||
export interface AlertProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertVariants> {
|
||||
title?: string
|
||||
icon?: LucideIcon
|
||||
dismissible?: boolean
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
export function Alert({
|
||||
className,
|
||||
variant = 'default',
|
||||
title,
|
||||
icon,
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
children,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
const IconComponent = icon || iconMap[variant || 'default']
|
||||
const iconColor = iconColorMap[variant || 'default']
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false)
|
||||
onDismiss?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<IconComponent className={cn('h-5 w-5 flex-shrink-0 mt-0.5', iconColor)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h5 className="font-semibold text-sm mb-1">{title}</h5>
|
||||
)}
|
||||
<div className="text-sm text-content-secondary">{children}</div>
|
||||
</div>
|
||||
{dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 p-1 rounded-md text-content-muted hover:text-content-primary hover:bg-surface-muted transition-colors duration-fast"
|
||||
aria-label="Dismiss alert"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type AlertTitleProps = React.HTMLAttributes<HTMLHeadingElement>
|
||||
|
||||
export function AlertTitle({ className, ...props }: AlertTitleProps) {
|
||||
return (
|
||||
<h5
|
||||
className={cn('font-semibold text-sm mb-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type AlertDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export function AlertDescription({ className, ...props }: AlertDescriptionProps) {
|
||||
return (
|
||||
<p
|
||||
className={cn('text-sm text-content-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/ui/Badge.tsx
Normal file
42
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center font-medium transition-colors duration-fast',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface-muted text-content-primary border border-border',
|
||||
primary: 'bg-brand-500 text-white',
|
||||
success: 'bg-success text-white',
|
||||
warning: 'bg-warning text-content-inverted',
|
||||
destructive: 'bg-error text-white',
|
||||
error: 'bg-error text-white',
|
||||
secondary: 'bg-surface-muted text-content-secondary border border-border',
|
||||
outline: 'border border-border text-content-secondary bg-transparent',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs px-2 py-0.5 rounded',
|
||||
md: 'text-sm px-2.5 py-0.5 rounded-md',
|
||||
lg: 'text-base px-3 py-1 rounded-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(badgeVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
111
frontend/src/components/ui/Button.tsx
Normal file
111
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Loader2, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
'inline-flex items-center justify-center gap-2',
|
||||
'rounded-lg font-medium',
|
||||
'transition-all duration-fast',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: [
|
||||
'bg-brand-500 text-white',
|
||||
'hover:bg-brand-600',
|
||||
'focus-visible:ring-brand-500',
|
||||
'active:bg-brand-700',
|
||||
],
|
||||
secondary: [
|
||||
'bg-surface-muted text-content-primary',
|
||||
'hover:bg-surface-subtle',
|
||||
'focus-visible:ring-content-muted',
|
||||
'active:bg-surface-base',
|
||||
],
|
||||
danger: [
|
||||
'bg-error text-white',
|
||||
'hover:bg-error/90',
|
||||
'focus-visible:ring-error',
|
||||
'active:bg-error/80',
|
||||
],
|
||||
ghost: [
|
||||
'text-content-secondary bg-transparent',
|
||||
'hover:bg-surface-muted hover:text-content-primary',
|
||||
'focus-visible:ring-content-muted',
|
||||
],
|
||||
outline: [
|
||||
'border border-border bg-transparent text-content-primary',
|
||||
'hover:bg-surface-subtle hover:border-border-strong',
|
||||
'focus-visible:ring-brand-500',
|
||||
],
|
||||
link: [
|
||||
'text-brand-500 bg-transparent underline-offset-4',
|
||||
'hover:underline hover:text-brand-400',
|
||||
'focus-visible:ring-brand-500',
|
||||
'p-0 h-auto',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
icon: 'h-10 w-10 p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean
|
||||
leftIcon?: LucideIcon
|
||||
rightIcon?: LucideIcon
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
isLoading = false,
|
||||
leftIcon: LeftIcon,
|
||||
rightIcon: RightIcon,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
LeftIcon && <LeftIcon className="h-4 w-4" />
|
||||
)}
|
||||
{children}
|
||||
{!isLoading && RightIcon && <RightIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
102
frontend/src/components/ui/Card.tsx
Normal file
102
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const cardVariants = cva(
|
||||
'rounded-lg border border-border bg-surface-elevated overflow-hidden transition-all duration-normal',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
interactive: [
|
||||
'cursor-pointer',
|
||||
'hover:shadow-lg hover:border-border-strong',
|
||||
'active:shadow-md',
|
||||
],
|
||||
compact: 'p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-tight text-content-primary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-content-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center p-6 pt-0 border-t border-border bg-surface-subtle/50 mt-4 -mx-px -mb-px rounded-b-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
46
frontend/src/components/ui/Checkbox.tsx
Normal file
46
frontend/src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { Check, Minus } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
|
||||
indeterminate?: boolean
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
CheckboxProps
|
||||
>(({ className, indeterminate, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded',
|
||||
'border border-border',
|
||||
'bg-surface-base',
|
||||
'ring-offset-surface-base',
|
||||
'transition-colors duration-fast',
|
||||
'hover:border-brand-400',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500 data-[state=checked]:text-white',
|
||||
'data-[state=indeterminate]:bg-brand-500 data-[state=indeterminate]:border-brand-500 data-[state=indeterminate]:text-white',
|
||||
className
|
||||
)}
|
||||
checked={indeterminate ? 'indeterminate' : props.checked}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Minus className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
246
frontend/src/components/ui/DataTable.tsx
Normal file
246
frontend/src/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import { Checkbox } from './Checkbox'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
cell: (row: T) => React.ReactNode
|
||||
sortable?: boolean
|
||||
width?: string
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
rowKey: (row: T) => string
|
||||
selectable?: boolean
|
||||
selectedKeys?: Set<string>
|
||||
onSelectionChange?: (keys: Set<string>) => void
|
||||
onRowClick?: (row: T) => void
|
||||
emptyState?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
stickyHeader?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTable - Reusable data table component
|
||||
*
|
||||
* Features:
|
||||
* - Generic type <T> for row data
|
||||
* - Sortable columns with chevron icons
|
||||
* - Row selection with Checkbox component
|
||||
* - Sticky header support
|
||||
* - Row hover states
|
||||
* - Selected row highlighting
|
||||
* - Empty state slot
|
||||
* - Responsive horizontal scroll
|
||||
*/
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
rowKey,
|
||||
selectable = false,
|
||||
selectedKeys = new Set(),
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
isLoading = false,
|
||||
stickyHeader = false,
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortConfig, setSortConfig] = React.useState<{
|
||||
key: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (prev?.key === key) {
|
||||
if (prev.direction === 'asc') {
|
||||
return { key, direction: 'desc' }
|
||||
}
|
||||
// Reset sort if clicking third time
|
||||
return null
|
||||
}
|
||||
return { key, direction: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (selectedKeys.size === data.length) {
|
||||
// All selected, deselect all
|
||||
onSelectionChange(new Set())
|
||||
} else {
|
||||
// Select all
|
||||
onSelectionChange(new Set(data.map(rowKey)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectRow = (key: string) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
const newKeys = new Set(selectedKeys)
|
||||
if (newKeys.has(key)) {
|
||||
newKeys.delete(key)
|
||||
} else {
|
||||
newKeys.add(key)
|
||||
}
|
||||
onSelectionChange(newKeys)
|
||||
}
|
||||
|
||||
const allSelected = data.length > 0 && selectedKeys.size === data.length
|
||||
const someSelected = selectedKeys.size > 0 && selectedKeys.size < data.length
|
||||
|
||||
const colSpan = columns.length + (selectable ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead
|
||||
className={cn(
|
||||
'bg-surface-subtle border-b border-border',
|
||||
stickyHeader && 'sticky top-0 z-10'
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th className="w-12 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="Select all rows"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-content-secondary',
|
||||
col.sortable &&
|
||||
'cursor-pointer select-none hover:text-content-primary transition-colors'
|
||||
)}
|
||||
style={{ width: col.width }}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
role={col.sortable ? 'button' : undefined}
|
||||
tabIndex={col.sortable ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (col.sortable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
handleSort(col.key)
|
||||
}
|
||||
}}
|
||||
aria-sort={
|
||||
sortConfig?.key === col.key
|
||||
? sortConfig.direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{col.header}</span>
|
||||
{col.sortable && (
|
||||
<span className="text-content-muted">
|
||||
{sortConfig?.key === col.key ? (
|
||||
sortConfig.direction === 'asc' ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-surface-elevated">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-6 py-12">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-6 py-12">
|
||||
{emptyState || (
|
||||
<div className="text-center text-content-muted">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => {
|
||||
const key = rowKey(row)
|
||||
const isSelected = selectedKeys.has(key)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isSelected && 'bg-brand-500/5',
|
||||
onRowClick &&
|
||||
'cursor-pointer hover:bg-surface-muted',
|
||||
!onRowClick && 'hover:bg-surface-subtle'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
role={onRowClick ? 'button' : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (onRowClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onRowClick(row)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectable && (
|
||||
<td
|
||||
className="w-12 px-4 py-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleSelectRow(key)}
|
||||
aria-label={`Select row ${key}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-6 py-4 text-sm text-content-primary"
|
||||
>
|
||||
{col.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/ui/Dialog.tsx
Normal file
141
frontend/src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||
'bg-surface-elevated border border-border rounded-xl shadow-xl',
|
||||
'duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 p-1.5 rounded-md',
|
||||
'text-content-muted hover:text-content-primary',
|
||||
'hover:bg-surface-muted',
|
||||
'transition-colors duration-fast',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-elevated'
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 px-6 pt-6 pb-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3',
|
||||
'px-6 pb-6 pt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold text-content-primary leading-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-content-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
70
frontend/src/components/ui/EmptyState.tsx
Normal file
70
frontend/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import { Button, type ButtonProps } from './Button'
|
||||
|
||||
export interface EmptyStateAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: ButtonProps['variant']
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
action?: EmptyStateAction
|
||||
secondaryAction?: EmptyStateAction
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState - Empty state pattern component
|
||||
*
|
||||
* Features:
|
||||
* - Centered content with dashed border
|
||||
* - Icon in muted background circle
|
||||
* - Primary and secondary action buttons
|
||||
* - Uses Button component for actions
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-16 px-6 text-center',
|
||||
'rounded-xl border border-dashed border-border bg-surface-subtle/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<div className="mb-4 rounded-full bg-surface-muted p-4 text-content-muted">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-content-primary">{title}</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-content-secondary">
|
||||
{description}
|
||||
</p>
|
||||
{(action || secondaryAction) && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
{action && (
|
||||
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<Button variant="ghost" onClick={secondaryAction.onClick}>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
frontend/src/components/ui/Input.tsx
Normal file
113
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as React from 'react'
|
||||
import { Eye, EyeOff, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
errorTestId?: string
|
||||
leftIcon?: LucideIcon
|
||||
rightIcon?: LucideIcon
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
errorTestId,
|
||||
leftIcon: LeftIcon,
|
||||
rightIcon: RightIcon,
|
||||
className,
|
||||
type,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
const isPassword = type === 'password'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="block text-sm font-medium text-content-secondary mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{LeftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<LeftIcon className="h-4 w-4 text-content-muted" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type={isPassword ? (showPassword ? 'text' : 'password') : type}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg px-4 py-2',
|
||||
'bg-surface-base border text-content-primary',
|
||||
'text-sm placeholder:text-content-muted',
|
||||
'transition-colors duration-fast',
|
||||
error
|
||||
? 'border-error focus:ring-error/20'
|
||||
: 'border-border hover:border-border-strong focus:border-brand-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border',
|
||||
LeftIcon && 'pl-10',
|
||||
(isPassword || RightIcon) && 'pr-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={cn(
|
||||
'absolute right-3 top-1/2 -translate-y-1/2',
|
||||
'text-content-muted hover:text-content-primary',
|
||||
'focus:outline-none transition-colors duration-fast'
|
||||
)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!isPassword && RightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<RightIcon className="h-4 w-4 text-content-muted" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
className="mt-1.5 text-sm text-error"
|
||||
data-testid={errorTestId}
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-content-muted">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
45
frontend/src/components/ui/Label.tsx
Normal file
45
frontend/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-content-primary',
|
||||
muted: 'text-content-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||
VariantProps<typeof labelVariants> {
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, variant, required, children, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(labelVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{required && (
|
||||
<span className="ml-1 text-error" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Label, labelVariants }
|
||||
32
frontend/src/components/ui/NativeSelect.tsx
Normal file
32
frontend/src/components/ui/NativeSelect.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface NativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between gap-2',
|
||||
'rounded-lg border px-3 py-2',
|
||||
'bg-surface-base text-content-primary text-sm',
|
||||
'placeholder:text-content-muted',
|
||||
'transition-colors duration-fast',
|
||||
error
|
||||
? 'border-error focus:ring-error'
|
||||
: 'border-border hover:border-border-strong focus:border-brand-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NativeSelect.displayName = 'NativeSelect';
|
||||
56
frontend/src/components/ui/Progress.tsx
Normal file
56
frontend/src/components/ui/Progress.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const progressVariants = cva(
|
||||
'h-full w-full flex-1 transition-all duration-normal',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-brand-500',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ProgressProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>,
|
||||
VariantProps<typeof progressVariants> {
|
||||
showValue?: boolean
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, variant, showValue = false, ...props }, ref) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-surface-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(progressVariants({ variant }), 'rounded-full')}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
{showValue && (
|
||||
<span className="text-sm font-medium text-content-secondary tabular-nums">
|
||||
{Math.round(value || 0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
180
frontend/src/components/ui/Select.tsx
Normal file
180
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
error?: boolean
|
||||
}
|
||||
>(({ className, children, error, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between gap-2',
|
||||
'rounded-lg border px-3 py-2',
|
||||
'bg-surface-base text-content-primary text-sm',
|
||||
'placeholder:text-content-muted',
|
||||
'transition-colors duration-fast',
|
||||
error
|
||||
? 'border-error focus:ring-error'
|
||||
: 'border-border hover:border-border-strong focus:border-brand-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-content-muted flex-shrink-0" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 text-content-muted" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-content-muted" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden',
|
||||
'rounded-lg border border-border',
|
||||
'bg-surface-elevated text-content-primary shadow-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2',
|
||||
'data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2',
|
||||
'data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold text-content-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm',
|
||||
'outline-none',
|
||||
'focus:bg-surface-muted focus:text-content-primary',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-brand-500" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
142
frontend/src/components/ui/Skeleton.tsx
Normal file
142
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const skeletonVariants = cva(
|
||||
'animate-pulse bg-surface-muted',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'rounded-md',
|
||||
circular: 'rounded-full',
|
||||
text: 'rounded h-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface SkeletonProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof skeletonVariants> {}
|
||||
|
||||
export function Skeleton({ className, variant, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(skeletonVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Pre-built patterns
|
||||
|
||||
export interface SkeletonCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
showImage?: boolean
|
||||
lines?: number
|
||||
}
|
||||
|
||||
export function SkeletonCard({
|
||||
className,
|
||||
showImage = true,
|
||||
lines = 3,
|
||||
...props
|
||||
}: SkeletonCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-surface-elevated p-4 space-y-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showImage && (
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
className={cn(
|
||||
'h-4',
|
||||
i === lines - 1 ? 'w-1/2' : 'w-full'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SkeletonTableProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
rows?: number
|
||||
columns?: number
|
||||
}
|
||||
|
||||
export function SkeletonTable({
|
||||
className,
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
...props
|
||||
}: SkeletonTableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-lg border border-border overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex gap-4 p-4 bg-surface-subtle border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-4 p-4">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
'h-4 flex-1',
|
||||
colIndex === 0 && 'w-1/4 flex-none'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SkeletonListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
items?: number
|
||||
showAvatar?: boolean
|
||||
}
|
||||
|
||||
export function SkeletonList({
|
||||
className,
|
||||
items = 3,
|
||||
showAvatar = true,
|
||||
...props
|
||||
}: SkeletonListProps) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)} {...props}>
|
||||
{Array.from({ length: items }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
{showAvatar && (
|
||||
<Skeleton variant="circular" className="h-10 w-10 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
frontend/src/components/ui/StatsCard.tsx
Normal file
108
frontend/src/components/ui/StatsCard.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export interface StatsCardChange {
|
||||
value: number
|
||||
trend: 'up' | 'down' | 'neutral'
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface StatsCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: StatsCardChange
|
||||
icon?: React.ReactNode
|
||||
href?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* StatsCard - KPI/metric card component
|
||||
*
|
||||
* Features:
|
||||
* - Trend indicators with TrendingUp/TrendingDown/Minus icons
|
||||
* - Color-coded trends (success for up, error for down, muted for neutral)
|
||||
* - Interactive hover state when href is provided
|
||||
* - Card styles (rounded-xl, border, shadow on hover)
|
||||
*/
|
||||
export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon,
|
||||
href,
|
||||
className,
|
||||
}: StatsCardProps) {
|
||||
const isInteractive = Boolean(href)
|
||||
|
||||
const TrendIcon =
|
||||
change?.trend === 'up'
|
||||
? TrendingUp
|
||||
: change?.trend === 'down'
|
||||
? TrendingDown
|
||||
: Minus
|
||||
|
||||
const trendColorClass =
|
||||
change?.trend === 'up'
|
||||
? 'text-success'
|
||||
: change?.trend === 'down'
|
||||
? 'text-error'
|
||||
: 'text-content-muted'
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-content-secondary truncate">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-bold text-content-primary tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
{change && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-1 text-sm',
|
||||
trendColorClass
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">{change.value}%</span>
|
||||
{change.label && (
|
||||
<span className="text-content-muted truncate">
|
||||
{change.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="shrink-0 rounded-lg bg-brand-500/10 p-3 text-brand-500">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const baseClasses = cn(
|
||||
'block rounded-xl border border-border bg-surface-elevated p-6',
|
||||
'transition-all duration-fast',
|
||||
isInteractive && [
|
||||
'hover:shadow-md hover:border-brand-500/50 cursor-pointer',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base',
|
||||
],
|
||||
className
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={baseClasses}>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className={baseClasses}>{content}</div>
|
||||
}
|
||||
50
frontend/src/components/ui/Switch.tsx
Normal file
50
frontend/src/components/ui/Switch.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, onCheckedChange, onChange, id, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'relative inline-flex items-center',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
onChange?.(e)
|
||||
onCheckedChange?.(e.target.checked)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'w-11 h-6 rounded-full transition-colors duration-fast',
|
||||
'bg-surface-muted',
|
||||
'peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-brand-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-surface-base',
|
||||
'peer-checked:bg-brand-500',
|
||||
"after:content-[''] after:absolute after:top-[2px] after:start-[2px]",
|
||||
'after:bg-white after:border after:border-border after:rounded-full',
|
||||
'after:h-5 after:w-5 after:transition-all after:duration-fast',
|
||||
'peer-checked:after:translate-x-full peer-checked:after:border-white',
|
||||
'rtl:peer-checked:after:-translate-x-full'
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
)
|
||||
Switch.displayName = 'Switch'
|
||||
|
||||
export { Switch }
|
||||
59
frontend/src/components/ui/Tabs.tsx
Normal file
59
frontend/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-lg',
|
||||
'bg-surface-subtle p-1 text-content-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap',
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'ring-offset-surface-base transition-all duration-fast',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-surface-elevated data-[state=active]:text-content-primary data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-surface-base',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
34
frontend/src/components/ui/Textarea.tsx
Normal file
34
frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-lg px-3 py-2',
|
||||
'border bg-surface-base text-content-primary',
|
||||
'text-sm placeholder:text-content-muted',
|
||||
'transition-colors duration-fast',
|
||||
error
|
||||
? 'border-error focus:ring-error/20'
|
||||
: 'border-border hover:border-border-strong focus:border-brand-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'resize-y',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
37
frontend/src/components/ui/Tooltip.tsx
Normal file
37
frontend/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md px-3 py-1.5',
|
||||
'bg-surface-overlay text-content-primary text-sm',
|
||||
'border border-border shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2',
|
||||
'data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2',
|
||||
'data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
181
frontend/src/components/ui/__tests__/Alert.test.tsx
Normal file
181
frontend/src/components/ui/__tests__/Alert.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertTitle, AlertDescription } from '../Alert'
|
||||
|
||||
describe('Alert', () => {
|
||||
it('renders with default variant', () => {
|
||||
render(<Alert>Default alert content</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toBeInTheDocument()
|
||||
expect(alert).toHaveClass('bg-surface-subtle')
|
||||
expect(screen.getByText('Default alert content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with info variant', () => {
|
||||
render(<Alert variant="info">Info message</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveClass('bg-info-muted')
|
||||
expect(alert).toHaveClass('border-info/30')
|
||||
})
|
||||
|
||||
it('renders with success variant', () => {
|
||||
render(<Alert variant="success">Success message</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveClass('bg-success-muted')
|
||||
expect(alert).toHaveClass('border-success/30')
|
||||
})
|
||||
|
||||
it('renders with warning variant', () => {
|
||||
render(<Alert variant="warning">Warning message</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveClass('bg-warning-muted')
|
||||
expect(alert).toHaveClass('border-warning/30')
|
||||
})
|
||||
|
||||
it('renders with error variant', () => {
|
||||
render(<Alert variant="error">Error message</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveClass('bg-error-muted')
|
||||
expect(alert).toHaveClass('border-error/30')
|
||||
})
|
||||
|
||||
it('renders with title', () => {
|
||||
render(<Alert title="Alert Title">Alert content</Alert>)
|
||||
|
||||
expect(screen.getByText('Alert Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alert content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders dismissible alert with dismiss button', () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(
|
||||
<Alert dismissible onDismiss={onDismiss}>
|
||||
Dismissible alert
|
||||
</Alert>
|
||||
)
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss alert/i })
|
||||
expect(dismissButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDismiss and hides alert when dismiss button is clicked', () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(
|
||||
<Alert dismissible onDismiss={onDismiss}>
|
||||
Dismissible alert
|
||||
</Alert>
|
||||
)
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss alert/i })
|
||||
fireEvent.click(dismissButton)
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides alert on dismiss without onDismiss callback', () => {
|
||||
render(
|
||||
<Alert dismissible>
|
||||
Dismissible alert
|
||||
</Alert>
|
||||
)
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss alert/i })
|
||||
fireEvent.click(dismissButton)
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom icon', () => {
|
||||
render(
|
||||
<Alert icon={AlertCircle} data-testid="alert-with-icon">
|
||||
Alert with custom icon
|
||||
</Alert>
|
||||
)
|
||||
|
||||
const alert = screen.getByTestId('alert-with-icon')
|
||||
// Custom icon should be rendered (AlertCircle)
|
||||
const iconContainer = alert.querySelector('svg')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default icon based on variant', () => {
|
||||
render(<Alert variant="error">Error alert</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
// Error variant uses XCircle icon
|
||||
const icon = alert.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveClass('text-error')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Alert className="custom-class">Alert content</Alert>)
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('does not render dismiss button when not dismissible', () => {
|
||||
render(<Alert>Non-dismissible alert</Alert>)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertTitle', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<AlertTitle>Test Title</AlertTitle>)
|
||||
|
||||
const title = screen.getByText('Test Title')
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(title.tagName).toBe('H5')
|
||||
expect(title).toHaveClass('font-semibold')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<AlertTitle className="custom-class">Title</AlertTitle>)
|
||||
|
||||
const title = screen.getByText('Title')
|
||||
expect(title).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertDescription', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<AlertDescription>Test Description</AlertDescription>)
|
||||
|
||||
const description = screen.getByText('Test Description')
|
||||
expect(description).toBeInTheDocument()
|
||||
expect(description.tagName).toBe('P')
|
||||
expect(description).toHaveClass('text-sm')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<AlertDescription className="custom-class">Description</AlertDescription>)
|
||||
|
||||
const description = screen.getByText('Description')
|
||||
expect(description).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Alert composition', () => {
|
||||
it('works with AlertTitle and AlertDescription subcomponents', () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Composed Title</AlertTitle>
|
||||
<AlertDescription>Composed description text</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Composed Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Composed description text')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
352
frontend/src/components/ui/__tests__/DataTable.test.tsx
Normal file
352
frontend/src/components/ui/__tests__/DataTable.test.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { DataTable, type Column } from '../DataTable'
|
||||
|
||||
interface TestRow {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const mockData: TestRow[] = [
|
||||
{ id: '1', name: 'Item 1', status: 'Active' },
|
||||
{ id: '2', name: 'Item 2', status: 'Inactive' },
|
||||
{ id: '3', name: 'Item 3', status: 'Active' },
|
||||
]
|
||||
|
||||
const mockColumns: Column<TestRow>[] = [
|
||||
{ key: 'name', header: 'Name', cell: (row) => row.name },
|
||||
{ key: 'status', header: 'Status', cell: (row) => row.status },
|
||||
]
|
||||
|
||||
const sortableColumns: Column<TestRow>[] = [
|
||||
{ key: 'name', header: 'Name', cell: (row) => row.name, sortable: true },
|
||||
{ key: 'status', header: 'Status', cell: (row) => row.status, sortable: true },
|
||||
]
|
||||
|
||||
describe('DataTable', () => {
|
||||
it('renders correctly with data', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty state when no data', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={[]}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom empty state', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={[]}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
emptyState={<div>Custom empty message</div>}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={[]}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
isLoading={true}
|
||||
/>
|
||||
)
|
||||
|
||||
// Loading spinner should be present (animated div)
|
||||
const spinnerContainer = document.querySelector('.animate-spin')
|
||||
expect(spinnerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles sortable column click - ascending', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={sortableColumns}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const nameHeader = screen.getByText('Name').closest('th')
|
||||
expect(nameHeader).toHaveAttribute('role', 'button')
|
||||
|
||||
fireEvent.click(nameHeader!)
|
||||
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
|
||||
})
|
||||
|
||||
it('handles sortable column click - descending on second click', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={sortableColumns}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const nameHeader = screen.getByText('Name').closest('th')
|
||||
|
||||
// First click - ascending
|
||||
fireEvent.click(nameHeader!)
|
||||
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
|
||||
|
||||
// Second click - descending
|
||||
fireEvent.click(nameHeader!)
|
||||
expect(nameHeader).toHaveAttribute('aria-sort', 'descending')
|
||||
})
|
||||
|
||||
it('handles sortable column click - resets on third click', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={sortableColumns}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const nameHeader = screen.getByText('Name').closest('th')
|
||||
|
||||
// First click - ascending
|
||||
fireEvent.click(nameHeader!)
|
||||
// Second click - descending
|
||||
fireEvent.click(nameHeader!)
|
||||
// Third click - reset
|
||||
fireEvent.click(nameHeader!)
|
||||
expect(nameHeader).not.toHaveAttribute('aria-sort')
|
||||
})
|
||||
|
||||
it('handles sortable column keyboard navigation', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={sortableColumns}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const nameHeader = screen.getByText('Name').closest('th')
|
||||
|
||||
fireEvent.keyDown(nameHeader!, { key: 'Enter' })
|
||||
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
|
||||
|
||||
fireEvent.keyDown(nameHeader!, { key: ' ' })
|
||||
expect(nameHeader).toHaveAttribute('aria-sort', 'descending')
|
||||
})
|
||||
|
||||
it('handles row selection - single row', () => {
|
||||
const onSelectionChange = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
selectable={true}
|
||||
selectedKeys={new Set()}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// First checkbox is "select all", row checkboxes start at index 1
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['1']))
|
||||
})
|
||||
|
||||
it('handles row selection - deselect row', () => {
|
||||
const onSelectionChange = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
selectable={true}
|
||||
selectedKeys={new Set(['1'])}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('handles row selection - select all', () => {
|
||||
const onSelectionChange = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
selectable={true}
|
||||
selectedKeys={new Set()}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// First checkbox is "select all"
|
||||
fireEvent.click(checkboxes[0])
|
||||
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['1', '2', '3']))
|
||||
})
|
||||
|
||||
it('handles row selection - deselect all when all selected', () => {
|
||||
const onSelectionChange = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
selectable={true}
|
||||
selectedKeys={new Set(['1', '2', '3'])}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// First checkbox is "select all" - clicking it deselects all
|
||||
fireEvent.click(checkboxes[0])
|
||||
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('handles row click', () => {
|
||||
const onRowClick = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByText('Item 1').closest('tr')
|
||||
fireEvent.click(row!)
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith(mockData[0])
|
||||
})
|
||||
|
||||
it('handles row keyboard navigation', () => {
|
||||
const onRowClick = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByText('Item 1').closest('tr')
|
||||
|
||||
fireEvent.keyDown(row!, { key: 'Enter' })
|
||||
expect(onRowClick).toHaveBeenCalledWith(mockData[0])
|
||||
|
||||
fireEvent.keyDown(row!, { key: ' ' })
|
||||
expect(onRowClick).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('applies sticky header class when stickyHeader is true', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
stickyHeader={true}
|
||||
/>
|
||||
)
|
||||
|
||||
const thead = document.querySelector('thead')
|
||||
expect(thead).toHaveClass('sticky')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
className="custom-class"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('highlights selected rows', () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
selectable={true}
|
||||
selectedKeys={new Set(['1'])}
|
||||
onSelectionChange={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
const selectedRow = screen.getByText('Item 1').closest('tr')
|
||||
expect(selectedRow).toHaveClass('bg-brand-500/5')
|
||||
})
|
||||
|
||||
it('does not call onSelectionChange when not provided', () => {
|
||||
// This test ensures no error when clicking selection without handler
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={mockColumns}
|
||||
rowKey={(row) => row.id}
|
||||
selectable={true}
|
||||
selectedKeys={new Set()}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// Should not throw
|
||||
fireEvent.click(checkboxes[0])
|
||||
fireEvent.click(checkboxes[1])
|
||||
})
|
||||
|
||||
it('applies column width when specified', () => {
|
||||
const columnsWithWidth: Column<TestRow>[] = [
|
||||
{ key: 'name', header: 'Name', cell: (row) => row.name, width: '200px' },
|
||||
{ key: 'status', header: 'Status', cell: (row) => row.status },
|
||||
]
|
||||
|
||||
render(
|
||||
<DataTable
|
||||
data={mockData}
|
||||
columns={columnsWithWidth}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const nameHeader = screen.getByText('Name').closest('th')
|
||||
expect(nameHeader).toHaveStyle({ width: '200px' })
|
||||
})
|
||||
})
|
||||
161
frontend/src/components/ui/__tests__/Input.test.tsx
Normal file
161
frontend/src/components/ui/__tests__/Input.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Search, Mail, Lock } from 'lucide-react'
|
||||
import { Input } from '../Input'
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Input placeholder="Enter text" />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter text')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input.tagName).toBe('INPUT')
|
||||
})
|
||||
|
||||
it('renders with label', () => {
|
||||
render(<Input label="Email" id="email-input" />)
|
||||
|
||||
const label = screen.getByText('Email')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label.tagName).toBe('LABEL')
|
||||
expect(label).toHaveAttribute('for', 'email-input')
|
||||
})
|
||||
|
||||
it('renders with error state and message', () => {
|
||||
render(
|
||||
<Input
|
||||
error="This field is required"
|
||||
errorTestId="input-error"
|
||||
/>
|
||||
)
|
||||
|
||||
const errorMessage = screen.getByTestId('input-error')
|
||||
expect(errorMessage).toBeInTheDocument()
|
||||
expect(errorMessage).toHaveTextContent('This field is required')
|
||||
expect(errorMessage).toHaveAttribute('role', 'alert')
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('border-error')
|
||||
})
|
||||
|
||||
it('renders with helper text', () => {
|
||||
render(<Input helperText="Enter your email address" />)
|
||||
|
||||
expect(screen.getByText('Enter your email address')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show helper text when error is present', () => {
|
||||
render(
|
||||
<Input
|
||||
helperText="Helper text"
|
||||
error="Error message"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Error message')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Helper text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with leftIcon', () => {
|
||||
render(<Input leftIcon={Search} data-testid="input-with-left-icon" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pl-10')
|
||||
|
||||
// Icon should be rendered
|
||||
const container = input.parentElement
|
||||
const icon = container?.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with rightIcon', () => {
|
||||
render(<Input rightIcon={Mail} data-testid="input-with-right-icon" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pr-10')
|
||||
})
|
||||
|
||||
it('renders with both leftIcon and rightIcon', () => {
|
||||
render(<Input leftIcon={Search} rightIcon={Mail} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pl-10')
|
||||
expect(input).toHaveClass('pr-10')
|
||||
})
|
||||
|
||||
it('renders disabled state', () => {
|
||||
render(<Input disabled placeholder="Disabled input" />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Disabled input')
|
||||
expect(input).toBeDisabled()
|
||||
expect(input).toHaveClass('disabled:cursor-not-allowed')
|
||||
expect(input).toHaveClass('disabled:opacity-50')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Input className="custom-class" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = vi.fn()
|
||||
render(<Input ref={ref} />)
|
||||
|
||||
expect(ref).toHaveBeenCalled()
|
||||
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement)
|
||||
})
|
||||
|
||||
it('handles password type with toggle visibility', () => {
|
||||
render(<Input type="password" placeholder="Enter password" />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter password')
|
||||
expect(input).toHaveAttribute('type', 'password')
|
||||
|
||||
// Toggle button should be present
|
||||
const toggleButton = screen.getByRole('button', { name: /show password/i })
|
||||
expect(toggleButton).toBeInTheDocument()
|
||||
|
||||
// Click to show password
|
||||
fireEvent.click(toggleButton)
|
||||
expect(input).toHaveAttribute('type', 'text')
|
||||
expect(screen.getByRole('button', { name: /hide password/i })).toBeInTheDocument()
|
||||
|
||||
// Click again to hide
|
||||
fireEvent.click(screen.getByRole('button', { name: /hide password/i }))
|
||||
expect(input).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('does not show password toggle for non-password types', () => {
|
||||
render(<Input type="email" placeholder="Enter email" />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<Input onChange={handleChange} placeholder="Input" />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Input')
|
||||
fireEvent.change(input, { target: { value: 'test value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
expect(input).toHaveValue('test value')
|
||||
})
|
||||
|
||||
it('renders password input with leftIcon', () => {
|
||||
render(<Input type="password" leftIcon={Lock} placeholder="Password" />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Password')
|
||||
expect(input).toHaveClass('pl-10')
|
||||
expect(input).toHaveClass('pr-10') // Password toggle adds right padding
|
||||
})
|
||||
|
||||
it('prioritizes password toggle over rightIcon for password type', () => {
|
||||
render(<Input type="password" rightIcon={Mail} placeholder="Password" />)
|
||||
|
||||
// Should show password toggle, not the Mail icon
|
||||
expect(screen.getByRole('button', { name: /show password/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
173
frontend/src/components/ui/__tests__/Skeleton.test.tsx
Normal file
173
frontend/src/components/ui/__tests__/Skeleton.test.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
Skeleton,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonList,
|
||||
} from '../Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default variant', () => {
|
||||
render(<Skeleton data-testid="skeleton" />)
|
||||
|
||||
const skeleton = screen.getByTestId('skeleton')
|
||||
expect(skeleton).toBeInTheDocument()
|
||||
expect(skeleton).toHaveClass('animate-pulse')
|
||||
expect(skeleton).toHaveClass('rounded-md')
|
||||
})
|
||||
|
||||
it('renders with circular variant', () => {
|
||||
render(<Skeleton variant="circular" data-testid="skeleton" />)
|
||||
|
||||
const skeleton = screen.getByTestId('skeleton')
|
||||
expect(skeleton).toHaveClass('rounded-full')
|
||||
})
|
||||
|
||||
it('renders with text variant', () => {
|
||||
render(<Skeleton variant="text" data-testid="skeleton" />)
|
||||
|
||||
const skeleton = screen.getByTestId('skeleton')
|
||||
expect(skeleton).toHaveClass('rounded')
|
||||
expect(skeleton).toHaveClass('h-4')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Skeleton className="custom-class" data-testid="skeleton" />)
|
||||
|
||||
const skeleton = screen.getByTestId('skeleton')
|
||||
expect(skeleton).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('passes through HTML attributes', () => {
|
||||
render(<Skeleton data-testid="skeleton" style={{ width: '100px' }} />)
|
||||
|
||||
const skeleton = screen.getByTestId('skeleton')
|
||||
expect(skeleton).toHaveStyle({ width: '100px' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCard', () => {
|
||||
it('renders with default props (image and 3 lines)', () => {
|
||||
render(<SkeletonCard data-testid="skeleton-card" />)
|
||||
|
||||
const card = screen.getByTestId('skeleton-card')
|
||||
expect(card).toBeInTheDocument()
|
||||
|
||||
// Should have image skeleton (h-32)
|
||||
const skeletons = card.querySelectorAll('.animate-pulse')
|
||||
// 1 image + 1 title + 3 text lines = 5 total
|
||||
expect(skeletons.length).toBe(5)
|
||||
})
|
||||
|
||||
it('renders without image when showImage is false', () => {
|
||||
render(<SkeletonCard showImage={false} data-testid="skeleton-card" />)
|
||||
|
||||
const card = screen.getByTestId('skeleton-card')
|
||||
const skeletons = card.querySelectorAll('.animate-pulse')
|
||||
// 1 title + 3 text lines = 4 total (no image)
|
||||
expect(skeletons.length).toBe(4)
|
||||
})
|
||||
|
||||
it('renders with custom number of lines', () => {
|
||||
render(<SkeletonCard lines={5} showImage={false} data-testid="skeleton-card" />)
|
||||
|
||||
const card = screen.getByTestId('skeleton-card')
|
||||
const skeletons = card.querySelectorAll('.animate-pulse')
|
||||
// 1 title + 5 text lines = 6 total
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<SkeletonCard className="custom-class" data-testid="skeleton-card" />)
|
||||
|
||||
const card = screen.getByTestId('skeleton-card')
|
||||
expect(card).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonTable', () => {
|
||||
it('renders with default rows and columns (5 rows, 4 columns)', () => {
|
||||
render(<SkeletonTable data-testid="skeleton-table" />)
|
||||
|
||||
const table = screen.getByTestId('skeleton-table')
|
||||
expect(table).toBeInTheDocument()
|
||||
|
||||
// Header row + 5 data rows
|
||||
const rows = table.querySelectorAll('.flex.gap-4')
|
||||
expect(rows.length).toBe(6) // 1 header + 5 rows
|
||||
})
|
||||
|
||||
it('renders with custom rows', () => {
|
||||
render(<SkeletonTable rows={3} data-testid="skeleton-table" />)
|
||||
|
||||
const table = screen.getByTestId('skeleton-table')
|
||||
// Header row + 3 data rows
|
||||
const rows = table.querySelectorAll('.flex.gap-4')
|
||||
expect(rows.length).toBe(4) // 1 header + 3 rows
|
||||
})
|
||||
|
||||
it('renders with custom columns', () => {
|
||||
render(<SkeletonTable columns={6} rows={1} data-testid="skeleton-table" />)
|
||||
|
||||
const table = screen.getByTestId('skeleton-table')
|
||||
// Check header has 6 skeletons
|
||||
const headerRow = table.querySelector('.bg-surface-subtle')
|
||||
const headerSkeletons = headerRow?.querySelectorAll('.animate-pulse')
|
||||
expect(headerSkeletons?.length).toBe(6)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<SkeletonTable className="custom-class" data-testid="skeleton-table" />)
|
||||
|
||||
const table = screen.getByTestId('skeleton-table')
|
||||
expect(table).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonList', () => {
|
||||
it('renders with default props (3 items with avatars)', () => {
|
||||
render(<SkeletonList data-testid="skeleton-list" />)
|
||||
|
||||
const list = screen.getByTestId('skeleton-list')
|
||||
expect(list).toBeInTheDocument()
|
||||
|
||||
// Each item has: 1 avatar (circular) + 2 text lines = 3 skeletons per item
|
||||
// 3 items * 3 = 9 total skeletons
|
||||
const items = list.querySelectorAll('.flex.items-center.gap-4')
|
||||
expect(items.length).toBe(3)
|
||||
})
|
||||
|
||||
it('renders with custom number of items', () => {
|
||||
render(<SkeletonList items={5} data-testid="skeleton-list" />)
|
||||
|
||||
const list = screen.getByTestId('skeleton-list')
|
||||
const items = list.querySelectorAll('.flex.items-center.gap-4')
|
||||
expect(items.length).toBe(5)
|
||||
})
|
||||
|
||||
it('renders without avatars when showAvatar is false', () => {
|
||||
render(<SkeletonList showAvatar={false} items={2} data-testid="skeleton-list" />)
|
||||
|
||||
const list = screen.getByTestId('skeleton-list')
|
||||
// No circular skeletons
|
||||
const circularSkeletons = list.querySelectorAll('.rounded-full')
|
||||
expect(circularSkeletons.length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders with avatars when showAvatar is true', () => {
|
||||
render(<SkeletonList showAvatar={true} items={2} data-testid="skeleton-list" />)
|
||||
|
||||
const list = screen.getByTestId('skeleton-list')
|
||||
// Should have circular skeletons for avatars
|
||||
const circularSkeletons = list.querySelectorAll('.rounded-full')
|
||||
expect(circularSkeletons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<SkeletonList className="custom-class" data-testid="skeleton-list" />)
|
||||
|
||||
const list = screen.getByTestId('skeleton-list')
|
||||
expect(list).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
167
frontend/src/components/ui/__tests__/StatsCard.test.tsx
Normal file
167
frontend/src/components/ui/__tests__/StatsCard.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Users } from 'lucide-react'
|
||||
import { StatsCard, type StatsCardChange } from '../StatsCard'
|
||||
|
||||
describe('StatsCard', () => {
|
||||
it('renders with title and value', () => {
|
||||
render(<StatsCard title="Total Users" value={1234} />)
|
||||
|
||||
expect(screen.getByText('Total Users')).toBeInTheDocument()
|
||||
expect(screen.getByText('1234')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with string value', () => {
|
||||
render(<StatsCard title="Revenue" value="$10,000" />)
|
||||
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument()
|
||||
expect(screen.getByText('$10,000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with icon', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
title="Users"
|
||||
value={100}
|
||||
icon={<Users data-testid="users-icon" />}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('users-icon')).toBeInTheDocument()
|
||||
// Icon container should have brand styling
|
||||
const iconContainer = screen.getByTestId('users-icon').parentElement
|
||||
expect(iconContainer).toHaveClass('bg-brand-500/10')
|
||||
expect(iconContainer).toHaveClass('text-brand-500')
|
||||
})
|
||||
|
||||
it('renders as link when href is provided', () => {
|
||||
render(<StatsCard title="Dashboard" value={50} href="/dashboard" />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('href', '/dashboard')
|
||||
})
|
||||
|
||||
it('renders as div when href is not provided', () => {
|
||||
render(<StatsCard title="Static Card" value={25} />)
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
const card = screen.getByText('Static Card').closest('div')
|
||||
expect(card).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with upward trend', () => {
|
||||
const change: StatsCardChange = {
|
||||
value: 12,
|
||||
trend: 'up',
|
||||
}
|
||||
|
||||
render(<StatsCard title="Growth" value={100} change={change} />)
|
||||
|
||||
expect(screen.getByText('12%')).toBeInTheDocument()
|
||||
// Should have success color for upward trend
|
||||
const trendContainer = screen.getByText('12%').closest('div')
|
||||
expect(trendContainer).toHaveClass('text-success')
|
||||
})
|
||||
|
||||
it('renders with downward trend', () => {
|
||||
const change: StatsCardChange = {
|
||||
value: 8,
|
||||
trend: 'down',
|
||||
}
|
||||
|
||||
render(<StatsCard title="Decline" value={50} change={change} />)
|
||||
|
||||
expect(screen.getByText('8%')).toBeInTheDocument()
|
||||
// Should have error color for downward trend
|
||||
const trendContainer = screen.getByText('8%').closest('div')
|
||||
expect(trendContainer).toHaveClass('text-error')
|
||||
})
|
||||
|
||||
it('renders with neutral trend', () => {
|
||||
const change: StatsCardChange = {
|
||||
value: 0,
|
||||
trend: 'neutral',
|
||||
}
|
||||
|
||||
render(<StatsCard title="Stable" value={75} change={change} />)
|
||||
|
||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||
// Should have muted color for neutral trend
|
||||
const trendContainer = screen.getByText('0%').closest('div')
|
||||
expect(trendContainer).toHaveClass('text-content-muted')
|
||||
})
|
||||
|
||||
it('renders trend with label', () => {
|
||||
const change: StatsCardChange = {
|
||||
value: 15,
|
||||
trend: 'up',
|
||||
label: 'from last month',
|
||||
}
|
||||
|
||||
render(<StatsCard title="Monthly Growth" value={200} change={change} />)
|
||||
|
||||
expect(screen.getByText('15%')).toBeInTheDocument()
|
||||
expect(screen.getByText('from last month')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<StatsCard title="Custom" value={10} className="custom-class" />
|
||||
)
|
||||
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('has hover styles when href is provided', () => {
|
||||
render(<StatsCard title="Hoverable" value={30} href="/test" />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveClass('hover:shadow-md')
|
||||
expect(link).toHaveClass('hover:border-brand-500/50')
|
||||
expect(link).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('does not have interactive styles when href is not provided', () => {
|
||||
const { container } = render(<StatsCard title="Static" value={40} />)
|
||||
|
||||
const card = container.firstChild
|
||||
expect(card).not.toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('has focus styles for accessibility when interactive', () => {
|
||||
render(<StatsCard title="Focusable" value={60} href="/link" />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveClass('focus:outline-none')
|
||||
expect(link).toHaveClass('focus-visible:ring-2')
|
||||
})
|
||||
|
||||
it('renders all elements together correctly', () => {
|
||||
const change: StatsCardChange = {
|
||||
value: 5,
|
||||
trend: 'up',
|
||||
label: 'vs yesterday',
|
||||
}
|
||||
|
||||
render(
|
||||
<StatsCard
|
||||
title="Complete Card"
|
||||
value="99.9%"
|
||||
change={change}
|
||||
icon={<Users data-testid="icon" />}
|
||||
href="/stats"
|
||||
className="test-class"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Complete Card')).toBeInTheDocument()
|
||||
expect(screen.getByText('99.9%')).toBeInTheDocument()
|
||||
expect(screen.getByText('5%')).toBeInTheDocument()
|
||||
expect(screen.getByText('vs yesterday')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/stats')
|
||||
expect(screen.getByRole('link')).toHaveClass('test-class')
|
||||
})
|
||||
})
|
||||
94
frontend/src/components/ui/index.ts
Normal file
94
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Core UI Components - Barrel Exports
|
||||
|
||||
// Badge
|
||||
export { Badge, type BadgeProps } from './Badge'
|
||||
|
||||
// Alert
|
||||
export { Alert, AlertTitle, AlertDescription, type AlertProps, type AlertTitleProps, type AlertDescriptionProps } from './Alert'
|
||||
|
||||
// StatsCard
|
||||
export { StatsCard, type StatsCardProps, type StatsCardChange } from './StatsCard'
|
||||
|
||||
// EmptyState
|
||||
export { EmptyState, type EmptyStateProps, type EmptyStateAction } from './EmptyState'
|
||||
|
||||
// DataTable
|
||||
export { DataTable, type DataTableProps, type Column } from './DataTable'
|
||||
|
||||
// Dialog
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './Dialog'
|
||||
|
||||
// Select
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
} from './Select'
|
||||
|
||||
// Tabs
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs'
|
||||
|
||||
// Tooltip
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip'
|
||||
|
||||
// Skeleton
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonList,
|
||||
type SkeletonProps,
|
||||
type SkeletonCardProps,
|
||||
type SkeletonTableProps,
|
||||
type SkeletonListProps,
|
||||
} from './Skeleton'
|
||||
|
||||
// Progress
|
||||
export { Progress, type ProgressProps } from './Progress'
|
||||
|
||||
// Checkbox
|
||||
export { Checkbox, type CheckboxProps } from './Checkbox'
|
||||
|
||||
// Label
|
||||
export { Label, labelVariants, type LabelProps } from './Label'
|
||||
|
||||
// Textarea
|
||||
export { Textarea, type TextareaProps } from './Textarea'
|
||||
|
||||
// Button
|
||||
export { Button, buttonVariants, type ButtonProps } from './Button'
|
||||
|
||||
// Card
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
type CardProps,
|
||||
} from './Card'
|
||||
|
||||
// Input
|
||||
export { Input, type InputProps } from './Input'
|
||||
|
||||
// Switch
|
||||
export { Switch } from './Switch'
|
||||
Reference in New Issue
Block a user