Skip to content
File

Blob: src/client/components/ui/dropdown-portal.tsx

typescript112 lines
1import {
2 useEffect,
3 useEffectEvent,
4 useLayoutEffect,
5 useRef,
6 type CSSProperties,
7 type ReactNode,
8 type RefObject,
9} from "react";
10import { autoUpdate, flip, offset, shift, size, useFloating } from "@floating-ui/react";
11import { createPortal } from "react-dom";
12 
13type Align = "left" | "right";
14type WidthMode = "fixed" | "match-trigger";
15 
16interface DropdownPortalProps {
17 triggerRef: RefObject<HTMLElement | null>;
18 align?: Align;
19 width?: number;
20 widthMode?: WidthMode;
21 zIndex?: number;
22 className?: string;
23 children: ReactNode;
24 onClose?: () => void;
25}
26 
27export function DropdownPortal({
28 triggerRef,
29 align = "right",
30 width = 128,
31 widthMode = "fixed",
32 zIndex = 50,
33 className,
34 children,
35 onClose,
36}: DropdownPortalProps) {
37 const panelRef = useRef<HTMLDivElement>(null);
38 const placement = align === "right" ? "bottom-end" : "bottom-start";
39 const matchTrigger = widthMode === "match-trigger";
40 const { refs, floatingStyles } = useFloating({
41 open: true,
42 placement,
43 strategy: "fixed",
44 middleware: matchTrigger
45 ? [
46 offset(4),
47 flip({ padding: 8 }),
48 shift({ padding: 8 }),
49 size({
50 apply({ rects, elements }) {
51 elements.floating.style.width = `${rects.reference.width}px`;
52 },
53 }),
54 ]
55 : [offset(4), flip({ padding: 8 }), shift({ padding: 8 })],
56 whileElementsMounted: autoUpdate,
57 });
58 const panelAnimationStyle: CSSProperties = {
59 animation: "var(--animate-menu)",
60 transformOrigin: align === "right" ? "top right" : "top left",
61 };
62 
63 useLayoutEffect(() => {
64 const referenceEl = triggerRef.current;
65 if (!referenceEl) return;
66 refs.setReference(referenceEl);
67 }, [refs, triggerRef]);
68 
69 // Effect Event so callers can pass inline `onClose={() => setOpen(false)}` without
70 // re-binding document listeners on every parent render.
71 const handleClose = useEffectEvent(() => onClose?.());
72 
73 useEffect(() => {
74 function handleClick(e: PointerEvent) {
75 const target = e.target as Node | null;
76 if (!target) return;
77 if (triggerRef.current?.contains(target)) return;
78 if (panelRef.current?.contains(target)) return;
79 handleClose();
80 }
81 
82 function handleKeyDown(e: KeyboardEvent) {
83 if (e.key === "Escape") handleClose();
84 }
85 
86 document.addEventListener("pointerdown", handleClick, true);
87 document.addEventListener("keydown", handleKeyDown);
88 return () => {
89 document.removeEventListener("pointerdown", handleClick, true);
90 document.removeEventListener("keydown", handleKeyDown);
91 };
92 }, [triggerRef]);
93 
94 return createPortal(
95 <div
96 ref={(node) => {
97 refs.setFloating(node);
98 panelRef.current = node;
99 }}
100 style={{ ...floatingStyles, ...(matchTrigger ? {} : { width }), zIndex }}
101 >
102 <div
103 className={`w-full rounded-md border border-zinc-700 bg-zinc-800 shadow-lg ${className ?? ""}`}
104 style={panelAnimationStyle}
105 >
106 {children}
107 </div>
108 </div>,
109 document.body,
110 );
111}