Skip to content

Commit 096034e

Browse files
authored
Merge pull request #267 from boostcampwm-2024/feature-fe-#266
Popover 공용 컴포넌트 구현
2 parents b187192 + baf6f08 commit 096034e

File tree

5 files changed

+220
-0
lines changed

5 files changed

+220
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
2+
import { usePopover } from "@/hooks/usePopover";
3+
import { cn } from "@/lib/utils";
4+
import { getPosition } from "@/lib/getPopoverPosition";
5+
6+
interface ContentProps {
7+
children: React.ReactNode;
8+
className?: string;
9+
}
10+
11+
export function Content({ children, className }: ContentProps) {
12+
const { open, setOpen, triggerRef, placement, offset } = usePopover();
13+
const contentRef = useRef<HTMLDivElement>(null);
14+
const [position, setPosition] = useState({ top: 0, left: 0 });
15+
16+
useLayoutEffect(() => {
17+
if (open && triggerRef.current && contentRef.current) {
18+
const triggerRect = triggerRef.current.getBoundingClientRect();
19+
const contentRect = contentRef.current.getBoundingClientRect();
20+
const newPosition = getPosition(
21+
triggerRect,
22+
contentRect,
23+
placement,
24+
offset,
25+
);
26+
setPosition(newPosition);
27+
}
28+
}, [open, placement, offset, triggerRef]);
29+
30+
useEffect(() => {
31+
const handleClickOutside = (e: MouseEvent) => {
32+
if (
33+
!contentRef.current?.contains(e.target as Node) &&
34+
!triggerRef.current?.contains(e.target as Node)
35+
) {
36+
setOpen(false);
37+
}
38+
};
39+
40+
const handleEscape = (e: KeyboardEvent) => {
41+
if (e.key === "Escape") setOpen(false);
42+
};
43+
44+
document.addEventListener("mousedown", handleClickOutside);
45+
document.addEventListener("keydown", handleEscape);
46+
47+
return () => {
48+
document.removeEventListener("mousedown", handleClickOutside);
49+
document.removeEventListener("keydown", handleEscape);
50+
};
51+
}, [setOpen, triggerRef]);
52+
53+
if (!open) return null;
54+
55+
return (
56+
<div
57+
ref={contentRef}
58+
className={cn("fixed z-50", className)}
59+
style={{
60+
top: `${position.top}px`,
61+
left: `${position.left}px`,
62+
}}
63+
>
64+
{children}
65+
</div>
66+
);
67+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { usePopover } from "@/hooks/usePopover";
2+
3+
interface TriggerProps {
4+
children: React.ReactNode;
5+
}
6+
7+
export function Trigger({ children }: TriggerProps) {
8+
const { open, setOpen, triggerRef } = usePopover();
9+
10+
return (
11+
<div ref={triggerRef} onClick={() => setOpen(!open)}>
12+
{children}
13+
</div>
14+
);
15+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useRef, useState } from "react";
2+
import { Content } from "./Content";
3+
import { Trigger } from "./Trigger";
4+
import { PopoverContext, Placement, Offset } from "@/hooks/usePopover";
5+
6+
interface PopoverProps {
7+
children: React.ReactNode;
8+
placement?: Placement;
9+
offset?: Partial<Offset>;
10+
}
11+
12+
function Popover({
13+
children,
14+
placement = "bottom",
15+
offset = { x: 0, y: 0 },
16+
}: PopoverProps) {
17+
const [open, setOpen] = useState(false);
18+
const triggerRef = useRef<HTMLDivElement>(null);
19+
20+
const fullOffset: Offset = {
21+
x: offset.x ?? 0,
22+
y: offset.y ?? 0,
23+
};
24+
25+
return (
26+
<PopoverContext.Provider
27+
value={{
28+
open,
29+
setOpen,
30+
triggerRef,
31+
placement,
32+
offset: fullOffset,
33+
}}
34+
>
35+
{children}
36+
</PopoverContext.Provider>
37+
);
38+
}
39+
40+
Popover.Trigger = Trigger;
41+
Popover.Content = Content;
42+
43+
export { Popover };
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createContext, useContext } from "react";
2+
3+
export type Placement = "top" | "right" | "bottom" | "left";
4+
5+
export interface Offset {
6+
x: number;
7+
y: number;
8+
}
9+
10+
export interface PopoverContextType {
11+
open: boolean;
12+
setOpen: (open: boolean) => void;
13+
triggerRef: React.RefObject<HTMLDivElement>;
14+
placement: Placement;
15+
offset: Offset;
16+
}
17+
18+
export const PopoverContext = createContext<PopoverContextType | null>(null);
19+
20+
export function usePopover() {
21+
const context = useContext(PopoverContext);
22+
if (!context) {
23+
throw new Error("Popover 컨텍스트를 찾을 수 없음.");
24+
}
25+
return context;
26+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Placement, Offset } from "@/hooks/usePopover";
2+
3+
export interface Position {
4+
top: number;
5+
left: number;
6+
}
7+
8+
interface PositionConfig {
9+
triggerRect: DOMRect;
10+
contentRect: DOMRect;
11+
offset: Offset;
12+
}
13+
14+
const getTopPosition = ({
15+
triggerRect,
16+
contentRect,
17+
offset,
18+
}: PositionConfig): Position => ({
19+
top: triggerRect.top - contentRect.height - offset.y,
20+
left:
21+
triggerRect.left + (triggerRect.width - contentRect.width) / 2 + offset.x,
22+
});
23+
24+
const getRightPosition = ({
25+
triggerRect,
26+
contentRect,
27+
offset,
28+
}: PositionConfig): Position => ({
29+
top:
30+
triggerRect.top + (triggerRect.height - contentRect.height) / 2 + offset.y,
31+
left: triggerRect.right + offset.x,
32+
});
33+
34+
const getBottomPosition = ({
35+
triggerRect,
36+
contentRect,
37+
offset,
38+
}: PositionConfig): Position => ({
39+
top: triggerRect.bottom + offset.y,
40+
left:
41+
triggerRect.left + (triggerRect.width - contentRect.width) / 2 + offset.x,
42+
});
43+
44+
const getLeftPosition = ({
45+
triggerRect,
46+
contentRect,
47+
offset,
48+
}: PositionConfig): Position => ({
49+
top:
50+
triggerRect.top + (triggerRect.height - contentRect.height) / 2 + offset.y,
51+
left: triggerRect.left - contentRect.width - offset.x,
52+
});
53+
54+
const positionMap: Record<Placement, (config: PositionConfig) => Position> = {
55+
top: getTopPosition,
56+
right: getRightPosition,
57+
bottom: getBottomPosition,
58+
left: getLeftPosition,
59+
};
60+
61+
export function getPosition(
62+
triggerRect: DOMRect,
63+
contentRect: DOMRect,
64+
placement: Placement,
65+
offset: Offset,
66+
): Position {
67+
const config = { triggerRect, contentRect, offset };
68+
return positionMap[placement](config);
69+
}

0 commit comments

Comments
 (0)