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:
2026-02-22 15:04:00 -06:00
parent a5ed260fbd
commit 44011f4e27
11 changed files with 568 additions and 111 deletions

View File

@@ -15,41 +15,51 @@ export function Avatar({
fallback, fallback,
initials, initials,
className = "", className = "",
style,
...props ...props
}: AvatarProps): ReactElement { }: AvatarProps): ReactElement {
const sizeStyles = { type AvatarSize = "sm" | "md" | "lg" | "xl";
const sizeStyles: Record<AvatarSize, string> = {
sm: "w-6 h-6 text-xs", sm: "w-6 h-6 text-xs",
md: "w-8 h-8 text-sm", md: "w-8 h-8 text-sm",
lg: "w-12 h-12 text-base", lg: "w-12 h-12 text-base",
xl: "w-16 h-16 text-xl", xl: "w-16 h-16 text-xl",
}; };
const baseStyles = const baseClass = `rounded-full overflow-hidden flex items-center justify-center font-medium ${sizeStyles[size]} ${className}`;
"rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
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) { 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) { if (fallback) {
return <div className={combinedClassName}>{fallback}</div>; return (
<div className={baseClass} style={gradientStyle}>
{fallback}
</div>
);
} }
if (initials) { if (initials) {
return <div className={combinedClassName}>{initials}</div>; return (
<div className={baseClass} style={gradientStyle}>
{initials}
</div>
);
} }
// Default fallback with user icon // Default fallback with user icon
return ( return (
<div className={combinedClassName}> <div className={baseClass} style={gradientStyle}>
<svg <svg className="w-1/2 h-1/2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
className="w-1/2 h-1/2 text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"

View File

@@ -8,38 +8,199 @@ export type BadgeVariant =
| "status-warning" | "status-warning"
| "status-error" | "status-error"
| "status-info" | "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> { export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant; variant?: BadgeVariant;
children: ReactNode; children: ReactNode;
} }
const variantStyles: Record<BadgeVariant, string> = { interface BadgeStyleDef {
"priority-high": "bg-red-100 text-red-800 border-red-200", style: React.CSSProperties;
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200", pulse?: boolean;
"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", const variantDefs: Record<BadgeVariant, BadgeStyleDef> = {
"status-error": "bg-red-100 text-red-800 border-red-200", "priority-high": {
"status-info": "bg-blue-100 text-blue-800 border-blue-200", style: {
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200", 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({ export function Badge({
variant = "status-neutral", variant = "status-neutral",
children, children,
className = "", className = "",
style,
...props ...props
}: BadgeProps): ReactElement { }: BadgeProps): ReactElement {
const baseStyles = const def = variantDefs[variant];
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
const combinedClassName = [baseStyles, variantStyles[variant], className] if (def.pulse) {
.filter(Boolean) ensurePulseStyle();
.join(" "); }
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 ( 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} {children}
</span> </span>
); );

View File

@@ -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> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost"; variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
children: ReactNode; 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({ export function Button({
variant = "primary", variant = "primary",
size = "md", size = "md",
children, children,
className = "", className = "",
style,
onMouseEnter,
onMouseLeave,
disabled,
...props ...props
}: ButtonProps): ReactElement { }: ButtonProps): ReactElement {
const baseStyles = "inline-flex items-center justify-center font-medium rounded-md"; const [isHovered, setIsHovered] = useState(false);
const variantStyles = { const vStyles = variantStyles[variant];
primary: "bg-blue-600 text-white hover:bg-blue-700", const baseClass = `inline-flex items-center justify-center font-medium rounded-md transition-colors ${sizeStyles[size]} ${className}`;
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700", const computedStyle: React.CSSProperties = {
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300", ...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 ( 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} {children}
</button> </button>
); );

View File

@@ -3,6 +3,7 @@ import type { ReactNode, ReactElement } from "react";
export interface CardProps { export interface CardProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
style?: React.CSSProperties;
id?: string; id?: string;
onMouseEnter?: () => void; onMouseEnter?: () => void;
onMouseLeave?: () => void; onMouseLeave?: () => void;
@@ -11,21 +12,25 @@ export interface CardProps {
export interface CardHeaderProps { export interface CardHeaderProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
style?: React.CSSProperties;
} }
export interface CardContentProps { export interface CardContentProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
style?: React.CSSProperties;
} }
export interface CardFooterProps { export interface CardFooterProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
style?: React.CSSProperties;
} }
export function Card({ export function Card({
children, children,
className = "", className = "",
style,
id, id,
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
@@ -35,24 +40,52 @@ export function Card({
id={id} id={id}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} 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} {children}
</div> </div>
); );
} }
export function CardHeader({ children, className = "" }: CardHeaderProps): ReactElement { export function CardHeader({ children, className = "", style }: 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 {
return ( 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} {children}
</div> </div>
); );

View 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"
/>
);
}

View File

@@ -1,4 +1,4 @@
import { forwardRef } from "react"; import { useState, forwardRef } from "react";
import type { InputHTMLAttributes, ReactElement } from "react"; import type { InputHTMLAttributes, ReactElement } from "react";
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> { 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( 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 ref
): ReactElement { ): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const inputId = id ?? `input-${Math.random().toString(36).substring(2, 11)}`; const inputId = id ?? `input-${Math.random().toString(36).substring(2, 11)}`;
const errorId = error ? `${inputId}-error` : undefined; const errorId = error ? `${inputId}-error` : undefined;
const helperId = helperText ? `${inputId}-helper` : undefined; const helperId = helperText ? `${inputId}-helper` : undefined;
const baseStyles = const inputStyle: React.CSSProperties = {
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"; background: "var(--bg-mid)",
const widthStyles = fullWidth ? "w-full" : ""; border: error
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300"; ? `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] const widthClass = fullWidth ? "w-full" : "";
.filter(Boolean)
.join(" ");
return ( return (
<div className={fullWidth ? "w-full" : ""}> <div className={fullWidth ? "w-full" : ""}>
{label && ( {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}
</label> </label>
)} )}
<input <input
ref={ref} ref={ref}
id={inputId} id={inputId}
className={combinedClassName} className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
style={inputStyle}
aria-invalid={error ? "true" : "false"} aria-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined} aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
onFocus={(e) => {
setIsFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...props} {...props}
/> />
{error && ( {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} {error}
</p> </p>
)} )}
{helperText && !error && ( {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} {helperText}
</p> </p>
)} )}

View File

@@ -25,7 +25,8 @@ export function Modal({
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const modalId = useRef(`modal-${Math.random().toString(36).substring(2, 11)}`); 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", sm: "max-w-md",
md: "max-w-lg", md: "max-w-lg",
lg: "max-w-2xl", lg: "max-w-2xl",
@@ -65,7 +66,8 @@ export function Modal({
return ( return (
<div <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} onClick={handleOverlayClick}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -75,18 +77,30 @@ export function Modal({
<div <div
ref={dialogRef} ref={dialogRef}
tabIndex={-1} 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" role="document"
> >
{title && ( {title && (
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between"> <div
<h2 id={`${modalId.current}-title`} className="text-lg font-semibold text-gray-900"> 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} {title}
</h2> </h2>
<button <button
type="button" type="button"
onClick={onClose} 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" aria-label="Close modal"
> >
<svg <svg
@@ -108,7 +122,13 @@ export function Modal({
)} )}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div> <div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
{footer && ( {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} {footer}
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import type { SelectHTMLAttributes, ReactElement } from "react"; import type { SelectHTMLAttributes, ReactElement } from "react";
export interface SelectOption { export interface SelectOption {
@@ -24,33 +25,56 @@ export function Select({
placeholder = "Select an option...", placeholder = "Select an option...",
className = "", className = "",
id, id,
style,
onFocus,
onBlur,
...props ...props
}: SelectProps): ReactElement { }: SelectProps): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const selectId = id ?? `select-${Math.random().toString(36).substring(2, 11)}`; const selectId = id ?? `select-${Math.random().toString(36).substring(2, 11)}`;
const errorId = error ? `${selectId}-error` : undefined; const errorId = error ? `${selectId}-error` : undefined;
const helperId = helperText ? `${selectId}-helper` : undefined; const helperId = helperText ? `${selectId}-helper` : undefined;
const baseStyles = const selectStyle: React.CSSProperties = {
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white"; background: "var(--bg-mid)",
const widthStyles = fullWidth ? "w-full" : ""; border: error
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300"; ? `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] const widthClass = fullWidth ? "w-full" : "";
.filter(Boolean)
.join(" ");
return ( return (
<div className={fullWidth ? "w-full" : ""}> <div className={fullWidth ? "w-full" : ""}>
{label && ( {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}
</label> </label>
)} )}
<select <select
id={selectId} id={selectId}
className={combinedClassName} className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
style={selectStyle}
aria-invalid={error ? "true" : "false"} aria-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined} aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
onFocus={(e) => {
setIsFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...props} {...props}
> >
<option value="" disabled> <option value="" disabled>
@@ -63,12 +87,12 @@ export function Select({
))} ))}
</select> </select>
{error && ( {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} {error}
</p> </p>
)} )}
{helperText && !error && ( {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} {helperText}
</p> </p>
)} )}

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import type { TextareaHTMLAttributes, ReactElement } from "react"; import type { TextareaHTMLAttributes, ReactElement } from "react";
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> { export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
@@ -16,48 +17,72 @@ export function Textarea({
resize = "vertical", resize = "vertical",
className = "", className = "",
id, id,
style,
onFocus,
onBlur,
...props ...props
}: TextareaProps): ReactElement { }: TextareaProps): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`; const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`;
const errorId = error ? `${textareaId}-error` : undefined; const errorId = error ? `${textareaId}-error` : undefined;
const helperId = helperText ? `${textareaId}-helper` : undefined; const helperId = helperText ? `${textareaId}-helper` : undefined;
const baseStyles = const resizeStyles: Record<string, string> = {
"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 = {
none: "resize-none", none: "resize-none",
both: "resize", both: "resize",
horizontal: "resize-x", horizontal: "resize-x",
vertical: "resize-y", vertical: "resize-y",
}; };
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
const combinedClassName = [baseStyles, widthStyles, resizeStyles[resize], errorStyles, className] const textareaStyle: React.CSSProperties = {
.filter(Boolean) background: "var(--bg-mid)",
.join(" "); 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 ( return (
<div className={fullWidth ? "w-full" : ""}> <div className={fullWidth ? "w-full" : ""}>
{label && ( {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}
</label> </label>
)} )}
<textarea <textarea
id={textareaId} 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-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined} aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
onFocus={(e) => {
setIsFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...props} {...props}
/> />
{error && ( {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} {error}
</p> </p>
)} )}
{helperText && !error && ( {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} {helperText}
</p> </p>
)} )}

View File

@@ -97,14 +97,38 @@ interface ToastItemProps {
onRemove: (id: string) => void; onRemove: (id: string) => void;
} }
function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement { interface ToastVariantStyle {
const variantStyles: Record<ToastVariant, string> = { background: string;
success: "bg-green-500 text-white border-green-600", border: string;
error: "bg-red-500 text-white border-red-600", color: string;
warning: "bg-yellow-500 text-white border-yellow-600", }
info: "bg-blue-500 text-white border-blue-600",
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 vStyle = variantStyles[toast.variant ?? "info"];
const icon: Record<ToastVariant, ReactNode> = { const icon: Record<ToastVariant, ReactNode> = {
success: ( success: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
@@ -146,7 +170,12 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
return ( return (
<div <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" role="alert"
> >
<span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span> <span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span>
@@ -155,7 +184,7 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
onClick={() => { onClick={() => {
onRemove(toast.id); 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" aria-label="Close notification"
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">

View File

@@ -60,3 +60,7 @@ export type {
AuthStatusPillProps, AuthStatusPillProps,
AuthDividerProps, AuthDividerProps,
} from "./components/AuthSurface.js"; } from "./components/AuthSurface.js";
// Dot
export { Dot } from "./components/Dot.js";
export type { DotProps, DotVariant } from "./components/Dot.js";