@@ -3,6 +3,7 @@ import React, {
|
|||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export const TWUIDropdownContentPositions = [
|
export const TWUIDropdownContentPositions = [
|
||||||
@@ -65,15 +66,20 @@ export default function Dropdown({
|
|||||||
...props
|
...props
|
||||||
}: TWUI_DROPDOWN_PROPS) {
|
}: TWUI_DROPDOWN_PROPS) {
|
||||||
const [open, setOpen] = React.useState(externalOpen);
|
const [open, setOpen] = React.useState(externalOpen);
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [contentStyle, setContentStyle] = React.useState<React.CSSProperties>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
let timeout: any;
|
const timeoutRef = React.useRef<any>(null);
|
||||||
let openTimeout: any;
|
const openTimeoutRef = React.useRef<any>(null);
|
||||||
|
|
||||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||||
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||||
const targetEl = e.target as HTMLElement;
|
const targetEl = e.target as HTMLElement;
|
||||||
|
if (dropdownContentRef.current?.contains(targetEl)) return;
|
||||||
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
|
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
|
||||||
|
|
||||||
if (!closestWrapper) {
|
if (!closestWrapper) {
|
||||||
@@ -98,6 +104,91 @@ export default function Dropdown({
|
|||||||
setOpen(externalOpen);
|
setOpen(externalOpen);
|
||||||
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
@@ -108,10 +199,10 @@ export default function Dropdown({
|
|||||||
)}
|
)}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!hoverOpen) return;
|
if (!hoverOpen) return;
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeoutRef.current);
|
||||||
window.clearTimeout(openTimeout);
|
window.clearTimeout(openTimeoutRef.current);
|
||||||
|
|
||||||
openTimeout = setTimeout(() => {
|
openTimeoutRef.current = setTimeout(() => {
|
||||||
externalSetOpen?.(true);
|
externalSetOpen?.(true);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, openDebounce);
|
}, openDebounce);
|
||||||
@@ -119,15 +210,15 @@ export default function Dropdown({
|
|||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!hoverOpen) return;
|
if (!hoverOpen) return;
|
||||||
|
|
||||||
window.clearTimeout(openTimeout);
|
window.clearTimeout(openTimeoutRef.current);
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
externalSetOpen?.(false);
|
externalSetOpen?.(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, debounce);
|
}, debounce);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeoutRef.current);
|
||||||
}}
|
}}
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
>
|
>
|
||||||
@@ -148,44 +239,35 @@ export default function Dropdown({
|
|||||||
{target}
|
{target}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{mounted &&
|
||||||
{...contentWrapperProps}
|
ReactDOM.createPortal(
|
||||||
className={twMerge(
|
<div
|
||||||
"absolute z-10 mt-1",
|
{...contentWrapperProps}
|
||||||
position == "left"
|
className={twMerge(
|
||||||
? "left-[100%] top-[50%] -translate-y-[50%]"
|
"fixed z-[1000]",
|
||||||
: position == "right"
|
above ? "mb-1" : "mt-1",
|
||||||
? "right-[100%] top-[50%] -translate-y-[50%]"
|
open ? "flex" : "hidden",
|
||||||
: position == "bottom-left"
|
"twui-dropdown-content",
|
||||||
? "left-0 top-[100%]"
|
contentWrapperProps?.className,
|
||||||
: position == "bottom-right"
|
)}
|
||||||
? "right-0 top-[100%]"
|
onMouseEnter={() => {
|
||||||
: position == "center"
|
if (!hoverOpen) return;
|
||||||
? "left-[50%] -translate-x-[50%] top-[100%]"
|
window.clearTimeout(timeoutRef.current);
|
||||||
: position == "top"
|
}}
|
||||||
? "left-[50%] -translate-x-[50%] bottom-[100%]"
|
onBlur={() => {
|
||||||
: "top-[100%]",
|
if (!hoverOpen) return;
|
||||||
above ? "-translate-y-[120%]" : "",
|
window.clearTimeout(timeoutRef.current);
|
||||||
open ? "flex" : "hidden",
|
}}
|
||||||
"twui-dropdown-content",
|
style={{
|
||||||
contentWrapperProps?.className,
|
...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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user