feat: 更新组件和引导对话,增强用户体验
- 在多个组件中添加新的选择和填空功能,提升互动性。 - 更新引导对话文本,确保信息准确性和一致性。 - 引入新的选择组件,优化用户填写体验。 - 清理冗余代码,提升可维护性。
This commit is contained in:
1 parent
a8723ab4aa
commit
5bdfdadede
27 files changed
+880
-1252
No files matched your search
@@ -0,0 +1,4 @@
|
||||
export { default } from "./select";
|
||||
export { default as Select } from "./select";
|
||||
export type { SelectProps, SelectOption } from "./select.types";
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user