183 lines
4.7 KiB
TypeScript
183 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import React, { forwardRef, useState } from "react";
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
import { cn } from "@/utils/cn";
|
|
|
|
/**
|
|
* 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);
|
|
};
|
|
|
|
// 确保 size 有默认值
|
|
const actualSize = size ?? "md";
|
|
|
|
// 滑块大小
|
|
const thumbSize = {
|
|
sm: "h-3.5 w-3.5",
|
|
md: "h-4 w-4",
|
|
lg: "h-5 w-5",
|
|
}[actualSize];
|
|
|
|
// 滑块位移
|
|
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",
|
|
}[actualSize];
|
|
|
|
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";
|