feat: 更新组件和引导对话,增强用户体验

- 在多个组件中添加新的选择和填空功能,提升互动性。
- 更新引导对话文本,确保信息准确性和一致性。
- 引入新的选择组件,优化用户填写体验。
- 清理冗余代码,提升可维护性。
This commit is contained in:
zhongyidian committed 2026-01-16 17:54:51 +08:00
1 parent a8723ab4aa
commit 5bdfdadede
27 files changed
+880 -1252

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
export { default } from "./select";
export { default as Select } from "./select";
export type { SelectProps, SelectOption } from "./select.types";
+363
View File
@@ -0,0 +1,363 @@
import { forwardRef, useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import type { SelectProps } from "./select.types";
/**
* Select - 下拉选择组件
* 使用 Portal 将下拉菜单渲染到 body 下,避免受到父容器缩放影响
*
* @example
* ```tsx
* <Select
* options={[
* { value: "1", label: "选项1" },
* { value: "2", label: "选项2" },
* ]}
* value={value}
* onChange={(val) => setValue(val)}
* placeholder="请选择"
* />
* ```
*/
const Select = forwardRef<HTMLDivElement, SelectProps>(
(
{
options,
value,
onChange,
placeholder = "请选择",
disabled = false,
className,
error = false,
zIndex = 1000,
fitContent = false,
minWidth,
maxWidth,
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const triggerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const [contentWidth, setContentWidth] = useState<number | null>(null);
// 获取当前选中的选项
const selectedOption = options.find((opt) => opt.value === value);
// 计算内容宽度(用于 fitContent)
const calculateContentWidth = useCallback(() => {
if (!fitContent || !triggerRef.current) return;
// 获取触发器的计算样式,用于匹配字体
const triggerStyles = window.getComputedStyle(triggerRef.current);
// 创建一个临时元素来测量所有选项的宽度
const tempElement = document.createElement("span");
tempElement.style.visibility = "hidden";
tempElement.style.position = "absolute";
tempElement.style.whiteSpace = "nowrap";
tempElement.style.fontSize = triggerStyles.fontSize;
tempElement.style.fontFamily = triggerStyles.fontFamily;
tempElement.style.fontWeight = triggerStyles.fontWeight;
tempElement.style.padding = "8px 12px"; // 与选项 padding 一致 (py-2 px-3)
document.body.appendChild(tempElement);
let calculatedWidth = 0;
// 测量所有选项和占位符的宽度
const textsToMeasure = [placeholder, ...options.map((opt) => opt.label)];
textsToMeasure.forEach((text) => {
tempElement.textContent = text;
const width = tempElement.offsetWidth;
if (width > calculatedWidth) {
calculatedWidth = width;
}
});
document.body.removeChild(tempElement);
// 应用最小和最大宽度限制
if (minWidth !== undefined && calculatedWidth < minWidth) {
calculatedWidth = minWidth;
}
if (maxWidth !== undefined && calculatedWidth > maxWidth) {
calculatedWidth = maxWidth;
}
setContentWidth(calculatedWidth);
}, [fitContent, options, placeholder, minWidth, maxWidth]);
// 计算下拉菜单位置
const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
let width = rect.width;
// 如果启用 fitContent,使用计算出的内容宽度
if (fitContent && contentWidth !== null) {
// width = contentWidth;
width = 0;
}
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: width,
});
}, [fitContent, contentWidth]);
// 打开下拉菜单
const handleOpen = useCallback(() => {
if (disabled) return;
// 如果启用 fitContent,先计算内容宽度
if (fitContent) {
calculateContentWidth();
}
// 使用 setTimeout 确保内容宽度计算完成后再更新位置
setTimeout(() => {
updateDropdownPosition();
setIsOpen(true);
setFocusedIndex(
value ? options.findIndex((opt) => opt.value === value) : -1
);
}, 0);
}, [
disabled,
fitContent,
calculateContentWidth,
updateDropdownPosition,
value,
options,
]);
// 关闭下拉菜单
const handleClose = useCallback(() => {
setIsOpen(false);
setFocusedIndex(-1);
}, []);
// 选择选项
const handleSelect = useCallback(
(optionValue: string) => {
if (onChange) {
onChange(optionValue);
}
handleClose();
},
[onChange, handleClose]
);
// 点击外部关闭
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
triggerRef.current &&
!triggerRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
handleClose();
}
};
const handleScroll = () => {
updateDropdownPosition();
};
const handleResize = () => {
updateDropdownPosition();
};
document.addEventListener("mousedown", handleClickOutside);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
};
}, [isOpen, handleClose, updateDropdownPosition]);
// 键盘导航
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
const enabledIndices = options
.map((opt, idx) => (!opt.disabled ? idx : -1))
.filter((idx) => idx !== -1);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setFocusedIndex((prev) => {
const currentEnabledIdx = enabledIndices.indexOf(prev);
const nextEnabledIdx =
currentEnabledIdx < enabledIndices.length - 1
? currentEnabledIdx + 1
: 0;
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1;
});
break;
case "ArrowUp":
event.preventDefault();
setFocusedIndex((prev) => {
const currentEnabledIdx = enabledIndices.indexOf(prev);
const nextEnabledIdx =
currentEnabledIdx > 0
? currentEnabledIdx - 1
: enabledIndices.length - 1;
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1;
});
break;
case "Enter":
case " ":
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < options.length) {
const option = options[focusedIndex];
if (option && !option.disabled) {
handleSelect(option.value);
}
}
break;
case "Escape":
event.preventDefault();
handleClose();
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, focusedIndex, options, handleSelect, handleClose]);
// 滚动到选中项
useEffect(() => {
if (isOpen && focusedIndex >= 0 && dropdownRef.current) {
const optionElement = dropdownRef.current.querySelector(
`[data-option-index="${focusedIndex}"]`
);
if (optionElement) {
optionElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, [isOpen, focusedIndex]);
return (
<>
{/* 隐藏的测量元素(用于 fitContent 模式) */}
{fitContent && (
<span
ref={measureRef}
style={{
position: "absolute",
visibility: "hidden",
whiteSpace: "nowrap",
}}
aria-hidden="true"
/>
)}
{/* 触发器 */}
<div
ref={(node) => {
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
triggerRef.current = node;
}}
className={cn(
"inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer",
error ? "border-red-500" : "border-[#4e402e]",
disabled && "opacity-50 cursor-not-allowed",
className
)}
onClick={handleOpen}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
{/* <span className={cn("truncate px-2", className)}> */}
{selectedOption ? selectedOption.label : placeholder}
{/* </span> */}
{/* <span
className={cn("ml-1 transition-transform", isOpen && "rotate-180")}
>
▼
</span> */}
</div>
{/* 下拉菜单 - 使用 Portal 渲染到 body */}
{isOpen &&
dropdownPosition &&
createPortal(
<div
ref={dropdownRef}
className="fixed bg-white border border-[#4e402e] rounded shadow-lg max-h-[200px] overflow-auto z-[1000]"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${
dropdownPosition.width > 0 ? dropdownPosition.width : "auto"
}px`,
zIndex: zIndex,
}}
role="listbox"
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isFocused = index === focusedIndex;
const isDisabled = option.disabled;
return (
<div
key={option.value}
data-option-index={index}
className={cn(
"px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap",
isSelected && "bg-[#4e402e]/10 font-medium",
isFocused && !isSelected && "bg-gray-100",
isDisabled &&
"opacity-50 cursor-not-allowed pointer-events-none"
)}
onClick={() => !isDisabled && handleSelect(option.value)}
role="option"
aria-selected={isSelected}
>
{option.label}
</div>
);
})}
</div>,
document.body
)}
</>
);
}
);
Select.displayName = "Select";
export default Select;
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
/** Select 选项 */
export interface SelectOption {
/** 选项值 */
value: string;
/** 选项标签 */
label: string;
/** 是否禁用 */
disabled?: boolean;
}
/** Select 组件属性 */
export interface SelectProps {
/** 选项列表 */
options: SelectOption[];
/** 当前选中的值 */
value?: string;
/** 值变化回调 */
onChange?: (value: string) => void;
/** 占位符文本 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
/** 是否显示错误状态 */
error?: boolean;
/** 下拉菜单的 z-index */
zIndex?: number;
/** 下拉菜单宽度是否自适应内容(根据最长的 label 文字宽度) */
fitContent?: boolean;
/** 下拉菜单最小宽度(当 fitContent 为 true 时生效) */
minWidth?: number;
/** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */
maxWidth?: number;
}