feat(tokens): implement complete design token system with WCAG AA compliance
- Add comprehensive design token system with CSS custom properties - Implement automatic light/dark theme switching - Create production-ready UI primitive library (Button, Input, Select, Card, Alert, Badge) - Ensure WCAG AA accessibility with 4.5:1+ contrast ratios - Add theme context and custom hooks for theme management - Include contrast validation utilities Components include full TypeScript interfaces, accessibility features, and consistent design token integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
150
reactrebuild0825/src/components/ui/Alert.tsx
Normal file
150
reactrebuild0825/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
||||
title?: string;
|
||||
dismissible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
({
|
||||
variant = 'info',
|
||||
title,
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
icon,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
if (!isVisible) {return null;}
|
||||
|
||||
// Default icons for each variant
|
||||
const defaultIcons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
neutral: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const alertStyles = clsx(
|
||||
// Base styles
|
||||
'relative p-lg rounded-lg border backdrop-blur-md',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
|
||||
// Variant styles
|
||||
{
|
||||
'bg-success-bg text-success-text border-success-border': variant === 'success',
|
||||
'bg-warning-bg text-warning-text border-warning-border': variant === 'warning',
|
||||
'bg-error-bg text-error-text border-error-border': variant === 'error',
|
||||
'bg-info-bg text-info-text border-info-border': variant === 'info',
|
||||
'bg-glass-bg text-text-primary border-glass-border': variant === 'neutral',
|
||||
},
|
||||
|
||||
className
|
||||
);
|
||||
|
||||
const iconColor = {
|
||||
success: 'text-success-accent',
|
||||
warning: 'text-warning-accent',
|
||||
error: 'text-error-accent',
|
||||
info: 'text-info-accent',
|
||||
neutral: 'text-text-muted',
|
||||
};
|
||||
|
||||
const displayIcon = icon ?? defaultIcons[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={alertStyles}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex">
|
||||
{displayIcon && (
|
||||
<div className={clsx('flex-shrink-0 mr-md', iconColor[variant])}>
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h3 className="text-sm font-semibold mb-xs">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div className="text-sm">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="mt-md">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dismissible && (
|
||||
<div className="flex-shrink-0 ml-md">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'inline-flex rounded-md p-xs transition-colors duration-150',
|
||||
'hover:bg-black/10 focus:bg-black/10 focus:outline-none',
|
||||
iconColor[variant]
|
||||
)}
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss alert"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
export { Alert };
|
||||
144
reactrebuild0825/src/components/ui/Badge.tsx
Normal file
144
reactrebuild0825/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'gold';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
dot?: boolean;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
dot = false,
|
||||
removable = false,
|
||||
onRemove,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseStyles = clsx(
|
||||
// Base styles
|
||||
'inline-flex items-center gap-xs font-medium backdrop-blur-md border',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
|
||||
// Size variants
|
||||
{
|
||||
'px-sm py-xs text-xs rounded-md h-5': size === 'sm',
|
||||
'px-md py-xs text-sm rounded-md h-6': size === 'md',
|
||||
'px-lg py-sm text-base rounded-lg h-8': size === 'lg',
|
||||
},
|
||||
|
||||
// Variant styles
|
||||
{
|
||||
// Success
|
||||
'bg-success-bg text-success-text border-success-border': variant === 'success',
|
||||
|
||||
// Warning
|
||||
'bg-warning-bg text-warning-text border-warning-border': variant === 'warning',
|
||||
|
||||
// Error
|
||||
'bg-error-bg text-error-text border-error-border': variant === 'error',
|
||||
|
||||
// Info
|
||||
'bg-info-bg text-info-text border-info-border': variant === 'info',
|
||||
|
||||
// Neutral
|
||||
'bg-glass-bg text-text-secondary border-glass-border': variant === 'neutral',
|
||||
|
||||
// Gold
|
||||
'bg-gold-500/10 text-gold-text border-gold-500/30': variant === 'gold',
|
||||
},
|
||||
|
||||
// Dot variant adjustments
|
||||
{
|
||||
'pl-sm': dot && size === 'sm',
|
||||
'pl-md': dot && size === 'md',
|
||||
'pl-lg': dot && size === 'lg',
|
||||
},
|
||||
|
||||
className
|
||||
);
|
||||
|
||||
const dotStyles = clsx(
|
||||
'rounded-full',
|
||||
{
|
||||
'w-1.5 h-1.5': size === 'sm',
|
||||
'w-2 h-2': size === 'md',
|
||||
'w-2.5 h-2.5': size === 'lg',
|
||||
},
|
||||
{
|
||||
'bg-success-accent': variant === 'success',
|
||||
'bg-warning-accent': variant === 'warning',
|
||||
'bg-error-accent': variant === 'error',
|
||||
'bg-info-accent': variant === 'info',
|
||||
'bg-text-muted': variant === 'neutral',
|
||||
'bg-gold-500': variant === 'gold',
|
||||
}
|
||||
);
|
||||
|
||||
const removeButtonStyles = clsx(
|
||||
'ml-xs rounded-full transition-colors duration-150 focus:outline-none',
|
||||
'hover:bg-black/10 focus:bg-black/10',
|
||||
{
|
||||
'w-3 h-3': size === 'sm',
|
||||
'w-4 h-4': size === 'md',
|
||||
'w-5 h-5': size === 'lg',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={baseStyles}
|
||||
{...props}
|
||||
>
|
||||
{dot && <span className={dotStyles} aria-hidden="true" />}
|
||||
|
||||
<span>{children}</span>
|
||||
|
||||
{removable && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className={removeButtonStyles}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}
|
||||
}}
|
||||
aria-label="Remove badge"
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
87
reactrebuild0825/src/components/ui/Button.tsx
Normal file
87
reactrebuild0825/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'gold' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
iconLeft,
|
||||
iconRight,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseStyles = clsx(
|
||||
// Base glass styling
|
||||
'relative inline-flex items-center justify-center gap-sm font-medium',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border border-glass-border backdrop-blur-md',
|
||||
'focus:outline-none focus:ring-2 focus:ring-gold-500 focus:ring-offset-2 focus:ring-offset-background-primary',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
|
||||
|
||||
// Size variants
|
||||
{
|
||||
'px-md py-xs text-sm h-8 rounded-md': size === 'sm',
|
||||
'px-lg py-sm text-base h-10 rounded-lg': size === 'md',
|
||||
'px-xl py-md text-lg h-12 rounded-xl': size === 'lg',
|
||||
},
|
||||
|
||||
// Variant styles
|
||||
{
|
||||
// Primary - Blue gradient with glass effect
|
||||
'bg-glass-bg text-primary-text border-primary-500/30 shadow-glass hover:bg-primary-500/20 hover:border-primary-400/50 hover:shadow-glow-sm active:scale-95': variant === 'primary',
|
||||
|
||||
// Secondary - Purple gradient with glass effect
|
||||
'bg-glass-bg text-secondary-text border-secondary-500/30 shadow-glass hover:bg-secondary-500/20 hover:border-secondary-400/50 hover:shadow-glow-sm active:scale-95': variant === 'secondary',
|
||||
|
||||
// Gold - Premium gold styling
|
||||
'bg-glass-bg text-gold-text border-gold-500/30 shadow-glass hover:bg-gold-500/20 hover:border-gold-400/50 hover:shadow-glow active:scale-95': variant === 'gold',
|
||||
|
||||
// Ghost - Minimal glass styling
|
||||
'bg-transparent text-text-primary border-border-default hover:bg-glass-bg hover:border-glass-border hover:shadow-glass-sm active:scale-95': variant === 'ghost',
|
||||
|
||||
// Danger - Error styling with glass effect
|
||||
'bg-glass-bg text-error-text border-error-accent/30 shadow-glass hover:bg-error-accent/20 hover:border-error-accent/50 hover:shadow-glow-sm active:scale-95': variant === 'danger',
|
||||
}
|
||||
);
|
||||
|
||||
const isDisabled = disabled ?? loading;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(baseStyles, className)}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className={clsx('flex items-center gap-sm', { 'opacity-0': loading })}>
|
||||
{iconLeft && <span className="flex-shrink-0">{iconLeft}</span>}
|
||||
{children}
|
||||
{iconRight && <span className="flex-shrink-0">{iconRight}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
167
reactrebuild0825/src/components/ui/Card.tsx
Normal file
167
reactrebuild0825/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface CardProps {
|
||||
variant?: 'default' | 'elevated' | 'outline';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
clickable?: boolean;
|
||||
elevation?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
clickable = false,
|
||||
elevation = 'md',
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
id,
|
||||
'data-testid': dataTestId
|
||||
}, ref) => {
|
||||
const cardStyles = clsx(
|
||||
// Base styles
|
||||
'relative bg-glass-bg backdrop-blur-md rounded-lg border transition-all duration-200 ease-in-out',
|
||||
|
||||
// Variant styles
|
||||
{
|
||||
'border-glass-border': variant === 'default',
|
||||
'border-glass-border shadow-glass-lg': variant === 'elevated',
|
||||
'border-border-default bg-transparent': variant === 'outline',
|
||||
},
|
||||
|
||||
// Elevation styles
|
||||
{
|
||||
'shadow-glass-sm': elevation === 'sm' && variant !== 'outline',
|
||||
'shadow-glass': elevation === 'md' && variant !== 'outline',
|
||||
'shadow-glass-lg': elevation === 'lg' && variant !== 'outline',
|
||||
'shadow-glass-xl': elevation === 'xl' && variant !== 'outline',
|
||||
},
|
||||
|
||||
// Padding styles
|
||||
{
|
||||
'p-0': padding === 'none',
|
||||
'p-sm': padding === 'sm',
|
||||
'p-lg': padding === 'md',
|
||||
'p-xl': padding === 'lg',
|
||||
'p-2xl': padding === 'xl',
|
||||
},
|
||||
|
||||
// Clickable styles
|
||||
{
|
||||
'cursor-pointer hover:shadow-glass-lg hover:border-gold-500/30 hover:bg-glass-bg/80 active:scale-[0.98]': clickable && variant !== 'outline',
|
||||
'cursor-pointer hover:border-gold-500/50 hover:bg-glass-bg active:scale-[0.98]': clickable && variant === 'outline',
|
||||
},
|
||||
|
||||
className
|
||||
);
|
||||
|
||||
if (clickable) {
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.RefObject<HTMLButtonElement>}
|
||||
className={cardStyles}
|
||||
onClick={onClick}
|
||||
id={id}
|
||||
data-testid={dataTestId}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cardStyles}
|
||||
onClick={onClick}
|
||||
id={id}
|
||||
data-testid={dataTestId}
|
||||
onKeyDown={onClick ? (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
} : undefined}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'flex items-center justify-between p-lg border-b border-glass-border/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('p-lg', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'flex items-center justify-end gap-sm p-lg border-t border-glass-border/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
CardBody.displayName = 'CardBody';
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardBody, CardFooter };
|
||||
124
reactrebuild0825/src/components/ui/Input.tsx
Normal file
124
reactrebuild0825/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
error?: string;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
variant?: 'default' | 'ghost';
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({
|
||||
label,
|
||||
helperText,
|
||||
error,
|
||||
iconLeft,
|
||||
iconRight,
|
||||
variant = 'default',
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const inputId = id ?? `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const helperTextId = `${inputId}-helper`;
|
||||
const errorId = `${inputId}-error`;
|
||||
|
||||
const inputStyles = clsx(
|
||||
// Base glass styling
|
||||
'w-full px-lg py-sm text-base text-text-primary placeholder-text-muted',
|
||||
'bg-glass-bg border border-glass-border backdrop-blur-md rounded-lg',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-gold-500 focus:ring-offset-2 focus:ring-offset-background-primary',
|
||||
'focus:border-gold-500/50 focus:bg-glass-bg',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
|
||||
// Variant styles
|
||||
{
|
||||
'shadow-glass hover:shadow-glass-lg': variant === 'default',
|
||||
'bg-transparent border-border-default hover:bg-glass-bg hover:border-glass-border': variant === 'ghost',
|
||||
},
|
||||
|
||||
// Icon padding adjustments
|
||||
{
|
||||
'pl-10': iconLeft,
|
||||
'pr-10': iconRight,
|
||||
},
|
||||
|
||||
// Error states
|
||||
{
|
||||
'border-error-accent/50 focus:ring-error-accent focus:border-error-accent/70': error,
|
||||
'border-glass-border': !error,
|
||||
},
|
||||
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-text-secondary mb-sm"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{iconLeft && (
|
||||
<div className="absolute inset-y-0 left-0 pl-lg flex items-center pointer-events-none">
|
||||
<span className="text-text-muted">{iconLeft}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={inputStyles}
|
||||
aria-describedby={clsx(
|
||||
helperText && helperTextId,
|
||||
error && errorId
|
||||
)}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{iconRight && (
|
||||
<div className="absolute inset-y-0 right-0 pr-lg flex items-center pointer-events-none">
|
||||
<span className="text-text-muted">{iconRight}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(helperText ?? error) && (
|
||||
<div className="mt-sm">
|
||||
{error ? (
|
||||
<p
|
||||
id={errorId}
|
||||
className="text-sm text-error-text"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : helperText ? (
|
||||
<p
|
||||
id={helperTextId}
|
||||
className="text-sm text-text-muted"
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
292
reactrebuild0825/src/components/ui/Select.tsx
Normal file
292
reactrebuild0825/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { forwardRef, useState, useRef, useEffect } from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
multiple?: boolean;
|
||||
onChange?: (value: string | string[]) => void;
|
||||
onSearch?: (query: string) => void;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
({
|
||||
options,
|
||||
value,
|
||||
placeholder = 'Select an option...',
|
||||
label,
|
||||
helperText,
|
||||
error,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
multiple = false,
|
||||
onChange,
|
||||
onSearch,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>(
|
||||
multiple ? (Array.isArray(value) ? value : value ? [value] : []) : value ? [value] : []
|
||||
);
|
||||
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectId = id ?? `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const helperTextId = `${selectId}-helper`;
|
||||
const errorId = `${selectId}-error`;
|
||||
|
||||
// Filter options based on search query
|
||||
const filteredOptions = searchable && searchQuery
|
||||
? options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: options;
|
||||
|
||||
// Get display value
|
||||
const getDisplayValue = () => {
|
||||
if (selectedValues.length === 0) {return placeholder;}
|
||||
|
||||
if (multiple) {
|
||||
return selectedValues.length === 1
|
||||
? options.find(opt => opt.value === selectedValues[0])?.label
|
||||
: `${selectedValues.length} selected`;
|
||||
}
|
||||
|
||||
return options.find(opt => opt.value === selectedValues[0])?.label ?? placeholder;
|
||||
};
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = (optionValue: string) => {
|
||||
let newSelectedValues: string[];
|
||||
|
||||
if (multiple) {
|
||||
newSelectedValues = selectedValues.includes(optionValue)
|
||||
? selectedValues.filter(v => v !== optionValue)
|
||||
: [...selectedValues, optionValue];
|
||||
} else {
|
||||
newSelectedValues = [optionValue];
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
onChange?.(multiple ? newSelectedValues : newSelectedValues[0] ?? '');
|
||||
};
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
onSearch?.(query);
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchable && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen, searchable]);
|
||||
|
||||
const triggerStyles = clsx(
|
||||
// Base glass styling
|
||||
'w-full px-lg py-sm text-base text-left',
|
||||
'bg-glass-bg border border-glass-border backdrop-blur-md rounded-lg',
|
||||
'transition-all duration-200 ease-in-out cursor-pointer',
|
||||
'focus:outline-none focus:ring-2 focus:ring-gold-500 focus:ring-offset-2 focus:ring-offset-background-primary',
|
||||
'focus:border-gold-500/50 shadow-glass hover:shadow-glass-lg',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
|
||||
// Error states
|
||||
{
|
||||
'border-error-accent/50 focus:ring-error-accent focus:border-error-accent/70': error,
|
||||
'border-glass-border': !error,
|
||||
},
|
||||
|
||||
// Open state
|
||||
{
|
||||
'border-gold-500/50 ring-2 ring-gold-500 ring-offset-2 ring-offset-background-primary': isOpen && !error,
|
||||
}
|
||||
);
|
||||
|
||||
const dropdownStyles = clsx(
|
||||
'absolute z-50 w-full mt-xs',
|
||||
'bg-glass-bg border border-glass-border backdrop-blur-md rounded-lg shadow-glass-lg',
|
||||
'max-h-60 overflow-auto'
|
||||
);
|
||||
|
||||
const optionStyles = (option: SelectOption, isSelected: boolean) => clsx(
|
||||
'w-full px-lg py-sm text-left text-base transition-colors duration-150',
|
||||
'hover:bg-glass-bg focus:bg-glass-bg focus:outline-none',
|
||||
{
|
||||
'text-text-primary': !option.disabled,
|
||||
'text-text-disabled cursor-not-allowed': option.disabled,
|
||||
'bg-gold-500/20 text-gold-text': isSelected && !option.disabled,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx('w-full', className)} {...props}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-text-secondary mb-sm"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div ref={selectRef} className="relative">
|
||||
<div
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={`${selectId}-listbox`}
|
||||
aria-describedby={clsx(
|
||||
helperText && helperTextId,
|
||||
error && errorId
|
||||
)}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={triggerStyles}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) {return;}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={clsx(
|
||||
selectedValues.length === 0 ? 'text-text-muted' : 'text-text-primary'
|
||||
)}>
|
||||
{getDisplayValue()}
|
||||
</span>
|
||||
<svg
|
||||
className={clsx(
|
||||
'w-5 h-5 transition-transform duration-200',
|
||||
isOpen && 'rotate-180',
|
||||
disabled ? 'text-text-disabled' : 'text-text-muted'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className={dropdownStyles}>
|
||||
{searchable && (
|
||||
<div className="p-sm border-b border-glass-border">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search options..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full px-sm py-xs text-sm bg-transparent border-none focus:outline-none text-text-primary placeholder-text-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div role="listbox" aria-multiselectable={multiple} id={`${selectId}-listbox`}>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-lg py-sm text-sm text-text-muted">
|
||||
No options found
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
disabled={option.disabled}
|
||||
className={optionStyles(option, isSelected)}
|
||||
onClick={() => !option.disabled && handleOptionSelect(option.value)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.label}</span>
|
||||
{multiple && isSelected && (
|
||||
<svg className="w-4 h-4 text-gold-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(helperText ?? error) && (
|
||||
<div className="mt-sm">
|
||||
{error ? (
|
||||
<p
|
||||
id={errorId}
|
||||
className="text-sm text-error-text"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : helperText ? (
|
||||
<p
|
||||
id={helperTextId}
|
||||
className="text-sm text-text-muted"
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export { Select };
|
||||
7
reactrebuild0825/src/components/ui/index.ts
Normal file
7
reactrebuild0825/src/components/ui/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// UI Component Library Exports
|
||||
export { Button, type ButtonProps } from './Button';
|
||||
export { Input, type InputProps } from './Input';
|
||||
export { Select, type SelectProps, type SelectOption } from './Select';
|
||||
export { Card, CardHeader, CardBody, CardFooter, type CardProps, type CardHeaderProps, type CardBodyProps, type CardFooterProps } from './Card';
|
||||
export { Badge, type BadgeProps } from './Badge';
|
||||
export { Alert, type AlertProps } from './Alert';
|
||||
@@ -63,6 +63,7 @@ export function ThemeProvider({ children, defaultTheme = 'dark' }: ThemeProvider
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const value: ThemeContextType = {
|
||||
|
||||
@@ -7,9 +7,9 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
r: parseInt(result[1]!, 16),
|
||||
g: parseInt(result[2]!, 16),
|
||||
b: parseInt(result[3]!, 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -17,16 +17,16 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
// Convert RGBA string to RGB values
|
||||
function rgbaToRgb(rgba: string): { r: number; g: number; b: number; a: number } | null {
|
||||
const match = rgba.match(/rgba?\(([^)]+)\)/);
|
||||
if (!match) {
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = match[1].split(',').map(v => parseFloat(v.trim()));
|
||||
return {
|
||||
r: values[0],
|
||||
g: values[1],
|
||||
b: values[2],
|
||||
a: values[3] || 1,
|
||||
r: values[0] ?? 0,
|
||||
g: values[1] ?? 0,
|
||||
b: values[2] ?? 0,
|
||||
a: values[3] ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function getLuminance(r: number, g: number, b: number): number {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
return 0.2126 * (rs ?? 0) + 0.7152 * (gs ?? 0) + 0.0722 * (bs ?? 0);
|
||||
}
|
||||
|
||||
// Calculate contrast ratio between two colors
|
||||
|
||||
Reference in New Issue
Block a user