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);
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: ThemeContextType = {
|
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);
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result
|
return result
|
||||||
? {
|
? {
|
||||||
r: parseInt(result[1], 16),
|
r: parseInt(result[1]!, 16),
|
||||||
g: parseInt(result[2], 16),
|
g: parseInt(result[2]!, 16),
|
||||||
b: parseInt(result[3], 16),
|
b: parseInt(result[3]!, 16),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,16 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|||||||
// Convert RGBA string to RGB values
|
// Convert RGBA string to RGB values
|
||||||
function rgbaToRgb(rgba: string): { r: number; g: number; b: number; a: number } | null {
|
function rgbaToRgb(rgba: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
const match = rgba.match(/rgba?\(([^)]+)\)/);
|
const match = rgba.match(/rgba?\(([^)]+)\)/);
|
||||||
if (!match) {
|
if (!match?.[1]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = match[1].split(',').map(v => parseFloat(v.trim()));
|
const values = match[1].split(',').map(v => parseFloat(v.trim()));
|
||||||
return {
|
return {
|
||||||
r: values[0],
|
r: values[0] ?? 0,
|
||||||
g: values[1],
|
g: values[1] ?? 0,
|
||||||
b: values[2],
|
b: values[2] ?? 0,
|
||||||
a: values[3] || 1,
|
a: values[3] ?? 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ function getLuminance(r: number, g: number, b: number): number {
|
|||||||
c = c / 255;
|
c = c / 255;
|
||||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
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
|
// Calculate contrast ratio between two colors
|
||||||
|
|||||||
Reference in New Issue
Block a user