Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 07:04:14 +01:00
co-authored by Claude Opus 5.5
parent b61a5b1fb3
commit d19d412066
+127 -45
View File
@@ -3,6 +3,7 @@ import React, {
type HTMLAttributes,
type PropsWithChildren,
} from "react";
import ReactDOM from "react-dom";
import { twMerge } from "tailwind-merge";
export const TWUIDropdownContentPositions = [
@@ -65,15 +66,20 @@ export default function Dropdown({
...props
}: TWUI_DROPDOWN_PROPS) {
const [open, setOpen] = React.useState(externalOpen);
const [mounted, setMounted] = React.useState(false);
const [contentStyle, setContentStyle] = React.useState<React.CSSProperties>(
{},
);
let timeout: any;
let openTimeout: any;
const timeoutRef = React.useRef<any>(null);
const openTimeoutRef = React.useRef<any>(null);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (dropdownContentRef.current?.contains(targetEl)) return;
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
if (!closestWrapper) {
@@ -98,6 +104,91 @@ export default function Dropdown({
setOpen(externalOpen);
}, [externalOpen]);
React.useEffect(() => {
setMounted(true);
}, []);
/**
* Content is portaled to `document.body` so it renders above
* everything else on the page. Position is computed from the
* wrapper's bounding rect and kept in sync on scroll/resize.
*/
const updateContentPosition = React.useCallback(() => {
const wrapperEl = dropdownRef.current;
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let style: React.CSSProperties;
switch (position) {
case "left":
style = {
left: rect.right,
top: centerY,
transform: "translateY(-50%)",
};
break;
case "right":
style = {
right: viewportWidth - rect.left,
top: centerY,
transform: "translateY(-50%)",
};
break;
case "bottom-left":
style = { left: rect.left, top: rect.bottom };
break;
case "bottom-right":
style = { right: viewportWidth - rect.right, top: rect.bottom };
break;
case "top":
style = {
left: centerX,
bottom: viewportHeight - rect.top,
transform: "translateX(-50%)",
};
break;
default:
style = {
left: centerX,
top: rect.bottom,
transform: "translateX(-50%)",
};
break;
}
if (
above &&
style.top !== undefined &&
position !== "left" &&
position !== "right"
) {
delete style.top;
style.bottom = viewportHeight - rect.top;
}
setContentStyle(style);
}, [position, above]);
React.useLayoutEffect(() => {
if (!open) return;
updateContentPosition();
window.addEventListener("scroll", updateContentPosition, true);
window.addEventListener("resize", updateContentPosition);
return () => {
window.removeEventListener("scroll", updateContentPosition, true);
window.removeEventListener("resize", updateContentPosition);
};
}, [open, updateContentPosition]);
return (
<div
{...props}
@@ -108,10 +199,10 @@ export default function Dropdown({
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
window.clearTimeout(openTimeout);
window.clearTimeout(timeoutRef.current);
window.clearTimeout(openTimeoutRef.current);
openTimeout = setTimeout(() => {
openTimeoutRef.current = setTimeout(() => {
externalSetOpen?.(true);
setOpen(true);
}, openDebounce);
@@ -119,15 +210,15 @@ export default function Dropdown({
onMouseLeave={(e) => {
if (!hoverOpen) return;
window.clearTimeout(openTimeout);
window.clearTimeout(openTimeoutRef.current);
timeout = setTimeout(() => {
timeoutRef.current = setTimeout(() => {
externalSetOpen?.(false);
setOpen(false);
}, debounce);
}}
onBlur={() => {
window.clearTimeout(timeout);
window.clearTimeout(timeoutRef.current);
}}
ref={dropdownRef}
>
@@ -148,44 +239,35 @@ export default function Dropdown({
{target}
</div>
<div
{...contentWrapperProps}
className={twMerge(
"absolute z-10 mt-1",
position == "left"
? "left-[100%] top-[50%] -translate-y-[50%]"
: position == "right"
? "right-[100%] top-[50%] -translate-y-[50%]"
: position == "bottom-left"
? "left-0 top-[100%]"
: position == "bottom-right"
? "right-0 top-[100%]"
: position == "center"
? "left-[50%] -translate-x-[50%] top-[100%]"
: position == "top"
? "left-[50%] -translate-x-[50%] bottom-[100%]"
: "top-[100%]",
above ? "-translate-y-[120%]" : "",
open ? "flex" : "hidden",
"twui-dropdown-content",
contentWrapperProps?.className,
{mounted &&
ReactDOM.createPortal(
<div
{...contentWrapperProps}
className={twMerge(
"fixed z-[1000]",
above ? "mb-1" : "mt-1",
open ? "flex" : "hidden",
"twui-dropdown-content",
contentWrapperProps?.className,
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeoutRef.current);
}}
onBlur={() => {
if (!hoverOpen) return;
window.clearTimeout(timeoutRef.current);
}}
style={{
...contentStyle,
...contentWrapperProps?.style,
}}
ref={dropdownContentRef}
>
{props.children}
</div>,
document.body,
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
onBlur={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
style={{
// top: `calc(100% + ${topOffset || 0}px)`,
...contentWrapperProps?.style,
}}
ref={dropdownContentRef}
>
{props.children}
</div>
</div>
);
}