chore: clean git cache

This commit is contained in:
GitHub Actions
2026-01-02 00:59:57 +00:00
parent 9a05e2f927
commit aae55a8ae9
289 changed files with 0 additions and 62352 deletions

View File

@@ -1,125 +0,0 @@
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}
/>
)
}

View File

@@ -1,42 +0,0 @@
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}
/>
)
}

View File

@@ -1,111 +0,0 @@
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 }

View File

@@ -1,102 +0,0 @@
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 }

View File

@@ -1,46 +0,0 @@
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 }

View File

@@ -1,246 +0,0 @@
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>
)
}

View File

@@ -1,141 +0,0 @@
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,
}

View File

@@ -1,70 +0,0 @@
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>
)
}

View File

@@ -1,113 +0,0 @@
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 }

View File

@@ -1,45 +0,0 @@
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 }

View File

@@ -1,32 +0,0 @@
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';

View File

@@ -1,56 +0,0 @@
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 }

View File

@@ -1,180 +0,0 @@
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,
}

View File

@@ -1,142 +0,0 @@
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>
)
}

View File

@@ -1,108 +0,0 @@
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>
}

View File

@@ -1,50 +0,0 @@
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 }

View File

@@ -1,59 +0,0 @@
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 }

View File

@@ -1,34 +0,0 @@
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 }

View File

@@ -1,37 +0,0 @@
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 }

View File

@@ -1,181 +0,0 @@
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()
})
})

View File

@@ -1,352 +0,0 @@
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' })
})
})

View File

@@ -1,161 +0,0 @@
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()
})
})

View File

@@ -1,173 +0,0 @@
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')
})
})

View File

@@ -1,167 +0,0 @@
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')
})
})

View File

@@ -1,94 +0,0 @@
// 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'