274 lines
8.5 KiB
TypeScript
274 lines
8.5 KiB
TypeScript
import React, {
|
|
type DetailedHTMLProps,
|
|
type HTMLAttributes,
|
|
type PropsWithChildren,
|
|
} from "react";
|
|
import ReactDOM from "react-dom";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
export const TWUIDropdownContentPositions = [
|
|
"left",
|
|
"bottom-left",
|
|
"top-left",
|
|
"top",
|
|
"bottom",
|
|
"right",
|
|
"bottom-right",
|
|
"top-right",
|
|
"center",
|
|
] as const;
|
|
|
|
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
|
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
target: React.ReactNode;
|
|
contentWrapperProps?: DetailedHTMLProps<
|
|
HTMLAttributes<HTMLDivElement>,
|
|
HTMLDivElement
|
|
>;
|
|
targetWrapperProps?: DetailedHTMLProps<
|
|
HTMLAttributes<HTMLDivElement>,
|
|
HTMLDivElement
|
|
>;
|
|
debounce?: number;
|
|
openDebounce?: number;
|
|
hoverOpen?: boolean;
|
|
above?: boolean;
|
|
position?: (typeof TWUIDropdownContentPositions)[number];
|
|
topOffset?: number;
|
|
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
externalOpen?: boolean;
|
|
keepOpen?: boolean;
|
|
disableClickActions?: boolean;
|
|
};
|
|
|
|
/**
|
|
* # Toggle Component
|
|
* @className_wrapper twui-dropdown-wrapper
|
|
* @className_wrapper twui-dropdown-target
|
|
* @className_wrapper twui-dropdown-content
|
|
*
|
|
* @note use the class `cancel-link` to prevent popup open on click
|
|
*/
|
|
export default function Dropdown({
|
|
contentWrapperProps,
|
|
targetWrapperProps,
|
|
hoverOpen,
|
|
above,
|
|
debounce = 200,
|
|
openDebounce = 200,
|
|
target,
|
|
position = "center",
|
|
topOffset,
|
|
externalSetOpen,
|
|
keepOpen,
|
|
disableClickActions,
|
|
externalOpen,
|
|
...props
|
|
}: TWUI_DROPDOWN_PROPS) {
|
|
const [open, setOpen] = React.useState(externalOpen);
|
|
const [mounted, setMounted] = React.useState(false);
|
|
const [contentStyle, setContentStyle] = React.useState<React.CSSProperties>(
|
|
{},
|
|
);
|
|
|
|
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) {
|
|
externalSetOpen?.(false);
|
|
return setOpen(false);
|
|
}
|
|
if (closestWrapper && closestWrapper !== dropdownRef.current) {
|
|
externalSetOpen?.(false);
|
|
return setOpen(false);
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (keepOpen) return;
|
|
document.addEventListener("click", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("click", handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
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}
|
|
className={twMerge(
|
|
"flex flex-col items-center relative",
|
|
"twui-dropdown-wrapper",
|
|
props.className,
|
|
)}
|
|
onMouseEnter={() => {
|
|
if (!hoverOpen) return;
|
|
window.clearTimeout(timeoutRef.current);
|
|
window.clearTimeout(openTimeoutRef.current);
|
|
|
|
openTimeoutRef.current = setTimeout(() => {
|
|
externalSetOpen?.(true);
|
|
setOpen(true);
|
|
}, openDebounce);
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!hoverOpen) return;
|
|
|
|
window.clearTimeout(openTimeoutRef.current);
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
externalSetOpen?.(false);
|
|
setOpen(false);
|
|
}, debounce);
|
|
}}
|
|
onBlur={() => {
|
|
window.clearTimeout(timeoutRef.current);
|
|
}}
|
|
ref={dropdownRef}
|
|
>
|
|
<div
|
|
onClick={(e) => {
|
|
const targetEl = e.target as HTMLElement | null;
|
|
if (targetEl?.closest?.(".cancel-link")) return;
|
|
if (disableClickActions) return;
|
|
externalSetOpen?.(!open);
|
|
setOpen(!open);
|
|
}}
|
|
className={twMerge(
|
|
"cursor-pointer",
|
|
"twui-dropdown-target",
|
|
targetWrapperProps?.className,
|
|
)}
|
|
>
|
|
{target}
|
|
</div>
|
|
|
|
{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,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|