@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user