优化复刻视频脚本和下拉框样式

This commit is contained in:
Azmat@qq.com
2026-08-31 10:59:54 +08:00
parent 284748eeff
commit f59e9d0418
20 changed files with 775 additions and 212 deletions
@@ -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>
);
}