From 80c78d75bc2e53155590aea1bc05b649b5f95185 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 8 Jun 2026 21:09:02 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=83=81=EB=A7=A4=EB=A7=A4=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=B0=B0=EC=B9=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/GridLayoutPicker.tsx | 117 +++++++++++++----- .../virtual/VirtualGridUnifiedHeader.tsx | 1 - 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/common/GridLayoutPicker.tsx b/frontend/src/components/common/GridLayoutPicker.tsx index 68c5846..59facd2 100644 --- a/frontend/src/components/common/GridLayoutPicker.tsx +++ b/frontend/src/components/common/GridLayoutPicker.tsx @@ -1,7 +1,8 @@ /** * 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업) — 알림 목록·가상매매 공용 */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { gridPresetsByGroup, getTradeNotificationGridPreset, @@ -134,6 +135,8 @@ const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset ); }; +const POPOVER_WIDTH = 320; + const GridLayoutPicker: React.FC = ({ value, onChange, @@ -142,14 +145,46 @@ const GridLayoutPicker: React.FC = ({ popoverAlign = 'right', }) => { const [open, setOpen] = useState(false); + const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null); const rootRef = useRef(null); + const popoverRef = useRef(null); const active = getTradeNotificationGridPreset(value); const groups = [...gridPresetsByGroup().entries()].sort((a, b) => a[0] - b[0]); + const updatePopoverPos = useCallback(() => { + const el = rootRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const width = Math.min(POPOVER_WIDTH, window.innerWidth - 48); + const left = popoverAlign === 'left' + ? rect.left + : rect.right - width; + setPopoverPos({ + top: rect.bottom + 8, + left: Math.max(8, Math.min(left, window.innerWidth - width - 8)), + }); + }, [popoverAlign]); + + useEffect(() => { + if (!open) { + setPopoverPos(null); + return; + } + updatePopoverPos(); + window.addEventListener('resize', updatePopoverPos); + window.addEventListener('scroll', updatePopoverPos, true); + return () => { + window.removeEventListener('resize', updatePopoverPos); + window.removeEventListener('scroll', updatePopoverPos, true); + }; + }, [open, updatePopoverPos]); + useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { - if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + const t = e.target as Node; + if (rootRef.current?.contains(t) || popoverRef.current?.contains(t)) return; + setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); @@ -164,9 +199,55 @@ const GridLayoutPicker: React.FC = ({ const popoverClass = [ cls(variant, 'popover'), + cls(variant, 'popover--portal'), popoverAlign === 'left' ? cls(variant, 'popover--align-left') : '', ].filter(Boolean).join(' '); + const popover = open && popoverPos ? ( +
+

그리드 배치

+ {groups.map(([group, presets]) => ( +
+ {group} +
+ {presets.map(preset => ( + + ))} +
+
+ ))} +
+ ) : null; + return (
- {open && ( -
-

그리드 배치

- {groups.map(([group, presets]) => ( -
- {group} -
- {presets.map(preset => ( - - ))} -
-
- ))} -
- )} + {popover && createPortal(popover, document.body)}
); }; diff --git a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx index 86fe400..b6cb04b 100644 --- a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx +++ b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx @@ -133,7 +133,6 @@ export const VirtualViewHeaderControls: React.FC = ({ )} {showSignalChartToggle && onGlobalDisplayModeChange && (