Design System 重构完成

This commit is contained in:
2026-02-10 03:54:09 +08:00
parent fe5e8533b5
commit 73d0b0d5fe
51 changed files with 4915 additions and 8 deletions

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

View File

@@ -0,0 +1 @@
export * from './button';

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

View File

@@ -0,0 +1 @@
export * from './card';

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

View File

@@ -0,0 +1 @@
export * from './checkbox';

View File

@@ -0,0 +1 @@
export * from './input';

View 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";

View File

@@ -0,0 +1 @@
export * from './radio';

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

View File

@@ -0,0 +1 @@
export * from './select';

View 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";

View File

@@ -0,0 +1 @@
export * from './switch';

View 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";

View File

@@ -0,0 +1 @@
export * from './textarea';

View 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";