fix: 셀렉트 드롭다운이 다른 컴포넌트에 가려지는 문제 해결

- React Portal 적용하여 드롭다운을 document.body에 렌더링
- Stacking Context 탈출로 z-index 충돌 문제 해결
- 모든 셀렉트 타입(code, autocomplete, dropdown, multiselect)에 적용
This commit is contained in:
SeongHyun Kim 2025-12-01 10:29:14 +09:00
commit 9e6fa67215
1 changed files with 112 additions and 25 deletions

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes"; import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles"; import { cn } from "@/lib/registry/components/common/inputStyles";
@ -65,6 +66,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
}); });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// 드롭다운 위치 (Portal 렌더링용)
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
const config = (props as any).webTypeConfig || componentConfig || {}; const config = (props as any).webTypeConfig || componentConfig || {};
@ -326,9 +329,26 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
}, [selectedValue, codeOptions, config.options]); }, [selectedValue, codeOptions, config.options]);
// 클릭 이벤트 핸들러 (React Query로 간소화) // 클릭 이벤트 핸들러 (React Query로 간소화)
// 드롭다운 위치 계산 함수
const updateDropdownPosition = () => {
if (selectRef.current) {
const rect = selectRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
const handleToggle = () => { const handleToggle = () => {
if (isDesignMode) return; if (isDesignMode) return;
// 드롭다운 열기 전에 위치 계산
if (!isOpen) {
updateDropdownPosition();
}
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요 // React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
@ -450,9 +470,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
value={searchQuery || selectedLabel} value={searchQuery || selectedLabel}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
updateDropdownPosition();
setIsOpen(true);
}}
onFocus={() => {
updateDropdownPosition();
setIsOpen(true); setIsOpen(true);
}} }}
onFocus={() => setIsOpen(true)}
placeholder="코드 또는 코드명 입력..." placeholder="코드 또는 코드명 입력..."
className={cn( className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
@ -461,8 +485,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
)} )}
readOnly={isDesignMode} readOnly={isDesignMode}
/> />
{isOpen && !isDesignMode && filteredOptions.length > 0 && ( {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"> {isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredOptions.map((option, index) => ( {filteredOptions.map((option, index) => (
<div <div
key={`${option.value}-${index}`} key={`${option.value}-${index}`}
@ -478,7 +510,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div> </div>
</div> </div>
))} ))}
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -508,8 +541,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
{isOpen && !isDesignMode && ( {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"> {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{isLoadingCodes ? ( {isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
@ -525,7 +566,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -590,9 +632,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
updateDropdownPosition();
setIsOpen(true);
}}
onFocus={() => {
updateDropdownPosition();
setIsOpen(true); setIsOpen(true);
}} }}
onFocus={() => setIsOpen(true)}
placeholder={placeholder} placeholder={placeholder}
className={cn( className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
@ -601,8 +647,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
)} )}
readOnly={isDesignMode} readOnly={isDesignMode}
/> />
{isOpen && !isDesignMode && filteredOptions.length > 0 && ( {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"> {isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredOptions.map((option, index) => ( {filteredOptions.map((option, index) => (
<div <div
key={`${option.value}-${index}`} key={`${option.value}-${index}`}
@ -620,7 +674,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
{option.label} {option.label}
</div> </div>
))} ))}
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -650,8 +705,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
{isOpen && !isDesignMode && ( {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg"> {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[99999] rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
@ -676,7 +739,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div> </div>
))} ))}
</div> </div>
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -693,7 +757,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
!isDesignMode && "hover:border-orange-400", !isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500", isSelected && "ring-2 ring-orange-500",
)} )}
onClick={() => !isDesignMode && setIsOpen(true)} onClick={() => {
if (!isDesignMode) {
updateDropdownPosition();
setIsOpen(true);
}
}}
style={{ style={{
pointerEvents: isDesignMode ? "none" : "auto", pointerEvents: isDesignMode ? "none" : "auto",
height: "100%" height: "100%"
@ -726,22 +795,30 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<span className="text-gray-500">{placeholder}</span> <span className="text-gray-500">{placeholder}</span>
)} )}
</div> </div>
{isOpen && !isDesignMode && ( {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"> {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{(isLoadingCodes || isLoadingCategories) ? ( {(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
allOptions.map((option, index) => { allOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value); const isOptionSelected = selectedValues.includes(option.value);
return ( return (
<div <div
key={`${option.value}-${index}`} key={`${option.value}-${index}`}
className={cn( className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100", "cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isSelected && "bg-blue-50 font-medium" isOptionSelected && "bg-blue-50 font-medium"
)} )}
onClick={() => { onClick={() => {
const newVals = isSelected const newVals = isOptionSelected
? selectedValues.filter((v) => v !== option.value) ? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value]; : [...selectedValues, option.value];
setSelectedValues(newVals); setSelectedValues(newVals);
@ -754,7 +831,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isOptionSelected}
onChange={() => {}} onChange={() => {}}
className="h-4 w-4" className="h-4 w-4"
/> />
@ -766,7 +843,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -795,8 +873,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
{isOpen && !isDesignMode && ( {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"> {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{isLoadingCodes ? ( {isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
@ -812,7 +898,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}
</div> </div>,
document.body
)} )}
</div> </div>
); );