feat:init

This commit is contained in:
zhongyidian committed 2025-12-29 19:19:10 +08:00
1 parent 53d0eeac6b
commit ae0c050010
494 files changed
+17701 -2318

No files matched your search

@@ -0,0 +1,204 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import DragFill from "./drag-fill";
import type { DragFillProps } from "./drag-fill.types";
import { cn } from "@/lib/utils";
const meta: Meta<typeof DragFill> = {
title: "Components/Quiz/DragFill",
component: DragFill,
tags: ["autodocs"],
argTypes: {
options: {
control: { type: "object" },
description: "可拖拽的选项列表",
},
blanks: {
control: { type: "object" },
description: "空白区域列表",
},
value: {
control: { type: "object" },
description: "当前填充结果(受控模式)",
},
defaultValue: {
control: { type: "object" },
description: "默认填充结果",
},
disabled: {
control: { type: "boolean" },
description: "是否禁用整个组件",
},
allowDuplicate: {
control: { type: "boolean" },
description: "是否允许重复选择同一选项",
},
showSubmitButton: {
control: { type: "boolean" },
description: "是否显示提交按钮",
},
submitText: {
control: { type: "text" },
description: "提交按钮文本",
},
},
};
export default meta;
type Story = StoryObj<typeof DragFill>;
// 基础示例
export const Default: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D"],
blanks: ["空白1", "空白2", "空白3"],
onSubmit: (results) => {
console.log("填充结果:", results);
},
},
};
// 使用选项和空白对象
export const WithObjects: Story = {
args: {
options: [
{ value: "opt1", label: "选项一" },
{ value: "opt2", label: "选项二" },
{ value: "opt3", label: "选项三(禁用)", disabled: true },
{ value: "opt4", label: "选项四" },
],
blanks: [
{ id: "blank1", label: "第一个空白", required: true },
{ id: "blank2", label: "第二个空白" },
{ id: "blank3", label: "第三个空白(必填)", required: true },
],
onSubmit: (results) => {
console.log("填充结果:", results);
},
},
};
// 受控模式
export const Controlled: Story = {
render: () => {
const [value, setValue] = useState<any[]>([]);
return (
<div className="space-y-4">
<DragFill
options={["选项A", "选项B", "选项C"]}
blanks={["空白1", "空白2"]}
value={value}
onChange={setValue}
onSubmit={(results) => {
alert(`提交的结果: ${JSON.stringify(results)}`);
}}
/>
<div className="text-sm text-gray-600">
当前填充: {JSON.stringify(value)}
</div>
</div>
);
},
};
// 允许重复选择
export const AllowDuplicate: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
blanks: ["空白1", "空白2", "空白3"],
allowDuplicate: true,
onSubmit: (results) => {
console.log("填充结果:", results);
},
},
};
// 禁用状态
export const Disabled: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
blanks: ["空白1", "空白2"],
disabled: true,
defaultValue: [{ blankId: "blank1", optionValue: "选项A" }],
},
};
// 不显示提交按钮
export const WithoutSubmitButton: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
blanks: ["空白1", "空白2"],
showSubmitButton: false,
onChange: (results) => {
console.log("填充变化:", results);
},
},
};
// 自定义样式
export const CustomStyled: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D"],
blanks: ["空白1", "空白2"],
className: "max-w-2xl mx-auto p-6 bg-gray-50 rounded-xl",
optionClassName: "text-lg font-semibold",
blankClassName: "min-h-[100px]",
submitText: "确认答案",
onSubmit: (results) => {
console.log("填充结果:", results);
},
},
};
// 自定义渲染
export const CustomRender: Story = {
render: () => {
return (
<DragFill
options={["选项A", "选项B", "选项C"]}
blanks={["空白1", "空白2"]}
renderOption={(option, isUsed) => (
<div
className={cn(
"p-4 rounded-lg border-2 transition-all cursor-move",
isUsed
? "border-gray-200 bg-gray-100 opacity-50"
: "border-blue-300 bg-blue-50 hover:border-blue-500"
)}
draggable={!isUsed}
>
<div className="flex items-center gap-2">
<span className="text-2xl">📦</span>
<span className="font-medium">{option.label}</span>
</div>
</div>
)}
renderBlank={(blank, filledValue) => (
<div
className={cn(
"min-h-[100px] p-6 rounded-lg border-2 border-dashed flex items-center justify-center",
filledValue
? "border-green-400 bg-green-50"
: "border-purple-300 bg-purple-50"
)}
>
{filledValue ? (
<div className="text-green-700 font-bold text-lg">
✓ {filledValue}
</div>
) : (
<div className="text-purple-500 text-center">
<div className="text-3xl mb-2">⬇️</div>
<div>{blank.label || "拖拽到这里"}</div>
</div>
)}
</div>
)}
onSubmit={(results) => {
console.log("填充结果:", results);
}}
/>
);
},
};
+549
View File
@@ -0,0 +1,549 @@
import { useState, useRef, useEffect, useCallback, forwardRef } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import type {
DragFillProps,
DragFillOption,
DragFillBlank,
DragFillResult,
} from "./drag-fill.types";
/**
* DragFill - 拖拽填空组件(使用鼠标事件实现)
*
* 用于问答场景的拖拽填空功能,支持将选项拖拽到空白处进行填充
*
* @example
* ```tsx
* <DragFill
* options={["选项1", "选项2", "选项3"]}
* blanks={["空白1", "空白2"]}
* onSubmit={(results) => console.log(results)}
* />
* ```
*/
const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
(
{
options,
blanks,
value: controlledValue,
defaultValue = [],
onChange,
onSubmit,
disabled = false,
allowDuplicate = false,
layout = "vertical",
hideLabels = false,
className,
optionClassName,
blankClassName,
submitText = "提交",
showSubmitButton = true,
renderOption,
renderBlank,
renderDragging,
},
ref
) => {
// 标准化选项数据
const normalizedOptions: DragFillOption[] = options.map((option) => {
if (typeof option === "string") {
return { value: option, label: option };
}
return option;
});
// 标准化空白区域数据
const normalizedBlanks: DragFillBlank[] = blanks.map((blank, index) => {
if (typeof blank === "string") {
return { id: `blank-${index}`, label: blank };
}
return blank;
});
// 受控/非受控模式
const isControlled = controlledValue !== undefined;
const [internalValue, setInternalValue] = useState<DragFillResult[]>(
defaultValue || []
);
const currentValue = isControlled ? controlledValue : internalValue;
// 拖拽状态
const [draggingOption, setDraggingOption] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [hoveredBlank, setHoveredBlank] = useState<string | null>(null);
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const blankRefsMap = useRef<Map<string, HTMLElement>>(new Map());
// 检查选项是否已被使用
const isOptionUsed = useCallback(
(optionValue: string): boolean => {
if (allowDuplicate) return false;
return currentValue.some(
(result) => result.optionValue === optionValue
);
},
[allowDuplicate, currentValue]
);
// 处理鼠标移动
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!draggingOption) return;
setDragPosition({ x: e.clientX, y: e.clientY });
// 检测是否悬停在空白区域上
let foundBlank: string | null = null;
// 首先检查组件内的空白区域
blankRefsMap.current.forEach((element, blankId) => {
const rect = element.getBoundingClientRect();
if (
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom
) {
foundBlank = blankId;
}
});
// 如果没找到,尝试查找页面中所有带 data-blank-id 的元素
if (!foundBlank) {
normalizedBlanks.forEach((blank) => {
const element = document.querySelector(
`[data-blank-id="${blank.id}"]`
);
if (element) {
const rect = element.getBoundingClientRect();
if (
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom
) {
foundBlank = blank.id;
}
}
});
}
setHoveredBlank(foundBlank);
},
[draggingOption, normalizedBlanks]
);
// 处理鼠标释放
const handleMouseUp = useCallback(() => {
if (!draggingOption) return;
// 如果悬停在空白区域上,执行放置
if (hoveredBlank) {
const optionValue = draggingOption;
// 检查是否允许重复
if (!allowDuplicate) {
const isAlreadyUsed = currentValue.some(
(result) => result.optionValue === optionValue
);
if (isAlreadyUsed) {
// 如果已使用,先移除旧的填充
const newValue = currentValue.filter(
(result) => result.optionValue !== optionValue
);
// 添加新的填充
const updatedValue = [
...newValue,
{ blankId: hoveredBlank, optionValue },
];
if (!isControlled) {
setInternalValue(updatedValue);
}
onChange?.(updatedValue);
setDraggingOption(null);
setDragPosition(null);
setHoveredBlank(null);
return;
}
}
// 检查空白区域是否已有填充
const existingIndex = currentValue.findIndex(
(result) => result.blankId === hoveredBlank
);
let updatedValue: DragFillResult[];
if (existingIndex >= 0) {
// 替换现有填充
updatedValue = [...currentValue];
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue };
} else {
// 添加新填充
updatedValue = [
...currentValue,
{ blankId: hoveredBlank, optionValue },
];
}
if (!isControlled) {
setInternalValue(updatedValue);
}
onChange?.(updatedValue);
}
setDraggingOption(null);
setDragPosition(null);
setHoveredBlank(null);
}, [
draggingOption,
hoveredBlank,
allowDuplicate,
currentValue,
isControlled,
onChange,
]);
// 添加全局鼠标事件监听
useEffect(() => {
if (draggingOption) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [draggingOption, handleMouseMove, handleMouseUp]);
// 处理鼠标按下开始拖拽
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
if (disabled) return;
const option = normalizedOptions.find((opt) => opt.value === optionValue);
if (option?.disabled) return;
// 检查是否已使用
if (!allowDuplicate && isOptionUsed(optionValue)) return;
// 获取实际的元素(如果有自定义渲染,使用第一个子元素)
const wrapperElement = e.currentTarget as HTMLElement;
const actualElement = (
renderOption ? wrapperElement.firstElementChild : wrapperElement
) as HTMLElement;
if (!actualElement) return;
const rect = actualElement.getBoundingClientRect();
// 记录偏移量和元素尺寸
dragOffsetRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
setDraggingOption(optionValue);
setDragPosition({ x: e.clientX, y: e.clientY });
};
// 移除填充
const handleRemoveFill = (blankId: string) => {
if (disabled) return;
const updatedValue = currentValue.filter(
(result) => result.blankId !== blankId
);
if (!isControlled) {
setInternalValue(updatedValue);
}
onChange?.(updatedValue);
};
// 处理提交
const handleSubmit = () => {
if (disabled) return;
// 检查必填项
const requiredBlanks = normalizedBlanks.filter((blank) => blank.required);
const filledBlankIds = currentValue.map((result) => result.blankId);
const allRequiredFilled = requiredBlanks.every((blank) =>
filledBlankIds.includes(blank.id)
);
if (!allRequiredFilled) {
return;
}
onSubmit?.(currentValue);
};
// 获取空白区域的填充值
const getBlankFill = (blankId: string): string | null => {
const result = currentValue.find((result) => result.blankId === blankId);
return result ? result.optionValue : null;
};
// 默认渲染选项
const defaultRenderOption = (option: DragFillOption, isUsed: boolean) => {
const isDragging = draggingOption === option.value;
return (
<div
onMouseDown={(e) => handleMouseDown(e, option.value)}
className={cn(
"px-6 py-4 border-2 rounded-lg transition-all select-none",
!disabled &&
!option.disabled &&
!isUsed &&
"cursor-move hover:border-orange-400 hover:bg-orange-50",
isDragging
? "opacity-50 border-orange-500 bg-orange-100"
: "border-gray-300 bg-white text-gray-700",
(disabled || option.disabled || isUsed) &&
"opacity-50 cursor-not-allowed",
optionClassName
)}
>
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8h16M4 16h16"
/>
</svg>
<span>{option.label}</span>
{isUsed && !allowDuplicate && (
<span className="ml-auto text-xs text-gray-500">已使用</span>
)}
</div>
</div>
);
};
// 默认渲染空白区域
const defaultRenderBlank = (
blank: DragFillBlank,
filledValue: string | null
) => {
const isHovered = hoveredBlank === blank.id;
const option = filledValue
? normalizedOptions.find((opt) => opt.value === filledValue)
: null;
return (
<div
ref={(el) => {
if (el) {
blankRefsMap.current.set(blank.id, el);
} else {
blankRefsMap.current.delete(blank.id);
}
}}
className={cn(
"min-h-[80px] px-6 py-4 border-2 border-dashed rounded-lg transition-all",
"flex items-center justify-between",
isHovered && draggingOption
? "border-orange-500 bg-orange-50"
: filledValue
? "border-green-500 bg-green-50"
: "border-gray-300 bg-gray-50",
blank.required && !filledValue && "border-red-300",
blankClassName
)}
>
{filledValue ? (
<>
<span className="text-green-700 font-medium">
{option?.label || filledValue}
</span>
{!disabled && (
<button
onClick={() => handleRemoveFill(blank.id)}
className="text-red-500 hover:text-red-700 transition-colors"
type="button"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</>
) : (
<span className="text-gray-500">
{blank.label || "拖拽选项到这里"}
</span>
)}
</div>
);
};
return (
<>
<div
ref={ref}
className={cn(
"flex gap-6 w-full h-full",
layout === "horizontal" ? "flex-row items-start" : "flex-col",
layout === "horizontal-options" && "flex-col",
className
)}
>
{/* 选项区域 */}
<div
className={cn(
"flex flex-col gap-3",
layout === "horizontal" && "flex-1",
layout === "horizontal-options" && "w-full flex-1"
)}
>
{!hideLabels && (
<h3 className="text-lg font-semibold text-gray-700">选项</h3>
)}
<div
className={cn(
layout === "horizontal-options"
? "flex flex-row gap-6 flex-wrap w-full"
: cn(
"grid gap-x-12 gap-y-16",
normalizedOptions.length > 2
? "grid-cols-2"
: "grid-cols-1"
)
)}
>
{normalizedOptions.map((option) => {
const isUsed = isOptionUsed(option.value);
const isDragging = draggingOption === option.value;
return (
<div
key={option.value}
onMouseDown={(e) => {
// 只有在没有自定义渲染或者不是已使用的情况下才处理拖拽
if (!disabled && !option.disabled && !isUsed) {
handleMouseDown(e, option.value);
}
}}
className={cn(
renderOption && "select-none flex-1",
isDragging && "opacity-50"
)}
>
{renderOption
? renderOption(option, isUsed)
: defaultRenderOption(option, isUsed)}
</div>
);
})}
</div>
</div>
{/* 空白区域 */}
<div
className={cn(
"flex flex-col gap-3",
layout === "horizontal" && "flex-1"
)}
>
{!hideLabels && (
<h3 className="text-lg font-semibold text-gray-700">填空</h3>
)}
<div className="flex flex-col gap-3">
{normalizedBlanks.map((blank) => {
const filledValue = getBlankFill(blank.id);
const isHovered = hoveredBlank === blank.id && !!draggingOption;
return (
<div
key={blank.id}
ref={(el) => {
if (el) {
// 如果使用自定义渲染,需要找到实际的空白区域元素
// 默认使用第一个子元素
const targetEl = renderBlank
? (el.firstElementChild as HTMLElement)
: el;
if (targetEl) {
blankRefsMap.current.set(blank.id, targetEl);
}
} else {
blankRefsMap.current.delete(blank.id);
}
}}
>
{renderBlank
? renderBlank(blank, filledValue, isHovered)
: defaultRenderBlank(blank, filledValue)}
</div>
);
})}
</div>
</div>
{/* 提交按钮 */}
{showSubmitButton && (
<button
onClick={handleSubmit}
disabled={disabled}
className={cn(
"px-6 py-3 rounded-lg font-medium transition-colors",
"bg-orange-500 text-white hover:bg-orange-600",
"disabled:opacity-50 disabled:cursor-not-allowed",
"focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
)}
>
{submitText}
</button>
)}
</div>
{/* 拖拽中的浮动元素 - 匹配选项框样式 */}
{draggingOption &&
dragPosition &&
createPortal(
<div
className="fixed pointer-events-none z-[99999]"
style={{
left: dragPosition.x,
top: dragPosition.y,
transform: "translate(-50%, -50%)",
}}
>
{(() => {
const option = normalizedOptions.find(
(opt) => opt.value === draggingOption
);
if (!option) return null;
if (renderDragging) {
return renderDragging(option);
}
return (
<span className="text-[28px] font-bold text-[#f0be75] drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
{option.label}
</span>
);
})()}
</div>,
document.body
)}
</>
);
}
);
DragFill.displayName = "DragFill";
export default DragFill;
@@ -0,0 +1,73 @@
import { ReactNode } from "react";
/** 选项数据 */
export interface DragFillOption {
/** 选项值 */
value: string;
/** 选项显示文本 */
label: string;
/** 是否禁用 */
disabled?: boolean;
}
/** 空白区域数据 */
export interface DragFillBlank {
/** 空白区域ID */
id: string;
/** 空白区域显示文本/提示 */
label?: string;
/** 是否必填 */
required?: boolean;
}
/** 填充结果 */
export interface DragFillResult {
/** 空白区域ID */
blankId: string;
/** 填充的选项值 */
optionValue: string;
}
/** 拖拽填空组件配置项 */
export interface DragFillProps {
/** 可拖拽的选项列表 */
options: DragFillOption[] | string[];
/** 空白区域列表 */
blanks: DragFillBlank[] | string[];
/** 当前填充结果(受控模式) */
value?: DragFillResult[];
/** 默认填充结果 */
defaultValue?: DragFillResult[];
/** 填充结果变化时的回调 */
onChange?: (results: DragFillResult[]) => void;
/** 提交时的回调 */
onSubmit?: (results: DragFillResult[]) => void;
/** 是否禁用整个组件 */
disabled?: boolean;
/** 是否允许重复选择同一选项 */
allowDuplicate?: boolean;
/** 布局方向:vertical(上下)、horizontal(左右)或 horizontal-options(选项水平排列) */
layout?: "vertical" | "horizontal" | "horizontal-options";
/** 是否隐藏"选项"和"填空"标签 */
hideLabels?: boolean;
/** 自定义类名 */
className?: string;
/** 选项容器的自定义类名 */
optionClassName?: string;
/** 空白区域容器的自定义类名 */
blankClassName?: string;
/** 提交按钮文本 */
submitText?: string;
/** 是否显示提交按钮 */
showSubmitButton?: boolean;
/** 自定义渲染选项 */
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode;
/** 自定义渲染空白区域 */
renderBlank?: (
blank: DragFillBlank,
filledValue: string | null,
isHovered: boolean
) => ReactNode;
/** 自定义渲染拖拽中的元素 */
renderDragging?: (option: DragFillOption) => ReactNode;
}
+9
View File
@@ -0,0 +1,9 @@
export { default } from "./drag-fill";
export { default as DragFill } from "./drag-fill";
export type {
DragFillProps,
DragFillOption,
DragFillBlank,
DragFillResult,
} from "./drag-fill.types";
+23
View File
@@ -0,0 +1,23 @@
// Single Choice component
export { SingleChoice, default as SingleChoiceDefault } from "./single-choice";
export type {
SingleChoiceProps,
SingleChoiceOption,
} from "./single-choice";
// Multiple Choice component
export { MultipleChoice } from "./multiple-choice";
export type {
MultipleChoiceProps,
MultipleChoiceOption,
} from "./multiple-choice";
// Drag Fill component
export { DragFill } from "./drag-fill";
export type {
DragFillProps,
DragFillOption,
DragFillBlank,
DragFillResult,
} from "./drag-fill";
@@ -0,0 +1,7 @@
export { default } from "./multiple-choice";
export { default as MultipleChoice } from "./multiple-choice";
export type {
MultipleChoiceProps,
MultipleChoiceOption,
} from "./multiple-choice.types";
@@ -0,0 +1,193 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import MultipleChoice from "./multiple-choice";
import type { MultipleChoiceProps } from "./multiple-choice.types";
import { cn } from "@/lib/utils";
const meta: Meta<typeof MultipleChoice> = {
title: "Components/Quiz/MultipleChoice",
component: MultipleChoice,
tags: ["autodocs"],
argTypes: {
options: {
control: { type: "object" },
description: "选项列表,可以是字符串数组或选项对象数组",
},
value: {
control: { type: "object" },
description: "当前选中的值数组(受控模式)",
},
defaultValue: {
control: { type: "object" },
description: "默认选中的值数组",
},
disabled: {
control: { type: "boolean" },
description: "是否禁用整个组件",
},
minSelections: {
control: { type: "number" },
description: "最小选择数量",
},
maxSelections: {
control: { type: "number" },
description: "最大选择数量",
},
showSubmitButton: {
control: { type: "boolean" },
description: "是否显示提交按钮",
},
submitText: {
control: { type: "text" },
description: "提交按钮文本",
},
},
};
export default meta;
type Story = StoryObj<typeof MultipleChoice>;
// 基础示例
export const Default: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
onSubmit: (values) => {
console.log("选中的值:", values);
},
},
};
// 使用选项对象
export const WithOptionObjects: Story = {
args: {
options: [
{ value: "opt1", label: "选项一" },
{ value: "opt2", label: "选项二" },
{ value: "opt3", label: "选项三(禁用)", disabled: true },
{ value: "opt4", label: "选项四" },
{ value: "opt5", label: "选项五" },
],
onSubmit: (values) => {
console.log("选中的值:", values);
},
},
};
// 受控模式
export const Controlled: Story = {
render: () => {
const [values, setValues] = useState<string[]>([]);
return (
<div className="space-y-4">
<MultipleChoice
options={["选项A", "选项B", "选项C", "选项D"]}
value={values}
onChange={setValues}
onSubmit={(vals) => {
alert(`提交的值: ${vals.join(", ")}`);
}}
/>
<div className="text-sm text-gray-600">
当前选中: {values.length > 0 ? values.join(", ") : "未选择"}
</div>
</div>
);
},
};
// 限制最大选择数量
export const WithMaxSelections: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
maxSelections: 2,
onSubmit: (values) => {
console.log("选中的值:", values);
},
},
};
// 限制最小和最大选择数量
export const WithMinMaxSelections: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
minSelections: 2,
maxSelections: 4,
onSubmit: (values) => {
console.log("选中的值:", values);
},
},
};
// 禁用状态
export const Disabled: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
disabled: true,
defaultValue: ["选项A"],
},
};
// 不显示提交按钮
export const WithoutSubmitButton: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
showSubmitButton: false,
onChange: (values) => {
console.log("值变化:", values);
},
},
};
// 自定义样式
export const CustomStyled: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D"],
className: "max-w-md mx-auto p-6 bg-gray-50 rounded-xl",
optionClassName: "text-lg font-semibold",
submitText: "确认答案",
maxSelections: 3,
onSubmit: (values) => {
console.log("选中的值:", values);
},
},
};
// 自定义渲染选项
export const CustomRender: Story = {
render: () => {
return (
<MultipleChoice
options={["选项A", "选项B", "选项C", "选项D"]}
maxSelections={2}
renderOption={(option, isSelected) => (
<div
className={cn(
"p-4 rounded-lg border-2 transition-all cursor-pointer",
isSelected
? "border-purple-500 bg-purple-100"
: "border-gray-200 bg-white"
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-6 h-6 rounded border-2 flex items-center justify-center text-lg",
isSelected
? "border-purple-500 bg-purple-500 text-white"
: "border-gray-300"
)}
>
{isSelected && "✓"}
</div>
<span className="font-medium">{option.label}</span>
</div>
</div>
)}
onSubmit={(values) => {
console.log("选中的值:", values);
}}
/>
);
},
};
@@ -0,0 +1,223 @@
import { useState, useEffect, forwardRef } from "react";
import { cn } from "@/lib/utils";
import type {
MultipleChoiceProps,
MultipleChoiceOption,
} from "./multiple-choice.types";
/**
* MultipleChoice - 多选组件
*
* 用于问答场景的多选功能,支持选项列表、多选状态管理、最小/最大选择数量限制和提交回调
*
* @example
* ```tsx
* <MultipleChoice
* options={["选项A", "选项B", "选项C"]}
* maxSelections={2}
* onSubmit={(values) => console.log(values)}
* />
* ```
*/
const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
(
{
options,
value: controlledValue,
defaultValue = [],
onChange,
onSubmit,
disabled = false,
minSelections,
maxSelections,
className,
optionClassName,
submitText = "提交",
showSubmitButton = true,
renderOption,
},
ref
) => {
// 标准化选项数据
const normalizedOptions: MultipleChoiceOption[] = options.map((option) => {
if (typeof option === "string") {
return { value: option, label: option };
}
return option;
});
// 受控/非受控模式
const isControlled = controlledValue !== undefined;
const [internalValue, setInternalValue] = useState<string[]>(
defaultValue || []
);
const currentValue = isControlled ? controlledValue : internalValue;
// 处理选项点击
const handleOptionClick = (optionValue: string) => {
if (disabled) return;
const option = normalizedOptions.find((opt) => opt.value === optionValue);
if (option?.disabled) return;
const isSelected = currentValue.includes(optionValue);
let newValue: string[];
if (isSelected) {
// 取消选择
newValue = currentValue.filter((v) => v !== optionValue);
// 检查最小选择数量限制
if (
minSelections !== undefined &&
newValue.length < minSelections
) {
return;
}
} else {
// 选择
// 检查最大选择数量限制
if (
maxSelections !== undefined &&
currentValue.length >= maxSelections
) {
return;
}
newValue = [...currentValue, optionValue];
}
if (!isControlled) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
// 处理提交
const handleSubmit = () => {
if (disabled) return;
// 检查最小选择数量
if (
minSelections !== undefined &&
currentValue.length < minSelections
) {
return;
}
onSubmit?.(currentValue);
};
// 检查是否可以提交
const canSubmit =
currentValue.length > 0 &&
(minSelections === undefined ||
currentValue.length >= minSelections);
// 默认渲染选项
const defaultRenderOption = (
option: MultipleChoiceOption,
isSelected: boolean
) => {
const isDisabled =
disabled ||
option.disabled ||
(!isSelected &&
maxSelections !== undefined &&
currentValue.length >= maxSelections);
return (
<div
className={cn(
"px-6 py-4 border-2 rounded-lg cursor-pointer transition-all",
"hover:border-green-400 hover:bg-green-50",
isSelected
? "border-green-500 bg-green-100 text-green-700"
: "border-gray-300 bg-white text-gray-700",
isDisabled && "opacity-50 cursor-not-allowed",
optionClassName
)}
onClick={() => handleOptionClick(option.value)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "border-green-500 bg-green-500"
: "border-gray-300 bg-white"
)}
>
{isSelected && (
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
<span>{option.label}</span>
</div>
</div>
);
};
return (
<div ref={ref} className={cn("flex flex-col gap-4", className)}>
<div className="flex flex-col gap-3">
{normalizedOptions.map((option) => {
const isSelected = currentValue.includes(option.value);
return (
<div key={option.value}>
{renderOption
? renderOption(option, isSelected)
: defaultRenderOption(option, isSelected)}
</div>
);
})}
</div>
{showSubmitButton && (
<div className="flex flex-col gap-2">
{minSelections !== undefined && (
<div className="text-sm text-gray-500">
至少选择 {minSelections} 项
{maxSelections !== undefined &&
`,最多选择 ${maxSelections} 项`}
{maxSelections === undefined && "(无上限)"}
</div>
)}
{maxSelections !== undefined && minSelections === undefined && (
<div className="text-sm text-gray-500">
最多选择 {maxSelections} 项
</div>
)}
<div className="text-sm text-gray-500">
已选择 {currentValue.length} 项
</div>
<button
onClick={handleSubmit}
disabled={disabled || !canSubmit}
className={cn(
"px-6 py-3 rounded-lg font-medium transition-colors",
"bg-green-500 text-white hover:bg-green-600",
"disabled:opacity-50 disabled:cursor-not-allowed",
"focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
)}
>
{submitText}
</button>
</div>
)}
</div>
);
}
);
MultipleChoice.displayName = "MultipleChoice";
export default MultipleChoice;
@@ -0,0 +1,45 @@
import { ReactNode } from "react";
/** 选项数据 */
export interface MultipleChoiceOption {
/** 选项值 */
value: string;
/** 选项显示文本 */
label: string;
/** 是否禁用 */
disabled?: boolean;
}
/** 多选组件配置项 */
export interface MultipleChoiceProps {
/** 选项列表 */
options: MultipleChoiceOption[] | string[];
/** 当前选中的值数组(受控模式) */
value?: string[];
/** 默认选中的值数组 */
defaultValue?: string[];
/** 选中值变化时的回调 */
onChange?: (values: string[]) => void;
/** 提交时的回调 */
onSubmit?: (values: string[]) => void;
/** 是否禁用整个组件 */
disabled?: boolean;
/** 最小选择数量 */
minSelections?: number;
/** 最大选择数量 */
maxSelections?: number;
/** 自定义类名 */
className?: string;
/** 选项容器的自定义类名 */
optionClassName?: string;
/** 提交按钮文本 */
submitText?: string;
/** 是否显示提交按钮 */
showSubmitButton?: boolean;
/** 自定义渲染选项 */
renderOption?: (
option: MultipleChoiceOption,
isSelected: boolean
) => ReactNode;
}
@@ -0,0 +1,7 @@
export { default } from "./single-choice";
export { default as SingleChoice } from "./single-choice";
export type {
SingleChoiceProps,
SingleChoiceOption,
} from "./single-choice.types";
@@ -0,0 +1,161 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import SingleChoice from "./single-choice";
import type { SingleChoiceProps } from "./single-choice.types";
import { cn } from "@/lib/utils";
const meta: Meta<typeof SingleChoice> = {
title: "Components/Quiz/SingleChoice",
component: SingleChoice,
tags: ["autodocs"],
argTypes: {
options: {
control: { type: "object" },
description: "选项列表,可以是字符串数组或选项对象数组",
},
value: {
control: { type: "text" },
description: "当前选中的值(受控模式)",
},
defaultValue: {
control: { type: "text" },
description: "默认选中的值",
},
disabled: {
control: { type: "boolean" },
description: "是否禁用整个组件",
},
showSubmitButton: {
control: { type: "boolean" },
description: "是否显示提交按钮",
},
submitText: {
control: { type: "text" },
description: "提交按钮文本",
},
},
};
export default meta;
type Story = StoryObj<typeof SingleChoice>;
// 基础示例
export const Default: Story = {
args: {
options: ["选项A", "选项B", "选项C", "选项D"],
onSubmit: (value) => {
console.log("选中的值:", value);
},
},
};
// 使用选项对象
export const WithOptionObjects: Story = {
args: {
options: [
{ value: "opt1", label: "选项一" },
{ value: "opt2", label: "选项二" },
{ value: "opt3", label: "选项三(禁用)", disabled: true },
{ value: "opt4", label: "选项四" },
],
onSubmit: (value) => {
console.log("选中的值:", value);
},
},
};
// 受控模式
export const Controlled: Story = {
render: () => {
const [value, setValue] = useState<string>("");
return (
<div className="space-y-4">
<SingleChoice
options={["选项A", "选项B", "选项C"]}
value={value}
onChange={setValue}
onSubmit={(val) => {
alert(`提交的值: ${val}`);
}}
/>
<div className="text-sm text-gray-600">
当前选中: {value || "未选择"}
</div>
</div>
);
},
};
// 禁用状态
export const Disabled: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
disabled: true,
defaultValue: "选项A",
},
};
// 不显示提交按钮
export const WithoutSubmitButton: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
showSubmitButton: false,
onChange: (value) => {
console.log("值变化:", value);
},
},
};
// 自定义样式
export const CustomStyled: Story = {
args: {
options: ["选项A", "选项B", "选项C"],
className: "max-w-md mx-auto p-6 bg-gray-50 rounded-xl",
optionClassName: "text-lg font-semibold",
submitText: "确认答案",
onSubmit: (value) => {
console.log("选中的值:", value);
},
},
};
// 自定义渲染选项
export const CustomRender: Story = {
render: () => {
return (
<SingleChoice
options={["选项A", "选项B", "选项C"]}
renderOption={(option, isSelected) => (
<div
className={cn(
"p-4 rounded-lg border-2 transition-all cursor-pointer",
isSelected
? "border-purple-500 bg-purple-100"
: "border-gray-200 bg-white"
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-5 h-5 rounded-full border-2 flex items-center justify-center",
isSelected
? "border-purple-500 bg-purple-500"
: "border-gray-300"
)}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
<span className="font-medium">{option.label}</span>
</div>
</div>
)}
onSubmit={(value) => {
console.log("选中的值:", value);
}}
/>
);
},
};
@@ -0,0 +1,116 @@
import { useState, forwardRef } from "react";
import { cn } from "@/lib/utils";
import type {
SingleChoiceProps,
SingleChoiceOption,
} from "./single-choice.types";
/**
* SingleChoice - 单选组件
*
* 用于问答场景的单选功能,支持选项列表、选中状态管理和提交回调
*
* @example
* ```tsx
* <SingleChoice
* options={["选项A", "选项B", "选项C"]}
* onSubmit={(value) => console.log(value)}
* />
* ```
*/
const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
(
{
options,
value: controlledValue,
defaultValue,
onChange,
onSubmit,
disabled = false,
className,
optionClassName,
submitText: _submitText = "提交", // 保留入参但不使用
showSubmitButton: _showSubmitButton = false, // 保留入参但不使用
renderOption,
},
ref
) => {
// 标准化选项数据
const normalizedOptions: SingleChoiceOption[] = options.map((option) => {
if (typeof option === "string") {
return { value: option, label: option };
}
return option;
});
// 受控/非受控模式
const isControlled = controlledValue !== undefined;
const [internalValue, setInternalValue] = useState<string>(
defaultValue || ""
);
const currentValue = isControlled ? controlledValue : internalValue;
// 处理选项点击
const handleOptionClick = (optionValue: string) => {
if (disabled) return;
if (
normalizedOptions.find((opt) => opt.value === optionValue)?.disabled
) {
return;
}
if (!isControlled) {
setInternalValue(optionValue);
}
onChange?.(optionValue);
// 单选题:点击即触发提交回调
onSubmit?.(optionValue);
};
// 默认渲染选项
const defaultRenderOption = (
option: SingleChoiceOption,
isSelected: boolean
) => {
return (
<div
className={cn(
"px-6 py-4 border-2 rounded-lg cursor-pointer transition-all",
"hover:border-blue-400 hover:bg-blue-50",
isSelected
? "border-blue-500 bg-blue-100 text-blue-700"
: "border-gray-300 bg-white text-gray-700",
option.disabled && "opacity-50 cursor-not-allowed",
optionClassName
)}
onClick={() => handleOptionClick(option.value)}
>
{option.label}
</div>
);
};
return (
<div ref={ref} className={cn("flex flex-col gap-4", className)}>
<div className="flex flex-col gap-3">
{normalizedOptions.map((option) => {
const isSelected = currentValue === option.value;
return (
<div key={option.value}>
{renderOption
? renderOption(option, isSelected)
: defaultRenderOption(option, isSelected)}
</div>
);
})}
</div>
{/* 提交按钮移除:点击选项即提交 */}
</div>
);
}
);
SingleChoice.displayName = "SingleChoice";
export default SingleChoice;
@@ -0,0 +1,38 @@
import { ReactNode } from "react";
/** 选项数据 */
export interface SingleChoiceOption {
/** 选项值 */
value: string;
/** 选项显示文本 */
label: string;
/** 是否禁用 */
disabled?: boolean;
}
/** 单选组件配置项 */
export interface SingleChoiceProps {
/** 选项列表 */
options: SingleChoiceOption[] | string[];
/** 当前选中的值 */
value?: string;
/** 默认选中的值 */
defaultValue?: string;
/** 选中值变化时的回调 */
onChange?: (value: string) => void;
/** 提交时的回调 */
onSubmit?: (value: string) => void;
/** 是否禁用整个组件 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
/** 选项容器的自定义类名 */
optionClassName?: string;
/** 提交按钮文本 */
submitText?: string;
/** 是否显示提交按钮 */
showSubmitButton?: boolean;
/** 自定义渲染选项 */
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode;
}