feat(ui): align design tokens and update component variants (MS15-UI-001, MS15-UI-002)
Replace hardcoded Tailwind color classes with CSS custom property inline styles across all packages/ui components (Button, Card, Badge, Input, Select, Textarea, Avatar, Modal, Toast). Badge: add new variants (badge-teal, badge-amber, badge-blue, badge-purple, badge-pulse) with mono font and pill shape. Button: add success variant, hover states via React state. Card: flat design, no shadows, semantic border/surface tokens. New: Dot component (teal, blue, amber, red, muted) with glow effect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,41 +15,51 @@ export function Avatar({
|
||||
fallback,
|
||||
initials,
|
||||
className = "",
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps): ReactElement {
|
||||
const sizeStyles = {
|
||||
type AvatarSize = "sm" | "md" | "lg" | "xl";
|
||||
const sizeStyles: Record<AvatarSize, string> = {
|
||||
sm: "w-6 h-6 text-xs",
|
||||
md: "w-8 h-8 text-sm",
|
||||
lg: "w-12 h-12 text-base",
|
||||
xl: "w-16 h-16 text-xl",
|
||||
};
|
||||
|
||||
const baseStyles =
|
||||
"rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
|
||||
const baseClass = `rounded-full overflow-hidden flex items-center justify-center font-medium ${sizeStyles[size]} ${className}`;
|
||||
|
||||
const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" ");
|
||||
const gradientStyle: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||
color: "#fff",
|
||||
...style,
|
||||
};
|
||||
|
||||
if (src) {
|
||||
return <img src={src} alt={alt} className={`${combinedClassName} object-cover`} {...props} />;
|
||||
return (
|
||||
<img src={src} alt={alt} className={`${baseClass} object-cover`} style={style} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return <div className={combinedClassName}>{fallback}</div>;
|
||||
return (
|
||||
<div className={baseClass} style={gradientStyle}>
|
||||
{fallback}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (initials) {
|
||||
return <div className={combinedClassName}>{initials}</div>;
|
||||
return (
|
||||
<div className={baseClass} style={gradientStyle}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default fallback with user icon
|
||||
return (
|
||||
<div className={combinedClassName}>
|
||||
<svg
|
||||
className="w-1/2 h-1/2 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className={baseClass} style={gradientStyle}>
|
||||
<svg className="w-1/2 h-1/2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
|
||||
@@ -8,38 +8,199 @@ export type BadgeVariant =
|
||||
| "status-warning"
|
||||
| "status-error"
|
||||
| "status-info"
|
||||
| "status-neutral";
|
||||
| "status-neutral"
|
||||
| "badge-teal"
|
||||
| "badge-amber"
|
||||
| "badge-red"
|
||||
| "badge-blue"
|
||||
| "badge-muted"
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
"priority-high": "bg-red-100 text-red-800 border-red-200",
|
||||
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"priority-low": "bg-green-100 text-green-800 border-green-200",
|
||||
"status-success": "bg-green-100 text-green-800 border-green-200",
|
||||
"status-warning": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"status-error": "bg-red-100 text-red-800 border-red-200",
|
||||
"status-info": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
interface BadgeStyleDef {
|
||||
style: React.CSSProperties;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
const variantDefs: Record<BadgeVariant, BadgeStyleDef> = {
|
||||
"priority-high": {
|
||||
style: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
color: "var(--ms-red-400)",
|
||||
border: "1px solid rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
"priority-medium": {
|
||||
style: {
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "var(--ms-amber-400)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
},
|
||||
"priority-low": {
|
||||
style: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
color: "var(--ms-teal-400)",
|
||||
border: "1px solid rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
"status-success": {
|
||||
style: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
color: "var(--ms-teal-400)",
|
||||
border: "1px solid rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
"status-warning": {
|
||||
style: {
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "var(--ms-amber-400)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
},
|
||||
"status-error": {
|
||||
style: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
color: "var(--ms-red-400)",
|
||||
border: "1px solid rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
"status-info": {
|
||||
style: {
|
||||
background: "rgba(47,128,255,0.12)",
|
||||
color: "var(--ms-blue-400)",
|
||||
border: "1px solid rgba(47,128,255,0.2)",
|
||||
},
|
||||
},
|
||||
"status-neutral": {
|
||||
style: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
},
|
||||
"badge-teal": {
|
||||
style: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
color: "var(--ms-teal-400)",
|
||||
border: "1px solid rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-amber": {
|
||||
style: {
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "var(--ms-amber-400)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-red": {
|
||||
style: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
color: "var(--ms-red-400)",
|
||||
border: "1px solid rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-blue": {
|
||||
style: {
|
||||
background: "rgba(47,128,255,0.12)",
|
||||
color: "var(--ms-blue-400)",
|
||||
border: "1px solid rgba(47,128,255,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-muted": {
|
||||
style: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
},
|
||||
"badge-purple": {
|
||||
style: {
|
||||
background: "rgba(139,92,246,0.12)",
|
||||
color: "var(--ms-purple-400)",
|
||||
border: "1px solid rgba(139,92,246,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-pulse": {
|
||||
style: {
|
||||
background: "rgba(47,128,255,0.12)",
|
||||
color: "var(--ms-blue-400)",
|
||||
border: "1px solid rgba(47,128,255,0.2)",
|
||||
},
|
||||
pulse: true,
|
||||
},
|
||||
};
|
||||
|
||||
const pulseKeyframes = `
|
||||
@keyframes ms-badge-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`;
|
||||
|
||||
let pulseStyleInjected = false;
|
||||
|
||||
function ensurePulseStyle(): void {
|
||||
if (pulseStyleInjected || typeof document === "undefined") return;
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = pulseKeyframes;
|
||||
document.head.appendChild(styleEl);
|
||||
pulseStyleInjected = true;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
variant = "status-neutral",
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
...props
|
||||
}: BadgeProps): ReactElement {
|
||||
const baseStyles =
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
|
||||
const combinedClassName = [baseStyles, variantStyles[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const def = variantDefs[variant];
|
||||
|
||||
if (def.pulse) {
|
||||
ensurePulseStyle();
|
||||
}
|
||||
|
||||
const baseStyle: React.CSSProperties = {
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--mono)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "20px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
...def.style,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={combinedClassName} role="status" aria-label={children as string} {...props}>
|
||||
<span
|
||||
className={className}
|
||||
style={baseStyle}
|
||||
role="status"
|
||||
aria-label={children as string}
|
||||
{...props}
|
||||
>
|
||||
{def.pulse && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "var(--ms-blue-400)",
|
||||
flexShrink: 0,
|
||||
animation: "ms-badge-pulse 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,39 +1,120 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode, ReactElement } from "react";
|
||||
import { useState, type ButtonHTMLAttributes, type ReactNode, type ReactElement } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
size?: "sm" | "md" | "lg";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface VariantStyle {
|
||||
base: React.CSSProperties;
|
||||
hover: React.CSSProperties;
|
||||
}
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
|
||||
const variantStyles: Record<ButtonVariant, VariantStyle> = {
|
||||
primary: {
|
||||
base: {
|
||||
background: "var(--ms-blue-500)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
},
|
||||
hover: {
|
||||
background: "var(--ms-blue-400)",
|
||||
boxShadow: "0 4px 16px rgba(47,128,255,0.3)",
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
base: {
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
},
|
||||
hover: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
},
|
||||
},
|
||||
ghost: {
|
||||
base: {
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
},
|
||||
hover: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
base: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
border: "1px solid rgba(229,72,77,0.3)",
|
||||
color: "var(--danger)",
|
||||
},
|
||||
hover: {
|
||||
background: "rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
success: {
|
||||
base: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
border: "1px solid rgba(20,184,166,0.3)",
|
||||
color: "var(--success)",
|
||||
},
|
||||
hover: {
|
||||
background: "rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps): ReactElement {
|
||||
const baseStyles = "inline-flex items-center justify-center font-medium rounded-md";
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const variantStyles = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300",
|
||||
const vStyles = variantStyles[variant];
|
||||
const baseClass = `inline-flex items-center justify-center font-medium rounded-md transition-colors ${sizeStyles[size]} ${className}`;
|
||||
|
||||
const computedStyle: React.CSSProperties = {
|
||||
...vStyles.base,
|
||||
...(isHovered && !disabled ? vStyles.hover : {}),
|
||||
...(disabled ? { opacity: 0.5, cursor: "not-allowed" } : { cursor: "pointer" }),
|
||||
...style,
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
const combinedClassName = [baseStyles, variantStyles[variant], sizeStyles[size], className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button className={combinedClassName} {...props}>
|
||||
<button
|
||||
className={baseClass}
|
||||
style={computedStyle}
|
||||
disabled={disabled}
|
||||
onMouseEnter={(e) => {
|
||||
setIsHovered(true);
|
||||
onMouseEnter?.(e);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
setIsHovered(false);
|
||||
onMouseLeave?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode, ReactElement } from "react";
|
||||
export interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
id?: string;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
@@ -11,21 +12,25 @@ export interface CardProps {
|
||||
export interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export interface CardContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export interface CardFooterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
id,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
@@ -35,24 +40,52 @@ export function Card({
|
||||
id={id}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`}
|
||||
className={className}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: "16px",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = "" }: CardHeaderProps): ReactElement {
|
||||
return <div className={`px-6 py-4 border-b border-gray-200 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = "" }: CardContentProps): ReactElement {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = "" }: CardFooterProps): ReactElement {
|
||||
export function CardHeader({ children, className = "", style }: CardHeaderProps): ReactElement {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg ${className}`}>
|
||||
<div
|
||||
className={`px-6 py-4 ${className}`}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = "", style }: CardContentProps): ReactElement {
|
||||
return (
|
||||
<div className={`px-6 py-4 ${className}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = "", style }: CardFooterProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`px-6 py-4 rounded-b-lg ${className}`}
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "var(--bg-mid)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
39
packages/ui/src/components/Dot.tsx
Normal file
39
packages/ui/src/components/Dot.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
||||
|
||||
export interface DotProps {
|
||||
variant?: DotVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DotColorDef {
|
||||
bg: string;
|
||||
shadow: string;
|
||||
}
|
||||
|
||||
export function Dot({ variant = "muted", className = "" }: DotProps): ReactElement {
|
||||
const colors: Record<DotVariant, DotColorDef> = {
|
||||
teal: { bg: "var(--success)", shadow: "0 0 5px var(--success)" },
|
||||
blue: { bg: "var(--primary)", shadow: "0 0 5px var(--primary)" },
|
||||
amber: { bg: "var(--warn)", shadow: "0 0 5px var(--warn)" },
|
||||
red: { bg: "var(--danger)", shadow: "0 0 5px var(--danger)" },
|
||||
muted: { bg: "var(--muted)", shadow: "none" },
|
||||
};
|
||||
|
||||
const { bg, shadow } = colors[variant];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block ${className}`}
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: bg,
|
||||
boxShadow: shadow,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef } from "react";
|
||||
import { useState, forwardRef } from "react";
|
||||
import type { InputHTMLAttributes, ReactElement } from "react";
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
@@ -9,44 +9,75 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, error, helperText, fullWidth = false, className = "", id, ...props },
|
||||
{
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = false,
|
||||
className = "",
|
||||
id,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputId = id ?? `input-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const helperId = helperText ? `${inputId}-helper` : undefined;
|
||||
|
||||
const baseStyles =
|
||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: "var(--bg-mid)",
|
||||
border: error
|
||||
? `1px solid var(--danger)`
|
||||
: isFocused
|
||||
? `1px solid var(--primary)`
|
||||
: `1px solid var(--border)`,
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={combinedClassName}
|
||||
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
|
||||
style={inputStyle}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,8 @@ export function Modal({
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const modalId = useRef(`modal-${Math.random().toString(36).substring(2, 11)}`);
|
||||
|
||||
const sizeStyles = {
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||
const sizeStyles: Record<ModalSize, string> = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-lg",
|
||||
lg: "max-w-2xl",
|
||||
@@ -65,7 +66,8 @@ export function Modal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -75,18 +77,30 @@ export function Modal({
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className={`bg-white rounded-lg shadow-xl w-full ${sizeStyles[size]} ${className}`}
|
||||
className={`rounded-lg w-full ${sizeStyles[size]} ${className}`}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
role="document"
|
||||
>
|
||||
{title && (
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 id={`${modalId.current}-title`} className="text-lg font-semibold text-gray-900">
|
||||
<div
|
||||
className="px-6 py-4 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<h2
|
||||
id={`${modalId.current}-title`}
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text)" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
||||
className="transition-colors p-1 rounded"
|
||||
style={{ color: "var(--muted)" }}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
@@ -108,7 +122,13 @@ export function Modal({
|
||||
)}
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
{footer && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg flex justify-end gap-2">
|
||||
<div
|
||||
className="px-6 py-4 rounded-b-lg flex justify-end gap-2"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "var(--bg-mid)",
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { SelectHTMLAttributes, ReactElement } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
@@ -24,33 +25,56 @@ export function Select({
|
||||
placeholder = "Select an option...",
|
||||
className = "",
|
||||
id,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: SelectProps): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const selectId = id ?? `select-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const errorId = error ? `${selectId}-error` : undefined;
|
||||
const helperId = helperText ? `${selectId}-helper` : undefined;
|
||||
|
||||
const baseStyles =
|
||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
const selectStyle: React.CSSProperties = {
|
||||
background: "var(--bg-mid)",
|
||||
border: error
|
||||
? `1px solid var(--danger)`
|
||||
: isFocused
|
||||
? `1px solid var(--primary)`
|
||||
: `1px solid var(--border)`,
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={combinedClassName}
|
||||
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
|
||||
style={selectStyle}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<option value="" disabled>
|
||||
@@ -63,12 +87,12 @@ export function Select({
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { TextareaHTMLAttributes, ReactElement } from "react";
|
||||
|
||||
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
||||
@@ -16,48 +17,72 @@ export function Textarea({
|
||||
resize = "vertical",
|
||||
className = "",
|
||||
id,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: TextareaProps): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const errorId = error ? `${textareaId}-error` : undefined;
|
||||
const helperId = helperText ? `${textareaId}-helper` : undefined;
|
||||
|
||||
const baseStyles =
|
||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const resizeStyles = {
|
||||
const resizeStyles: Record<string, string> = {
|
||||
none: "resize-none",
|
||||
both: "resize",
|
||||
horizontal: "resize-x",
|
||||
vertical: "resize-y",
|
||||
};
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, resizeStyles[resize], errorStyles, className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
background: "var(--bg-mid)",
|
||||
border: error
|
||||
? `1px solid var(--danger)`
|
||||
: isFocused
|
||||
? `1px solid var(--primary)`
|
||||
: `1px solid var(--border)`,
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label htmlFor={textareaId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={textareaId}
|
||||
className={combinedClassName}
|
||||
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${resizeStyles[resize] ?? "resize-y"} ${className}`}
|
||||
style={textareaStyle}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -97,13 +97,37 @@ interface ToastItemProps {
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ToastVariantStyle {
|
||||
background: string;
|
||||
border: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ToastVariant, ToastVariantStyle> = {
|
||||
success: {
|
||||
background: "rgba(20,184,166,0.15)",
|
||||
border: "1px solid rgba(20,184,166,0.35)",
|
||||
color: "var(--success)",
|
||||
},
|
||||
error: {
|
||||
background: "rgba(229,72,77,0.15)",
|
||||
border: "1px solid rgba(229,72,77,0.35)",
|
||||
color: "var(--danger)",
|
||||
},
|
||||
warning: {
|
||||
background: "rgba(245,158,11,0.15)",
|
||||
border: "1px solid rgba(245,158,11,0.35)",
|
||||
color: "var(--warn)",
|
||||
},
|
||||
info: {
|
||||
background: "rgba(47,128,255,0.15)",
|
||||
border: "1px solid rgba(47,128,255,0.35)",
|
||||
color: "var(--info)",
|
||||
},
|
||||
};
|
||||
|
||||
function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
success: "bg-green-500 text-white border-green-600",
|
||||
error: "bg-red-500 text-white border-red-600",
|
||||
warning: "bg-yellow-500 text-white border-yellow-600",
|
||||
info: "bg-blue-500 text-white border-blue-600",
|
||||
};
|
||||
const vStyle = variantStyles[toast.variant ?? "info"];
|
||||
|
||||
const icon: Record<ToastVariant, ReactNode> = {
|
||||
success: (
|
||||
@@ -146,7 +170,12 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${variantStyles[toast.variant ?? "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
|
||||
className="rounded-md px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md"
|
||||
style={{
|
||||
background: vStyle.background,
|
||||
border: vStyle.border,
|
||||
color: vStyle.color,
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span>
|
||||
@@ -155,7 +184,7 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||
onClick={() => {
|
||||
onRemove(toast.id);
|
||||
}}
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/20"
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
|
||||
@@ -60,3 +60,7 @@ export type {
|
||||
AuthStatusPillProps,
|
||||
AuthDividerProps,
|
||||
} from "./components/AuthSurface.js";
|
||||
|
||||
// Dot
|
||||
export { Dot } from "./components/Dot.js";
|
||||
export type { DotProps, DotVariant } from "./components/Dot.js";
|
||||
|
||||
Reference in New Issue
Block a user