Files
tailwind-ui-library/components/elements/Dropdown.tsx
T
tbenandClaude Opus 5.5 d19d412066 Updates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 07:04:14 +01:00

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>
);
}