feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。 - 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。 - 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。 - 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。 - 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
This commit is contained in:
1 parent
5d00adc1a5
commit
76c67b492e
216 files changed
+12454
-13976
No files matched your search
@@ -1,169 +1,167 @@
|
||||
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";
|
||||
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",
|
||||
title: 'Components/Quiz/DragFill',
|
||||
component: DragFill,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "可拖拽的选项列表",
|
||||
control: { type: 'object' },
|
||||
description: '可拖拽的选项列表',
|
||||
},
|
||||
blanks: {
|
||||
control: { type: "object" },
|
||||
description: "空白区域列表",
|
||||
control: { type: 'object' },
|
||||
description: '空白区域列表',
|
||||
},
|
||||
value: {
|
||||
control: { type: "object" },
|
||||
description: "当前填充结果(受控模式)",
|
||||
control: { type: 'object' },
|
||||
description: '当前填充结果(受控模式)',
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "object" },
|
||||
description: "默认填充结果",
|
||||
control: { type: 'object' },
|
||||
description: '默认填充结果',
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否禁用整个组件',
|
||||
},
|
||||
allowDuplicate: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否允许重复选择同一选项",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否允许重复选择同一选项',
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否显示提交按钮',
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
control: { type: 'text' },
|
||||
description: '提交按钮文本',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DragFill>;
|
||||
export default meta
|
||||
type Story = StoryObj<typeof DragFill>
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
blanks: ["空白1", "空白2", "空白3"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D'],
|
||||
blanks: ['空白1', '空白2', '空白3'],
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 使用选项和空白对象
|
||||
export const WithObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
{ 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 },
|
||||
{ id: 'blank1', label: '第一个空白', required: true },
|
||||
{ id: 'blank2', label: '第二个空白' },
|
||||
{ id: 'blank3', label: '第三个空白(必填)', required: true },
|
||||
],
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<any[]>([]);
|
||||
const [value, setValue] = useState<any[]>([])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DragFill
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
blanks={["空白1", "空白2"]}
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
blanks={['空白1', '空白2']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(results) => {
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`);
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前填充: {JSON.stringify(value)}
|
||||
</div>
|
||||
<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"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
blanks: ['空白1', '空白2', '空白3'],
|
||||
allowDuplicate: true,
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
blanks: ['空白1', '空白2'],
|
||||
disabled: true,
|
||||
defaultValue: [{ blankId: "blank1", optionValue: "选项A" }],
|
||||
defaultValue: [{ blankId: 'blank1', optionValue: '选项A' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
blanks: ['空白1', '空白2'],
|
||||
showSubmitButton: false,
|
||||
onChange: (results) => {
|
||||
console.log("填充变化:", 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: "确认答案",
|
||||
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);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义渲染
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DragFill
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
blanks={["空白1", "空白2"]}
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
blanks={['空白1', '空白2']}
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border-2 transition-all cursor-move",
|
||||
'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"
|
||||
? 'border-gray-200 bg-gray-100 opacity-50'
|
||||
: 'border-blue-300 bg-blue-50 hover:border-blue-500'
|
||||
)}
|
||||
draggable={!isUsed}
|
||||
>
|
||||
@@ -176,29 +174,24 @@ export const CustomRender: Story = {
|
||||
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"
|
||||
'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-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>{blank.label || '拖拽到这里'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useRef, useEffect, useCallback, forwardRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
} from './drag-fill.types'
|
||||
|
||||
/**
|
||||
* DragFill - 拖拽填空组件(使用鼠标事件实现)
|
||||
@@ -33,12 +33,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
allowDuplicate = false,
|
||||
layout = "vertical",
|
||||
layout = 'vertical',
|
||||
hideLabels = false,
|
||||
className,
|
||||
optionClassName,
|
||||
blankClassName,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
showSubmitButton = true,
|
||||
renderOption,
|
||||
renderBlank,
|
||||
@@ -48,205 +48,180 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: DragFillOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return option
|
||||
})
|
||||
|
||||
// 标准化空白区域数据
|
||||
const normalizedBlanks: DragFillBlank[] = blanks.map((blank, index) => {
|
||||
if (typeof blank === "string") {
|
||||
return { id: `blank-${index}`, label: blank };
|
||||
if (typeof blank === 'string') {
|
||||
return { id: `blank-${index}`, label: blank }
|
||||
}
|
||||
return blank;
|
||||
});
|
||||
return blank
|
||||
})
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<DragFillResult[]>(
|
||||
defaultValue || []
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
const isControlled = controlledValue !== undefined
|
||||
const [internalValue, setInternalValue] = useState<DragFillResult[]>(defaultValue || [])
|
||||
const currentValue = isControlled ? controlledValue : internalValue
|
||||
|
||||
// 拖拽状态
|
||||
const [draggingOption, setDraggingOption] = useState<string | null>(null);
|
||||
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());
|
||||
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
|
||||
);
|
||||
if (allowDuplicate) return false
|
||||
return currentValue.some((result) => result.optionValue === optionValue)
|
||||
},
|
||||
[allowDuplicate, currentValue]
|
||||
);
|
||||
)
|
||||
|
||||
// 通用的坐标更新逻辑(鼠标/触摸)
|
||||
const updateDragPosition = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!draggingOption) return;
|
||||
if (!draggingOption) return
|
||||
|
||||
setDragPosition({ x: clientX, y: clientY });
|
||||
setDragPosition({ x: clientX, y: clientY })
|
||||
|
||||
// 检测是否悬停在空白区域上
|
||||
let foundBlank: string | null = null;
|
||||
let foundBlank: string | null = null
|
||||
|
||||
// 首先检查组件内的空白区域
|
||||
blankRefsMap.current.forEach((element, blankId) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blankId;
|
||||
foundBlank = blankId
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// 如果没找到,尝试查找页面中所有带 data-blank-id 的元素
|
||||
if (!foundBlank) {
|
||||
normalizedBlanks.forEach((blank) => {
|
||||
const element = document.querySelector(
|
||||
`[data-blank-id="${blank.id}"]`
|
||||
);
|
||||
const element = document.querySelector(`[data-blank-id="${blank.id}"]`)
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blank.id;
|
||||
foundBlank = blank.id
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
setHoveredBlank(foundBlank);
|
||||
setHoveredBlank(foundBlank)
|
||||
},
|
||||
[draggingOption, normalizedBlanks]
|
||||
);
|
||||
)
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
updateDragPosition(e.clientX, e.clientY);
|
||||
updateDragPosition(e.clientX, e.clientY)
|
||||
},
|
||||
[updateDragPosition]
|
||||
);
|
||||
)
|
||||
|
||||
// 处理触摸移动
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
// 阻止页面滚动,确保拖拽顺滑
|
||||
e.preventDefault();
|
||||
updateDragPosition(touch.clientX, touch.clientY);
|
||||
e.preventDefault()
|
||||
updateDragPosition(touch.clientX, touch.clientY)
|
||||
},
|
||||
[updateDragPosition]
|
||||
);
|
||||
)
|
||||
|
||||
// 处理释放(鼠标/触摸)
|
||||
const handleRelease = useCallback(() => {
|
||||
if (!draggingOption) return;
|
||||
if (!draggingOption) return
|
||||
|
||||
// 如果悬停在空白区域上,执行放置
|
||||
if (hoveredBlank) {
|
||||
const optionValue = draggingOption;
|
||||
const optionValue = draggingOption
|
||||
|
||||
// 检查是否允许重复
|
||||
if (!allowDuplicate) {
|
||||
const isAlreadyUsed = currentValue.some(
|
||||
(result) => result.optionValue === optionValue
|
||||
);
|
||||
const isAlreadyUsed = currentValue.some((result) => result.optionValue === optionValue)
|
||||
if (isAlreadyUsed) {
|
||||
// 如果已使用,先移除旧的填充
|
||||
const newValue = currentValue.filter(
|
||||
(result) => result.optionValue !== optionValue
|
||||
);
|
||||
const newValue = currentValue.filter((result) => result.optionValue !== optionValue)
|
||||
// 添加新的填充
|
||||
const updatedValue = [
|
||||
...newValue,
|
||||
{ blankId: hoveredBlank, optionValue },
|
||||
];
|
||||
const updatedValue = [...newValue, { blankId: hoveredBlank, optionValue }]
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
setInternalValue(updatedValue)
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
setDraggingOption(null);
|
||||
setDragPosition(null);
|
||||
setHoveredBlank(null);
|
||||
return;
|
||||
onChange?.(updatedValue)
|
||||
setDraggingOption(null)
|
||||
setDragPosition(null)
|
||||
setHoveredBlank(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查空白区域是否已有填充
|
||||
const existingIndex = currentValue.findIndex(
|
||||
(result) => result.blankId === hoveredBlank
|
||||
);
|
||||
const existingIndex = currentValue.findIndex((result) => result.blankId === hoveredBlank)
|
||||
|
||||
let updatedValue: DragFillResult[];
|
||||
let updatedValue: DragFillResult[]
|
||||
if (existingIndex >= 0) {
|
||||
// 替换现有填充
|
||||
updatedValue = [...currentValue];
|
||||
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue };
|
||||
updatedValue = [...currentValue]
|
||||
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue }
|
||||
} else {
|
||||
// 添加新填充
|
||||
updatedValue = [
|
||||
...currentValue,
|
||||
{ blankId: hoveredBlank, optionValue },
|
||||
];
|
||||
updatedValue = [...currentValue, { blankId: hoveredBlank, optionValue }]
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
setInternalValue(updatedValue)
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
onChange?.(updatedValue)
|
||||
}
|
||||
|
||||
setDraggingOption(null);
|
||||
setDragPosition(null);
|
||||
setHoveredBlank(null);
|
||||
}, [
|
||||
draggingOption,
|
||||
hoveredBlank,
|
||||
allowDuplicate,
|
||||
currentValue,
|
||||
isControlled,
|
||||
onChange,
|
||||
]);
|
||||
setDraggingOption(null)
|
||||
setDragPosition(null)
|
||||
setHoveredBlank(null)
|
||||
}, [draggingOption, hoveredBlank, allowDuplicate, currentValue, isControlled, onChange])
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
useEffect(() => {
|
||||
if (draggingOption) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleRelease);
|
||||
document.addEventListener("touchmove", handleTouchMove, {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleRelease)
|
||||
document.addEventListener('touchmove', handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", handleRelease);
|
||||
document.addEventListener("touchcancel", handleRelease);
|
||||
})
|
||||
document.addEventListener('touchend', handleRelease)
|
||||
document.addEventListener('touchcancel', handleRelease)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleRelease);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleRelease);
|
||||
document.removeEventListener("touchcancel", handleRelease);
|
||||
};
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleRelease)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleRelease)
|
||||
document.removeEventListener('touchcancel', handleRelease)
|
||||
}
|
||||
}
|
||||
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease]);
|
||||
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease])
|
||||
|
||||
// 开始拖拽(通用)
|
||||
const startDragging = (
|
||||
@@ -255,98 +230,93 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
clientY: number,
|
||||
currentTarget: HTMLElement
|
||||
) => {
|
||||
if (disabled) return;
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue);
|
||||
if (option?.disabled) return;
|
||||
if (disabled) return
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue)
|
||||
if (option?.disabled) return
|
||||
|
||||
// 检查是否已使用
|
||||
if (!allowDuplicate && isOptionUsed(optionValue)) return;
|
||||
if (!allowDuplicate && isOptionUsed(optionValue)) return
|
||||
|
||||
// 获取实际的元素(如果有自定义渲染,使用第一个子元素)
|
||||
const wrapperElement = currentTarget;
|
||||
const wrapperElement = currentTarget
|
||||
const actualElement = renderOption
|
||||
? (wrapperElement.firstElementChild as HTMLElement)
|
||||
: wrapperElement;
|
||||
: wrapperElement
|
||||
|
||||
if (!actualElement) return;
|
||||
if (!actualElement) return
|
||||
|
||||
const rect = actualElement.getBoundingClientRect();
|
||||
const rect = actualElement.getBoundingClientRect()
|
||||
|
||||
// 记录偏移量和元素尺寸
|
||||
dragOffsetRef.current = {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
setDraggingOption(optionValue);
|
||||
setDragPosition({ x: clientX, y: clientY });
|
||||
};
|
||||
setDraggingOption(optionValue)
|
||||
setDragPosition({ x: clientX, y: clientY })
|
||||
}
|
||||
|
||||
// 处理鼠标按下开始拖拽
|
||||
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
|
||||
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget as HTMLElement);
|
||||
};
|
||||
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget as HTMLElement)
|
||||
}
|
||||
|
||||
// 处理触摸开始拖拽
|
||||
const handleTouchStart = (e: React.TouchEvent, optionValue: string) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
// 防止滚动干扰
|
||||
e.preventDefault();
|
||||
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement);
|
||||
};
|
||||
e.preventDefault()
|
||||
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement)
|
||||
}
|
||||
|
||||
// 移除填充
|
||||
const handleRemoveFill = (blankId: string) => {
|
||||
if (disabled) return;
|
||||
const updatedValue = currentValue.filter(
|
||||
(result) => result.blankId !== blankId
|
||||
);
|
||||
if (disabled) return
|
||||
const updatedValue = currentValue.filter((result) => result.blankId !== blankId)
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
setInternalValue(updatedValue)
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
};
|
||||
onChange?.(updatedValue)
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
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)
|
||||
);
|
||||
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;
|
||||
return
|
||||
}
|
||||
onSubmit?.(currentValue);
|
||||
};
|
||||
onSubmit?.(currentValue)
|
||||
}
|
||||
|
||||
// 获取空白区域的填充值
|
||||
const getBlankFill = (blankId: string): string | null => {
|
||||
const result = currentValue.find((result) => result.blankId === blankId);
|
||||
return result ? result.optionValue : 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;
|
||||
const isDragging = draggingOption === option.value
|
||||
return (
|
||||
<div
|
||||
onMouseDown={(e) => handleMouseDown(e, option.value)}
|
||||
onTouchStart={(e) => handleTouchStart(e, option.value)}
|
||||
className={cn(
|
||||
"px-6 py-4 border-2 rounded-lg transition-all select-none",
|
||||
'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",
|
||||
'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",
|
||||
? '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
|
||||
)}
|
||||
>
|
||||
@@ -370,57 +340,45 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
// 默认渲染空白区域
|
||||
const defaultRenderBlank = (
|
||||
blank: DragFillBlank,
|
||||
filledValue: string | null
|
||||
) => {
|
||||
const isHovered = hoveredBlank === blank.id;
|
||||
const option = filledValue
|
||||
? normalizedOptions.find((opt) => opt.value === filledValue)
|
||||
: null;
|
||||
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);
|
||||
blankRefsMap.current.set(blank.id, el)
|
||||
} else {
|
||||
blankRefsMap.current.delete(blank.id);
|
||||
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",
|
||||
'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"
|
||||
? '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",
|
||||
? '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>
|
||||
<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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -432,95 +390,84 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">
|
||||
{blank.label || "拖拽选项到这里"}
|
||||
</span>
|
||||
<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",
|
||||
'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"
|
||||
'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>
|
||||
)}
|
||||
{!hideLabels && <h3 className="text-lg font-semibold text-gray-700">选项</h3>}
|
||||
<div
|
||||
className={cn(
|
||||
layout === "horizontal-options"
|
||||
? optionClassName || "flex flex-row gap-6 flex-wrap w-full"
|
||||
layout === 'horizontal-options'
|
||||
? optionClassName || 'flex flex-row gap-6 flex-wrap w-full'
|
||||
: cn(
|
||||
optionClassName,
|
||||
!optionClassName && "grid gap-x-12 gap-y-16",
|
||||
!optionClassName && 'grid gap-x-12 gap-y-16',
|
||||
!optionClassName && normalizedOptions.length > 2
|
||||
? "grid-cols-2"
|
||||
: !optionClassName && "grid-cols-1"
|
||||
? 'grid-cols-2'
|
||||
: !optionClassName && 'grid-cols-1'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{normalizedOptions.map((option) => {
|
||||
const isUsed = isOptionUsed(option.value);
|
||||
const isDragging = draggingOption === option.value;
|
||||
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);
|
||||
handleMouseDown(e, option.value)
|
||||
}
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (!disabled && !option.disabled && !isUsed) {
|
||||
handleTouchStart(e, option.value);
|
||||
handleTouchStart(e, option.value)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
renderOption && "select-none",
|
||||
!optionClassName && renderOption && "flex-1",
|
||||
isDragging && "opacity-50"
|
||||
renderOption && 'select-none',
|
||||
!optionClassName && renderOption && '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={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">
|
||||
{normalizedBlanks.map((blank) => {
|
||||
const filledValue = getBlankFill(blank.id);
|
||||
const isHovered = hoveredBlank === blank.id && !!draggingOption;
|
||||
const filledValue = getBlankFill(blank.id)
|
||||
const isHovered = hoveredBlank === blank.id && !!draggingOption
|
||||
return (
|
||||
<div
|
||||
key={blank.id}
|
||||
@@ -528,14 +475,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
if (el) {
|
||||
// 如果使用自定义渲染,需要找到实际的空白区域元素
|
||||
// 默认使用第一个子元素
|
||||
const targetEl = renderBlank
|
||||
? (el.firstElementChild as HTMLElement)
|
||||
: el;
|
||||
const targetEl = renderBlank ? (el.firstElementChild as HTMLElement) : el
|
||||
if (targetEl) {
|
||||
blankRefsMap.current.set(blank.id, targetEl);
|
||||
blankRefsMap.current.set(blank.id, targetEl)
|
||||
}
|
||||
} else {
|
||||
blankRefsMap.current.delete(blank.id);
|
||||
blankRefsMap.current.delete(blank.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -543,7 +488,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
? renderBlank(blank, filledValue, isHovered)
|
||||
: defaultRenderBlank(blank, filledValue)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,10 +499,10 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
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"
|
||||
'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}
|
||||
@@ -574,31 +519,29 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const option = normalizedOptions.find(
|
||||
(opt) => opt.value === draggingOption
|
||||
);
|
||||
if (!option) return null;
|
||||
const option = normalizedOptions.find((opt) => opt.value === draggingOption)
|
||||
if (!option) return null
|
||||
if (renderDragging) {
|
||||
return renderDragging(option);
|
||||
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";
|
||||
DragFill.displayName = 'DragFill'
|
||||
|
||||
export default DragFill;
|
||||
export default DragFill
|
||||
@@ -1,73 +1,69 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/** 选项数据 */
|
||||
export interface DragFillOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 空白区域数据 */
|
||||
export interface DragFillBlank {
|
||||
/** 空白区域ID */
|
||||
id: string;
|
||||
id: string
|
||||
/** 空白区域显示文本/提示 */
|
||||
label?: string;
|
||||
label?: string
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
/** 填充结果 */
|
||||
export interface DragFillResult {
|
||||
/** 空白区域ID */
|
||||
blankId: string;
|
||||
blankId: string
|
||||
/** 填充的选项值 */
|
||||
optionValue: string;
|
||||
optionValue: string
|
||||
}
|
||||
|
||||
/** 拖拽填空组件配置项 */
|
||||
export interface DragFillProps {
|
||||
/** 可拖拽的选项列表 */
|
||||
options: DragFillOption[] | string[];
|
||||
options: DragFillOption[] | string[]
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[] | string[];
|
||||
blanks: DragFillBlank[] | string[]
|
||||
/** 当前填充结果(受控模式) */
|
||||
value?: DragFillResult[];
|
||||
value?: DragFillResult[]
|
||||
/** 默认填充结果 */
|
||||
defaultValue?: DragFillResult[];
|
||||
defaultValue?: DragFillResult[]
|
||||
/** 填充结果变化时的回调 */
|
||||
onChange?: (results: DragFillResult[]) => void;
|
||||
onChange?: (results: DragFillResult[]) => void
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (results: DragFillResult[]) => void;
|
||||
onSubmit?: (results: DragFillResult[]) => void
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 是否允许重复选择同一选项 */
|
||||
allowDuplicate?: boolean;
|
||||
allowDuplicate?: boolean
|
||||
/** 布局方向:vertical(上下)、horizontal(左右)或 horizontal-options(选项水平排列) */
|
||||
layout?: "vertical" | "horizontal" | "horizontal-options";
|
||||
layout?: 'vertical' | 'horizontal' | 'horizontal-options'
|
||||
/** 是否隐藏"选项"和"填空"标签 */
|
||||
hideLabels?: boolean;
|
||||
hideLabels?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
optionClassName?: string
|
||||
/** 空白区域容器的自定义类名 */
|
||||
blankClassName?: string;
|
||||
blankClassName?: string
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode;
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode
|
||||
/** 自定义渲染空白区域 */
|
||||
renderBlank?: (
|
||||
blank: DragFillBlank,
|
||||
filledValue: string | null,
|
||||
isHovered: boolean
|
||||
) => ReactNode;
|
||||
renderBlank?: (blank: DragFillBlank, filledValue: string | null, isHovered: boolean) => ReactNode
|
||||
/** 自定义渲染拖拽中的元素 */
|
||||
renderDragging?: (option: DragFillOption) => ReactNode;
|
||||
renderDragging?: (option: DragFillOption) => ReactNode
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
export { default } from "./drag-fill";
|
||||
export { default as DragFill } from "./drag-fill";
|
||||
export { default } from './drag-fill'
|
||||
export { default as DragFill } from './drag-fill'
|
||||
export type {
|
||||
DragFillProps,
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./drag-fill.types";
|
||||
|
||||
} from './drag-fill.types'
|
||||
@@ -1,23 +1,11 @@
|
||||
// Single Choice component
|
||||
export { SingleChoice, default as SingleChoiceDefault } from "./single-choice";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice";
|
||||
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";
|
||||
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";
|
||||
|
||||
export { DragFill } from './drag-fill'
|
||||
export type { DragFillProps, DragFillOption, DragFillBlank, DragFillResult } from './drag-fill'
|
||||
@@ -1,7 +1,3 @@
|
||||
export { default } from "./multiple-choice";
|
||||
export { default as MultipleChoice } from "./multiple-choice";
|
||||
export type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice.types";
|
||||
|
||||
export { default } from './multiple-choice'
|
||||
export { default as MultipleChoice } from './multiple-choice'
|
||||
export type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice.types'
|
||||
@@ -1,193 +1,188 @@
|
||||
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";
|
||||
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",
|
||||
title: 'Components/Quiz/MultipleChoice',
|
||||
component: MultipleChoice,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "选项列表,可以是字符串数组或选项对象数组",
|
||||
control: { type: 'object' },
|
||||
description: '选项列表,可以是字符串数组或选项对象数组',
|
||||
},
|
||||
value: {
|
||||
control: { type: "object" },
|
||||
description: "当前选中的值数组(受控模式)",
|
||||
control: { type: 'object' },
|
||||
description: '当前选中的值数组(受控模式)',
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "object" },
|
||||
description: "默认选中的值数组",
|
||||
control: { type: 'object' },
|
||||
description: '默认选中的值数组',
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否禁用整个组件',
|
||||
},
|
||||
minSelections: {
|
||||
control: { type: "number" },
|
||||
description: "最小选择数量",
|
||||
control: { type: 'number' },
|
||||
description: '最小选择数量',
|
||||
},
|
||||
maxSelections: {
|
||||
control: { type: "number" },
|
||||
description: "最大选择数量",
|
||||
control: { type: 'number' },
|
||||
description: '最大选择数量',
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否显示提交按钮',
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
control: { type: 'text' },
|
||||
description: '提交按钮文本',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MultipleChoice>;
|
||||
export default meta
|
||||
type Story = StoryObj<typeof MultipleChoice>
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", 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: "选项五" },
|
||||
{ value: 'opt1', label: '选项一' },
|
||||
{ value: 'opt2', label: '选项二' },
|
||||
{ value: 'opt3', label: '选项三(禁用)', disabled: true },
|
||||
{ value: 'opt4', label: '选项四' },
|
||||
{ value: 'opt5', label: '选项五' },
|
||||
],
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
const [values, setValues] = useState<string[]>([])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MultipleChoice
|
||||
options={["选项A", "选项B", "选项C", "选项D"]}
|
||||
options={['选项A', '选项B', '选项C', '选项D']}
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
onSubmit={(vals) => {
|
||||
alert(`提交的值: ${vals.join(", ")}`);
|
||||
alert(`提交的值: ${vals.join(', ')}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {values.length > 0 ? values.join(", ") : "未选择"}
|
||||
当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 限制最大选择数量
|
||||
export const WithMaxSelections: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
|
||||
maxSelections: 2,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 限制最小和最大选择数量
|
||||
export const WithMinMaxSelections: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
|
||||
minSelections: 2,
|
||||
maxSelections: 4,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
disabled: true,
|
||||
defaultValue: ["选项A"],
|
||||
defaultValue: ['选项A'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
showSubmitButton: false,
|
||||
onChange: (values) => {
|
||||
console.log("值变化:", 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: "确认答案",
|
||||
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);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义渲染选项
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<MultipleChoice
|
||||
options={["选项A", "选项B", "选项C", "选项D"]}
|
||||
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"
|
||||
'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"
|
||||
'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 && "✓"}
|
||||
{isSelected && '✓'}
|
||||
</div>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useState, useEffect, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice.types";
|
||||
import { useState, useEffect, forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice.types'
|
||||
|
||||
/**
|
||||
* MultipleChoice - 多选组件
|
||||
@@ -32,7 +29,7 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
maxSelections,
|
||||
className,
|
||||
optionClassName,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
showSubmitButton = true,
|
||||
renderOption,
|
||||
},
|
||||
@@ -40,96 +37,79 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: MultipleChoiceOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return option
|
||||
})
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string[]>(
|
||||
defaultValue || []
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
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;
|
||||
if (disabled) return
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue)
|
||||
if (option?.disabled) return
|
||||
|
||||
const isSelected = currentValue.includes(optionValue);
|
||||
let newValue: string[];
|
||||
const isSelected = currentValue.includes(optionValue)
|
||||
let newValue: string[]
|
||||
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValue = currentValue.filter((v) => v !== optionValue);
|
||||
newValue = currentValue.filter((v) => v !== optionValue)
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValue.length < minSelections
|
||||
) {
|
||||
return;
|
||||
if (minSelections !== undefined && newValue.length < minSelections) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
currentValue.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
if (maxSelections !== undefined && currentValue.length >= maxSelections) {
|
||||
return
|
||||
}
|
||||
newValue = [...currentValue, optionValue];
|
||||
newValue = [...currentValue, optionValue]
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
setInternalValue(newValue)
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
// 检查最小选择数量
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
currentValue.length < minSelections
|
||||
) {
|
||||
return;
|
||||
if (minSelections !== undefined && currentValue.length < minSelections) {
|
||||
return
|
||||
}
|
||||
onSubmit?.(currentValue);
|
||||
};
|
||||
onSubmit?.(currentValue)
|
||||
}
|
||||
|
||||
// 检查是否可以提交
|
||||
const canSubmit =
|
||||
currentValue.length > 0 &&
|
||||
(minSelections === undefined ||
|
||||
currentValue.length >= minSelections);
|
||||
(minSelections === undefined || currentValue.length >= minSelections)
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (
|
||||
option: MultipleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => {
|
||||
const defaultRenderOption = (option: MultipleChoiceOption, isSelected: boolean) => {
|
||||
const isDisabled =
|
||||
disabled ||
|
||||
option.disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
currentValue.length >= maxSelections);
|
||||
(!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",
|
||||
'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",
|
||||
? '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)}
|
||||
@@ -137,10 +117,8 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
<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"
|
||||
'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 && (
|
||||
@@ -162,21 +140,21 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-col gap-4", className)}>
|
||||
<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);
|
||||
const isSelected = currentValue.includes(option.value)
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -185,27 +163,22 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
{minSelections !== undefined && (
|
||||
<div className="text-sm text-gray-500">
|
||||
至少选择 {minSelections} 项
|
||||
{maxSelections !== undefined &&
|
||||
`,最多选择 ${maxSelections} 项`}
|
||||
{maxSelections === undefined && "(无上限)"}
|
||||
{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">最多选择 {maxSelections} 项</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {currentValue.length} 项
|
||||
</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"
|
||||
'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}
|
||||
@@ -213,11 +186,10 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
MultipleChoice.displayName = "MultipleChoice";
|
||||
|
||||
export default MultipleChoice;
|
||||
MultipleChoice.displayName = 'MultipleChoice'
|
||||
|
||||
export default MultipleChoice
|
||||
@@ -1,45 +1,41 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/** 选项数据 */
|
||||
export interface MultipleChoiceOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 多选组件配置项 */
|
||||
export interface MultipleChoiceProps {
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[] | string[];
|
||||
options: MultipleChoiceOption[] | string[]
|
||||
/** 当前选中的值数组(受控模式) */
|
||||
value?: string[];
|
||||
value?: string[]
|
||||
/** 默认选中的值数组 */
|
||||
defaultValue?: string[];
|
||||
defaultValue?: string[]
|
||||
/** 选中值变化时的回调 */
|
||||
onChange?: (values: string[]) => void;
|
||||
onChange?: (values: string[]) => void
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
onSubmit?: (values: string[]) => void
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
minSelections?: number
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
maxSelections?: number
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
optionClassName?: string
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (
|
||||
option: MultipleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => ReactNode;
|
||||
renderOption?: (option: MultipleChoiceOption, isSelected: boolean) => ReactNode
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export { default } from "./single-choice";
|
||||
export { default as SingleChoice } from "./single-choice";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice.types";
|
||||
|
||||
export { default } from './single-choice'
|
||||
export { default as SingleChoice } from './single-choice'
|
||||
export type { SingleChoiceProps, SingleChoiceOption } from './single-choice.types'
|
||||
@@ -1,161 +1,152 @@
|
||||
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";
|
||||
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",
|
||||
title: 'Components/Quiz/SingleChoice',
|
||||
component: SingleChoice,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "选项列表,可以是字符串数组或选项对象数组",
|
||||
control: { type: 'object' },
|
||||
description: '选项列表,可以是字符串数组或选项对象数组',
|
||||
},
|
||||
value: {
|
||||
control: { type: "text" },
|
||||
description: "当前选中的值(受控模式)",
|
||||
control: { type: 'text' },
|
||||
description: '当前选中的值(受控模式)',
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "text" },
|
||||
description: "默认选中的值",
|
||||
control: { type: 'text' },
|
||||
description: '默认选中的值',
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否禁用整个组件',
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否显示提交按钮',
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
control: { type: 'text' },
|
||||
description: '提交按钮文本',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SingleChoice>;
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SingleChoice>
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D'],
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
console.log('选中的值:', value)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 使用选项对象
|
||||
export const WithOptionObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
{ value: 'opt1', label: '选项一' },
|
||||
{ value: 'opt2', label: '选项二' },
|
||||
{ value: 'opt3', label: '选项三(禁用)', disabled: true },
|
||||
{ value: 'opt4', label: '选项四' },
|
||||
],
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
console.log('选中的值:', value)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [value, setValue] = useState<string>('')
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SingleChoice
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(val) => {
|
||||
alert(`提交的值: ${val}`);
|
||||
alert(`提交的值: ${val}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {value || "未选择"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
disabled: true,
|
||||
defaultValue: "选项A",
|
||||
defaultValue: '选项A',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
showSubmitButton: false,
|
||||
onChange: (value) => {
|
||||
console.log("值变化:", 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: "确认答案",
|
||||
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);
|
||||
console.log('选中的值:', value)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义渲染选项
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<SingleChoice
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
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"
|
||||
'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"
|
||||
'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" />
|
||||
)}
|
||||
{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);
|
||||
console.log('选中的值:', value)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useState, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice.types";
|
||||
import { useState, forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SingleChoiceProps, SingleChoiceOption } from './single-choice.types'
|
||||
|
||||
/**
|
||||
* SingleChoice - 单选组件
|
||||
@@ -29,7 +26,7 @@ const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
|
||||
disabled = false,
|
||||
className,
|
||||
optionClassName,
|
||||
submitText: _submitText = "提交", // 保留入参但不使用
|
||||
submitText: _submitText = '提交', // 保留入参但不使用
|
||||
showSubmitButton: _showSubmitButton = false, // 保留入参但不使用
|
||||
renderOption,
|
||||
},
|
||||
@@ -37,80 +34,73 @@ const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: SingleChoiceOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return option
|
||||
})
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string>(
|
||||
defaultValue || ""
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
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 (disabled) return
|
||||
if (normalizedOptions.find((opt) => opt.value === optionValue)?.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(optionValue);
|
||||
setInternalValue(optionValue)
|
||||
}
|
||||
onChange?.(optionValue);
|
||||
onChange?.(optionValue)
|
||||
// 单选题:点击即触发提交回调
|
||||
onSubmit?.(optionValue);
|
||||
};
|
||||
onSubmit?.(optionValue)
|
||||
}
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (
|
||||
option: SingleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => {
|
||||
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",
|
||||
'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",
|
||||
? '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 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;
|
||||
const isSelected = currentValue === option.value
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮移除:点击选项即提交 */}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
SingleChoice.displayName = "SingleChoice";
|
||||
SingleChoice.displayName = 'SingleChoice'
|
||||
|
||||
export default SingleChoice;
|
||||
export default SingleChoice
|
||||
@@ -1,38 +1,37 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/** 选项数据 */
|
||||
export interface SingleChoiceOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 单选组件配置项 */
|
||||
export interface SingleChoiceProps {
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[] | string[];
|
||||
options: SingleChoiceOption[] | string[]
|
||||
/** 当前选中的值 */
|
||||
value?: string;
|
||||
value?: string
|
||||
/** 默认选中的值 */
|
||||
defaultValue?: string;
|
||||
defaultValue?: string
|
||||
/** 选中值变化时的回调 */
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value: string) => void
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (value: string) => void;
|
||||
onSubmit?: (value: string) => void
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
optionClassName?: string
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode;
|
||||
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user