优化复刻视频脚本和下拉框样式
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export type CustomSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
disabled?: boolean;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
type MenuPos = {
|
||||
left: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
|
||||
export function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
className,
|
||||
fill,
|
||||
align = "left",
|
||||
size = "md",
|
||||
placeholder = "请选择",
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: CustomSelectOption[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
align?: "left" | "right";
|
||||
size?: "sm" | "md";
|
||||
placeholder?: string;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState<MenuPos | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const current = options.find((item) => item.value === value);
|
||||
|
||||
function place() {
|
||||
const btn = btnRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const gutter = 8;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gutter;
|
||||
const spaceAbove = rect.top - gutter;
|
||||
const up = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||
const width = Math.min(rect.width, window.innerWidth - gutter * 2);
|
||||
let left = align === "right" ? rect.right - width : rect.left;
|
||||
left = Math.min(Math.max(gutter, left), window.innerWidth - width - gutter);
|
||||
const next: MenuPos = {
|
||||
left,
|
||||
width,
|
||||
maxHeight: Math.max(120, Math.min(320, (up ? spaceAbove : spaceBelow) - 4)),
|
||||
};
|
||||
if (up) next.bottom = window.innerHeight - rect.top + 6;
|
||||
else next.top = rect.bottom + 6;
|
||||
setPos(next);
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
place();
|
||||
}, [open, align, options.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (btnRef.current?.contains(target) || menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); };
|
||||
const onReposition = () => place();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", onReposition);
|
||||
window.addEventListener("scroll", onReposition, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", onReposition);
|
||||
window.removeEventListener("scroll", onReposition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const menu = menuRef.current;
|
||||
const active = menu?.querySelector<HTMLElement>(".rs-select-option.selected");
|
||||
if (!menu || !active) return;
|
||||
menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2;
|
||||
}, [open, value]);
|
||||
|
||||
return (
|
||||
<div className={`rs-select${fill ? " rs-select-fill" : ""}${open ? " open" : ""}${size === "sm" ? " rs-select-sm" : ""}${className ? ` ${className}` : ""}`}>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className="rs-select-btn"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className={`rs-select-label${current ? "" : " is-placeholder"}`}>{current?.label || placeholder}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
{open && pos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="rs-select-menu rs-select-menu-portal"
|
||||
role="listbox"
|
||||
style={{
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
maxHeight: pos.maxHeight,
|
||||
top: pos.top,
|
||||
bottom: pos.bottom,
|
||||
}}
|
||||
>
|
||||
{options.map((item, index) => {
|
||||
const showGroup = Boolean(item.group && item.group !== options[index - 1]?.group);
|
||||
return (
|
||||
<div key={`${item.group || ""}:${item.value}`}>
|
||||
{showGroup ? <div className="rs-select-group">{item.group}</div> : null}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.value === value}
|
||||
className={`rs-select-option${item.value === value ? " selected" : ""}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="rs-select-option-text">
|
||||
<span className="rs-select-option-label">{item.label}</span>
|
||||
{item.desc ? <span className="rs-select-option-desc">{item.desc}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user