Design System 重构完成
This commit is contained in:
273
src/design-system/base/button/button.tsx
Normal file
273
src/design-system/base/button/button.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Button 组件
|
||||
*
|
||||
* Design System 中的按钮组件,支持多种变体、尺寸和状态。
|
||||
* 自动处理 Link/button 切换,支持图标和加载状态。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Primary 按钮
|
||||
* <Button variant="primary" onClick={handleClick}>
|
||||
* 点击我
|
||||
* </Button>
|
||||
*
|
||||
* // 带图标的按钮
|
||||
* <Button variant="secondary" leftIcon={<Icon />}>
|
||||
* 带图标
|
||||
* </Button>
|
||||
*
|
||||
* // 作为链接使用
|
||||
* <Button variant="primary" href="/path">
|
||||
* 链接按钮
|
||||
* </Button>
|
||||
*
|
||||
* // 加载状态
|
||||
* <Button variant="primary" loading>
|
||||
* 提交中...
|
||||
* </Button>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 按钮变体样式
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
// 基础样式
|
||||
"inline-flex items-center justify-center gap-2 rounded-xl font-semibold shadow transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-primary-500 text-white hover:bg-primary-600 shadow-md",
|
||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 shadow-sm",
|
||||
success: "bg-success-500 text-white hover:bg-success-600 shadow-md",
|
||||
warning: "bg-warning-500 text-white hover:bg-warning-600 shadow-md",
|
||||
error: "bg-error-500 text-white hover:bg-error-600 shadow-md",
|
||||
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 shadow-none",
|
||||
outline: "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 shadow-none",
|
||||
link: "text-primary-500 hover:text-primary-600 hover:underline shadow-none px-0",
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-base",
|
||||
lg: "h-12 px-6 text-lg",
|
||||
},
|
||||
fullWidth: {
|
||||
true: "w-full",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// 链接变体不应用高度和圆角
|
||||
{
|
||||
variant: "link",
|
||||
size: "sm",
|
||||
className: "h-auto px-0",
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
size: "md",
|
||||
className: "h-auto px-0",
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
size: "lg",
|
||||
className: "h-auto px-0",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "secondary",
|
||||
size: "md",
|
||||
fullWidth: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
// 内容
|
||||
children?: React.ReactNode;
|
||||
|
||||
// 导航
|
||||
href?: string;
|
||||
openInNewTab?: boolean;
|
||||
|
||||
// 图标
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
iconSrc?: string; // For Next.js Image icons
|
||||
iconAlt?: string;
|
||||
|
||||
// 状态
|
||||
loading?: boolean;
|
||||
selected?: boolean;
|
||||
|
||||
// 样式
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button 组件
|
||||
*/
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
fullWidth = false,
|
||||
href,
|
||||
openInNewTab = false,
|
||||
iconSrc,
|
||||
iconAlt,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
children,
|
||||
className,
|
||||
loading = false,
|
||||
selected = false,
|
||||
disabled,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
// 计算样式
|
||||
const computedClass = cn(
|
||||
buttonVariants({ variant, size, fullWidth }),
|
||||
selected && variant === "secondary" && "bg-gray-200",
|
||||
className
|
||||
);
|
||||
|
||||
// 图标尺寸映射
|
||||
const iconSize = { sm: 14, md: 16, lg: 20 }[size];
|
||||
|
||||
// 渲染 SVG 图标
|
||||
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
|
||||
if (!icon) return null;
|
||||
return (
|
||||
<span className={`flex items-center shrink-0 ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染 Next.js Image 图标
|
||||
const renderImageIcon = () => {
|
||||
if (!iconSrc) return null;
|
||||
return (
|
||||
<Image
|
||||
src={iconSrc}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
alt={iconAlt || "icon"}
|
||||
className="shrink-0"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染加载图标
|
||||
const renderLoadingIcon = () => {
|
||||
if (!loading) return null;
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// 组装内容
|
||||
const content = (
|
||||
<>
|
||||
{loading && renderLoadingIcon()}
|
||||
{renderImageIcon()}
|
||||
{renderSvgIcon(leftIcon, "left")}
|
||||
{children}
|
||||
{renderSvgIcon(rightIcon, "right")}
|
||||
</>
|
||||
);
|
||||
|
||||
// 如果提供了 href,渲染为 Link
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={computedClass}
|
||||
target={openInNewTab ? "_blank" : undefined}
|
||||
rel={openInNewTab ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 否则渲染为 button
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
className={computedClass}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预定义的按钮快捷组件
|
||||
*/
|
||||
export const PrimaryButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="primary" {...props} />
|
||||
);
|
||||
|
||||
export const SecondaryButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="secondary" {...props} />
|
||||
);
|
||||
|
||||
export const SuccessButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="success" {...props} />
|
||||
);
|
||||
|
||||
export const WarningButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="warning" {...props} />
|
||||
);
|
||||
|
||||
export const ErrorButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="error" {...props} />
|
||||
);
|
||||
|
||||
export const GhostButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="ghost" {...props} />
|
||||
);
|
||||
|
||||
export const OutlineButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="outline" {...props} />
|
||||
);
|
||||
|
||||
export const LinkButton = (props: Omit<ButtonProps, "variant">) => (
|
||||
<Button variant="link" {...props} />
|
||||
);
|
||||
1
src/design-system/base/button/index.ts
Normal file
1
src/design-system/base/button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './button';
|
||||
198
src/design-system/base/card/card.tsx
Normal file
198
src/design-system/base/card/card.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Card 卡片组件
|
||||
*
|
||||
* Design System 中的卡片容器组件,提供统一的内容包装样式。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认卡片
|
||||
* <Card>
|
||||
* <p>卡片内容</p>
|
||||
* </Card>
|
||||
*
|
||||
* // 带边框的卡片
|
||||
* <Card variant="bordered" padding="lg">
|
||||
* <p>带边框的内容</p>
|
||||
* </Card>
|
||||
*
|
||||
* // 无内边距卡片
|
||||
* <Card padding="none">
|
||||
* <img src="image.jpg" alt="完全填充的图片" />
|
||||
* </Card>
|
||||
*
|
||||
* // 可点击的卡片
|
||||
* <Card clickable onClick={handleClick}>
|
||||
* <p>点击我</p>
|
||||
* </Card>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 卡片变体样式
|
||||
*/
|
||||
const cardVariants = cva(
|
||||
// 基础样式
|
||||
"rounded-2xl bg-white transition-all duration-250",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "shadow-xl",
|
||||
bordered: "border-2 border-gray-200 shadow-sm",
|
||||
elevated: "shadow-2xl",
|
||||
flat: "border border-gray-200 shadow-none",
|
||||
},
|
||||
padding: {
|
||||
none: "",
|
||||
xs: "p-3",
|
||||
sm: "p-4",
|
||||
md: "p-6",
|
||||
lg: "p-8",
|
||||
xl: "p-10",
|
||||
},
|
||||
clickable: {
|
||||
true: "cursor-pointer hover:shadow-primary/25 hover:-translate-y-0.5 active:translate-y-0",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
padding: "md",
|
||||
clickable: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type CardVariant = VariantProps<typeof cardVariants>["variant"];
|
||||
export type CardPadding = VariantProps<typeof cardVariants>["padding"];
|
||||
|
||||
export interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {
|
||||
// 子元素
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card 卡片组件
|
||||
*/
|
||||
export function Card({
|
||||
variant = "default",
|
||||
padding = "md",
|
||||
clickable = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(cardVariants({ variant, padding, clickable }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CardSection - 卡片内容区块
|
||||
* 用于组织卡片内部的多个内容区块
|
||||
*/
|
||||
export interface CardSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
export function CardSection({
|
||||
noPadding = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
!noPadding && "p-6",
|
||||
"first:rounded-t-2xl last:rounded-b-2xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CardHeader - 卡片头部
|
||||
*/
|
||||
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between p-6 border-b border-gray-200", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CardTitle - 卡片标题
|
||||
*/
|
||||
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardTitleProps) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold text-gray-900", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CardBody - 卡片主体
|
||||
*/
|
||||
export const CardBody = CardSection;
|
||||
|
||||
/**
|
||||
* CardFooter - 卡片底部
|
||||
*/
|
||||
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 p-6 border-t border-gray-200", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/design-system/base/card/index.ts
Normal file
1
src/design-system/base/card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './card';
|
||||
170
src/design-system/base/checkbox/checkbox.tsx
Normal file
170
src/design-system/base/checkbox/checkbox.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Checkbox 复选框组件
|
||||
*
|
||||
* Design System 中的复选框组件,支持多种状态和尺寸。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认复选框
|
||||
* <Checkbox>同意条款</Checkbox>
|
||||
*
|
||||
* // 受控组件
|
||||
* <Checkbox checked={checked} onChange={handleChange}>
|
||||
* 同意条款
|
||||
* </Checkbox>
|
||||
*
|
||||
* // 错误状态
|
||||
* <Checkbox error>必选项</Checkbox>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 复选框变体样式
|
||||
*/
|
||||
const checkboxVariants = cva(
|
||||
// 基础样式
|
||||
"peer h-4 w-4 shrink-0 rounded border-2 transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-gray-300 checked:bg-primary-500 checked:border-primary-500",
|
||||
success: "border-gray-300 checked:bg-success-500 checked:border-success-500",
|
||||
warning: "border-gray-300 checked:bg-warning-500 checked:border-warning-500",
|
||||
error: "border-gray-300 checked:bg-error-500 checked:border-error-500",
|
||||
},
|
||||
size: {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
},
|
||||
error: {
|
||||
true: "border-error-500",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
error: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type CheckboxVariant = VariantProps<typeof checkboxVariants>["variant"];
|
||||
export type CheckboxSize = VariantProps<typeof checkboxVariants>["size"];
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
VariantProps<typeof checkboxVariants> {
|
||||
// 标签文本
|
||||
label?: React.ReactNode;
|
||||
// 标签位置
|
||||
labelPosition?: "left" | "right";
|
||||
// 自定义复选框类名
|
||||
checkboxClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox 复选框组件
|
||||
*/
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
size = "md",
|
||||
error = false,
|
||||
label,
|
||||
labelPosition = "right",
|
||||
className,
|
||||
checkboxClassName,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const checkboxId = React.useId();
|
||||
|
||||
const renderCheckbox = () => (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
checkboxVariants({ variant, size, error }),
|
||||
checkboxClassName
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderLabel = () => {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className={cn(
|
||||
"text-base font-normal leading-none",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
labelPosition === "left" ? "mr-2" : "ml-2"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
if (!label) {
|
||||
return renderCheckbox();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center", className)}>
|
||||
{labelPosition === "left" && renderLabel()}
|
||||
{renderCheckbox()}
|
||||
{labelPosition === "right" && renderLabel()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
/**
|
||||
* CheckboxGroup - 复选框组
|
||||
*/
|
||||
export interface CheckboxGroupProps {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckboxGroup({
|
||||
children,
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
}: CheckboxGroupProps) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{label && (
|
||||
<div className="text-base font-medium text-gray-900">
|
||||
{label}
|
||||
{required && <span className="text-error-500 ml-1">*</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">{children}</div>
|
||||
{error && <p className="text-sm text-error-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/design-system/base/checkbox/index.ts
Normal file
1
src/design-system/base/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './checkbox';
|
||||
1
src/design-system/base/input/index.ts
Normal file
1
src/design-system/base/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './input';
|
||||
151
src/design-system/base/input/input.tsx
Normal file
151
src/design-system/base/input/input.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Input 输入框组件
|
||||
*
|
||||
* Design System 中的输入框组件,支持多种样式变体和尺寸。
|
||||
* 完全可访问,支持焦点状态和错误状态。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认样式
|
||||
* <Input placeholder="请输入内容" />
|
||||
*
|
||||
* // 带边框样式
|
||||
* <Input variant="bordered" placeholder="带边框的输入框" />
|
||||
*
|
||||
* // 填充样式
|
||||
* <Input variant="filled" placeholder="填充背景的输入框" />
|
||||
*
|
||||
* // 错误状态
|
||||
* <Input variant="bordered" error placeholder="有错误的输入框" />
|
||||
*
|
||||
* // 禁用状态
|
||||
* <Input disabled placeholder="禁用的输入框" />
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 输入框变体样式
|
||||
*/
|
||||
const inputVariants = cva(
|
||||
// 基础样式
|
||||
"flex w-full rounded-xl border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-b-2 border-gray-300 bg-transparent rounded-t-xl",
|
||||
bordered: "border-gray-300 bg-white",
|
||||
filled: "border-transparent bg-gray-100",
|
||||
search: "border-gray-200 bg-white pl-10 rounded-full",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-10 px-4 text-base",
|
||||
lg: "h-12 px-5 text-lg",
|
||||
},
|
||||
error: {
|
||||
true: "border-error-500 focus-visible:ring-error-500",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// 填充变体的错误状态
|
||||
{
|
||||
variant: "filled",
|
||||
error: true,
|
||||
className: "bg-error-50",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
error: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type InputVariant = VariantProps<typeof inputVariants>["variant"];
|
||||
export type InputSize = VariantProps<typeof inputVariants>["size"];
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
VariantProps<typeof inputVariants> {
|
||||
// 左侧图标(通常用于搜索框)
|
||||
leftIcon?: React.ReactNode;
|
||||
// 右侧图标(例如清除按钮)
|
||||
rightIcon?: React.ReactNode;
|
||||
// 容器类名(用于包裹图标和输入框)
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input 输入框组件
|
||||
*/
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
size = "md",
|
||||
error = false,
|
||||
className,
|
||||
containerClassName,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
type = "text",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 如果有左侧图标,使用相对定位的容器
|
||||
if (leftIcon) {
|
||||
return (
|
||||
<div className={cn("relative", containerClassName)}>
|
||||
{/* 左侧图标 */}
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
{leftIcon}
|
||||
</div>
|
||||
{/* 输入框 */}
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
inputVariants({ variant, size, error }),
|
||||
leftIcon && "pl-10"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* 右侧图标 */}
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通输入框
|
||||
return (
|
||||
<div className={cn("relative", containerClassName)}>
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(inputVariants({ variant, size, error }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
1
src/design-system/base/radio/index.ts
Normal file
1
src/design-system/base/radio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './radio';
|
||||
219
src/design-system/base/radio/radio.tsx
Normal file
219
src/design-system/base/radio/radio.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Radio 单选按钮组件
|
||||
*
|
||||
* Design System 中的单选按钮组件,支持多种状态和尺寸。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认单选按钮
|
||||
* <Radio name="choice" value="1">选项 1</Radio>
|
||||
* <Radio name="choice" value="2">选项 2</Radio>
|
||||
*
|
||||
* // 受控组件
|
||||
* <Radio
|
||||
* name="choice"
|
||||
* value="1"
|
||||
* checked={value === "1"}
|
||||
* onChange={(e) => setValue(e.target.value)}
|
||||
* >
|
||||
* 选项 1
|
||||
* </Radio>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 单选按钮变体样式
|
||||
*/
|
||||
const radioVariants = cva(
|
||||
// 基础样式
|
||||
"peer h-4 w-4 shrink-0 rounded-full border-2 transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-gray-300 checked:border-primary-500",
|
||||
success: "border-gray-300 checked:border-success-500",
|
||||
warning: "border-gray-300 checked:border-warning-500",
|
||||
error: "border-gray-300 checked:border-error-500",
|
||||
},
|
||||
size: {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
},
|
||||
error: {
|
||||
true: "border-error-500",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
error: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type RadioVariant = VariantProps<typeof radioVariants>["variant"];
|
||||
export type RadioSize = VariantProps<typeof radioVariants>["size"];
|
||||
|
||||
export interface RadioProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
VariantProps<typeof radioVariants> {
|
||||
// 标签文本
|
||||
label?: React.ReactNode;
|
||||
// 标签位置
|
||||
labelPosition?: "left" | "right";
|
||||
// 自定义单选按钮类名
|
||||
radioClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Radio 单选按钮组件
|
||||
*/
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
size = "md",
|
||||
error = false,
|
||||
label,
|
||||
labelPosition = "right",
|
||||
className,
|
||||
radioClassName,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const radioId = React.useId();
|
||||
|
||||
const renderRadio = () => (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
id={radioId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
radioVariants({ variant, size, error }),
|
||||
"peer/radio",
|
||||
radioClassName
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* 选中状态的圆点 */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none transition-all duration-250",
|
||||
"peer-checked/radio:bg-current",
|
||||
size === "sm" && "h-1.5 w-1.5",
|
||||
size === "md" && "h-2 w-2",
|
||||
size === "lg" && "h-2.5 w-2.5",
|
||||
variant === "default" && "text-primary-500",
|
||||
variant === "success" && "text-success-500",
|
||||
variant === "warning" && "text-warning-500",
|
||||
variant === "error" && "text-error-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLabel = () => {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={radioId}
|
||||
className={cn(
|
||||
"text-base font-normal leading-none",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
labelPosition === "left" ? "mr-2" : "ml-2"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
if (!label) {
|
||||
return renderRadio();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center", className)}>
|
||||
{labelPosition === "left" && renderLabel()}
|
||||
{renderRadio()}
|
||||
{labelPosition === "right" && renderLabel()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Radio.displayName = "Radio";
|
||||
|
||||
/**
|
||||
* RadioGroup - 单选按钮组
|
||||
*/
|
||||
export interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
className?: string;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
}
|
||||
|
||||
export function RadioGroup({
|
||||
children,
|
||||
name,
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
orientation = "vertical",
|
||||
}: RadioGroupProps) {
|
||||
// 为每个 Radio 注入 name 和 onChange
|
||||
const enhancedChildren = React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, {
|
||||
name,
|
||||
checked: value !== undefined ? child.props.value === value : undefined,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.value);
|
||||
child.props.onChange?.(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{label && (
|
||||
<div className="text-base font-medium text-gray-900">
|
||||
{label}
|
||||
{required && <span className="text-error-500 ml-1">*</span>}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
orientation === "vertical" ? "space-y-2" : "flex gap-4"
|
||||
)}
|
||||
>
|
||||
{enhancedChildren}
|
||||
</div>
|
||||
{error && <p className="text-sm text-error-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/design-system/base/select/index.ts
Normal file
1
src/design-system/base/select/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './select';
|
||||
112
src/design-system/base/select/select.tsx
Normal file
112
src/design-system/base/select/select.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Select 下拉选择框组件
|
||||
*
|
||||
* Design System 中的下拉选择框组件。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Select>
|
||||
* <option value="">请选择</option>
|
||||
* <option value="1">选项 1</option>
|
||||
* <option value="2">选项 2</option>
|
||||
* </Select>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Select 变体样式
|
||||
*/
|
||||
const selectVariants = cva(
|
||||
// 基础样式
|
||||
"flex w-full appearance-none items-center justify-between rounded-xl border px-3 py-2 pr-8 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-b-2 border-gray-300 bg-transparent rounded-t-xl",
|
||||
bordered: "border-gray-300 bg-white",
|
||||
filled: "border-transparent bg-gray-100",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-10 px-4 text-base",
|
||||
lg: "h-12 px-5 text-lg",
|
||||
},
|
||||
error: {
|
||||
true: "border-error-500 focus-visible:ring-error-500",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "filled",
|
||||
error: true,
|
||||
className: "bg-error-50",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
error: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type SelectVariant = VariantProps<typeof selectVariants>["variant"];
|
||||
export type SelectSize = VariantProps<typeof selectVariants>["size"];
|
||||
|
||||
export interface SelectProps
|
||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size">,
|
||||
VariantProps<typeof selectVariants> {}
|
||||
|
||||
/**
|
||||
* Select 下拉选择框组件
|
||||
*/
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
size = "md",
|
||||
error = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(selectVariants({ variant, size, error }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
{/* 下拉箭头图标 */}
|
||||
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = "Select";
|
||||
1
src/design-system/base/switch/index.ts
Normal file
1
src/design-system/base/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './switch';
|
||||
179
src/design-system/base/switch/switch.tsx
Normal file
179
src/design-system/base/switch/switch.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useState } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Switch 开关组件
|
||||
*
|
||||
* Design System 中的开关组件,用于二进制状态切换。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认开关
|
||||
* <Switch checked={checked} onChange={setChecked} />
|
||||
*
|
||||
* // 带标签
|
||||
* <Switch label="启用通知" checked={checked} onChange={setChecked} />
|
||||
*
|
||||
* // 不同尺寸
|
||||
* <Switch size="sm" checked={checked} onChange={setChecked} />
|
||||
* <Switch size="lg" checked={checked} onChange={setChecked} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 开关变体样式
|
||||
*/
|
||||
const switchVariants = cva(
|
||||
// 基础样式
|
||||
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-gray-300 bg-gray-100 checked:border-primary-500 checked:bg-primary-500",
|
||||
success:
|
||||
"border-gray-300 bg-gray-100 checked:border-success-500 checked:bg-success-500",
|
||||
warning:
|
||||
"border-gray-300 bg-gray-100 checked:border-warning-500 checked:bg-warning-500",
|
||||
error:
|
||||
"border-gray-300 bg-gray-100 checked:border-error-500 checked:bg-error-500",
|
||||
},
|
||||
size: {
|
||||
sm: "h-5 w-9",
|
||||
md: "h-6 w-11",
|
||||
lg: "h-7 w-13",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type SwitchVariant = VariantProps<typeof switchVariants>["variant"];
|
||||
export type SwitchSize = VariantProps<typeof switchVariants>["size"];
|
||||
|
||||
export interface SwitchProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
VariantProps<typeof switchVariants> {
|
||||
// 标签文本
|
||||
label?: React.ReactNode;
|
||||
// 标签位置
|
||||
labelPosition?: "left" | "right";
|
||||
// 自定义开关类名
|
||||
switchClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch 开关组件
|
||||
*/
|
||||
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
size = "md",
|
||||
label,
|
||||
labelPosition = "right",
|
||||
className,
|
||||
switchClassName,
|
||||
disabled,
|
||||
checked,
|
||||
defaultChecked,
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const switchId = React.useId();
|
||||
const [internalChecked, setInternalChecked] = useState(
|
||||
checked ?? defaultChecked ?? false
|
||||
);
|
||||
|
||||
// 处理受控和非受控模式
|
||||
const isControlled = checked !== undefined;
|
||||
const isChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!isControlled) {
|
||||
setInternalChecked(e.target.checked);
|
||||
}
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
// 滑块大小
|
||||
const thumbSize = {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
}[size];
|
||||
|
||||
// 滑块位移
|
||||
const thumbTranslate = {
|
||||
sm: isChecked ? "translate-x-4" : "translate-x-0.5",
|
||||
md: isChecked ? "translate-x-5" : "translate-x-0.5",
|
||||
lg: isChecked ? "translate-x-6" : "translate-x-0.5",
|
||||
}[size];
|
||||
|
||||
const renderSwitch = () => (
|
||||
<div className="relative inline-block">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
id={switchId}
|
||||
disabled={disabled}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
switchVariants({ variant, size }),
|
||||
"peer/switch",
|
||||
switchClassName
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* 滑块 */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow-sm transition-transform duration-250",
|
||||
thumbSize,
|
||||
thumbTranslate
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLabel = () => {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={switchId}
|
||||
className={cn(
|
||||
"text-base font-normal leading-none",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
labelPosition === "left" ? "mr-3" : "ml-3"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
if (!label) {
|
||||
return renderSwitch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center", className)}>
|
||||
{labelPosition === "left" && renderLabel()}
|
||||
{renderSwitch()}
|
||||
{labelPosition === "right" && renderLabel()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Switch.displayName = "Switch";
|
||||
1
src/design-system/base/textarea/index.ts
Normal file
1
src/design-system/base/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './textarea';
|
||||
104
src/design-system/base/textarea/textarea.tsx
Normal file
104
src/design-system/base/textarea/textarea.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/design-system/lib/utils";
|
||||
|
||||
/**
|
||||
* Textarea 多行文本输入组件
|
||||
*
|
||||
* Design System 中的多行文本输入组件,支持多种样式变体。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认样式
|
||||
* <Textarea placeholder="请输入内容" rows={4} />
|
||||
*
|
||||
* // 带边框样式
|
||||
* <Textarea variant="bordered" placeholder="带边框的文本域" />
|
||||
*
|
||||
* // 填充样式
|
||||
* <Textarea variant="filled" placeholder="填充背景的文本域" />
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Textarea 变体样式
|
||||
*/
|
||||
const textareaVariants = cva(
|
||||
// 基础样式
|
||||
"flex w-full rounded-xl border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-b-2 border-gray-300 bg-transparent rounded-t-xl",
|
||||
bordered: "border-gray-300 bg-white",
|
||||
filled: "border-transparent bg-gray-100",
|
||||
},
|
||||
error: {
|
||||
true: "border-error-500 focus-visible:ring-error-500",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "filled",
|
||||
error: true,
|
||||
className: "bg-error-50",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
error: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type TextareaVariant = VariantProps<typeof textareaVariants>["variant"];
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
VariantProps<typeof textareaVariants> {
|
||||
// 自动调整高度
|
||||
autoResize?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Textarea 多行文本输入组件
|
||||
*/
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
error = false,
|
||||
className,
|
||||
autoResize = false,
|
||||
onChange,
|
||||
rows = 3,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 自动调整高度的 change 处理
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (autoResize) {
|
||||
const target = e.target;
|
||||
target.style.height = "auto";
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
className={cn(textareaVariants({ variant, error }), className)}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
Reference in New Issue
Block a user