Skip to content

Commit

Permalink
Merge pull request #267 from boostcampwm-2024/feature-fe-#266
Browse files Browse the repository at this point in the history
Popover 공용 컴포넌트 구현
  • Loading branch information
yewonJin authored Nov 24, 2024
2 parents b187192 + baf6f08 commit 096034e
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 0 deletions.
67 changes: 67 additions & 0 deletions apps/frontend/src/components/commons/popover/Content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { usePopover } from "@/hooks/usePopover";
import { cn } from "@/lib/utils";
import { getPosition } from "@/lib/getPopoverPosition";

interface ContentProps {
children: React.ReactNode;
className?: string;
}

export function Content({ children, className }: ContentProps) {
const { open, setOpen, triggerRef, placement, offset } = usePopover();
const contentRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });

useLayoutEffect(() => {
if (open && triggerRef.current && contentRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const contentRect = contentRef.current.getBoundingClientRect();
const newPosition = getPosition(
triggerRect,
contentRect,
placement,
offset,
);
setPosition(newPosition);
}
}, [open, placement, offset, triggerRef]);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
!contentRef.current?.contains(e.target as Node) &&
!triggerRef.current?.contains(e.target as Node)
) {
setOpen(false);
}
};

const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};

document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);

return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [setOpen, triggerRef]);

if (!open) return null;

return (
<div
ref={contentRef}
className={cn("fixed z-50", className)}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{children}
</div>
);
}
15 changes: 15 additions & 0 deletions apps/frontend/src/components/commons/popover/Trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { usePopover } from "@/hooks/usePopover";

interface TriggerProps {
children: React.ReactNode;
}

export function Trigger({ children }: TriggerProps) {
const { open, setOpen, triggerRef } = usePopover();

return (
<div ref={triggerRef} onClick={() => setOpen(!open)}>
{children}
</div>
);
}
43 changes: 43 additions & 0 deletions apps/frontend/src/components/commons/popover/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useRef, useState } from "react";
import { Content } from "./Content";
import { Trigger } from "./Trigger";
import { PopoverContext, Placement, Offset } from "@/hooks/usePopover";

interface PopoverProps {
children: React.ReactNode;
placement?: Placement;
offset?: Partial<Offset>;
}

function Popover({
children,
placement = "bottom",
offset = { x: 0, y: 0 },
}: PopoverProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);

const fullOffset: Offset = {
x: offset.x ?? 0,
y: offset.y ?? 0,
};

return (
<PopoverContext.Provider
value={{
open,
setOpen,
triggerRef,
placement,
offset: fullOffset,
}}
>
{children}
</PopoverContext.Provider>
);
}

Popover.Trigger = Trigger;
Popover.Content = Content;

export { Popover };
26 changes: 26 additions & 0 deletions apps/frontend/src/hooks/usePopover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createContext, useContext } from "react";

export type Placement = "top" | "right" | "bottom" | "left";

export interface Offset {
x: number;
y: number;
}

export interface PopoverContextType {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement>;
placement: Placement;
offset: Offset;
}

export const PopoverContext = createContext<PopoverContextType | null>(null);

export function usePopover() {
const context = useContext(PopoverContext);
if (!context) {
throw new Error("Popover 컨텍스트를 찾을 수 없음.");
}
return context;
}
69 changes: 69 additions & 0 deletions apps/frontend/src/lib/getPopoverPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Placement, Offset } from "@/hooks/usePopover";

export interface Position {
top: number;
left: number;
}

interface PositionConfig {
triggerRect: DOMRect;
contentRect: DOMRect;
offset: Offset;
}

const getTopPosition = ({
triggerRect,
contentRect,
offset,
}: PositionConfig): Position => ({
top: triggerRect.top - contentRect.height - offset.y,
left:
triggerRect.left + (triggerRect.width - contentRect.width) / 2 + offset.x,
});

const getRightPosition = ({
triggerRect,
contentRect,
offset,
}: PositionConfig): Position => ({
top:
triggerRect.top + (triggerRect.height - contentRect.height) / 2 + offset.y,
left: triggerRect.right + offset.x,
});

const getBottomPosition = ({
triggerRect,
contentRect,
offset,
}: PositionConfig): Position => ({
top: triggerRect.bottom + offset.y,
left:
triggerRect.left + (triggerRect.width - contentRect.width) / 2 + offset.x,
});

const getLeftPosition = ({
triggerRect,
contentRect,
offset,
}: PositionConfig): Position => ({
top:
triggerRect.top + (triggerRect.height - contentRect.height) / 2 + offset.y,
left: triggerRect.left - contentRect.width - offset.x,
});

const positionMap: Record<Placement, (config: PositionConfig) => Position> = {
top: getTopPosition,
right: getRightPosition,
bottom: getBottomPosition,
left: getLeftPosition,
};

export function getPosition(
triggerRect: DOMRect,
contentRect: DOMRect,
placement: Placement,
offset: Offset,
): Position {
const config = { triggerRect, contentRect, offset };
return positionMap[placement](config);
}

0 comments on commit 096034e

Please sign in to comment.