기존 Dropdown 공통 컴포넌트는 "목록 열기/닫기 + 아이템 선택"에는 적합했지만,
아래 요구사항을 한 번에 만족시키기엔 역할이 부족했다.
Esc 입력으로 닫기그래서 단순 드롭다운을 확장하는 대신, 검색과 선택 상태를 포함한 SearchDropdown을 별도 컴포넌트로 분리했다.
SearchDropdown은 compound component 패턴으로 구성했다.
SearchDropdown: 상태 소유 (searchData, isOpen, selectedUser)SearchDropdown.Trigger: 입력/트리거 렌더링SearchDropdown.UserList: 필터링된 목록 렌더링SearchDropdown.UserItem: 항목 선택 처리상위에서 Context로 상태/핸들러를 공유하고, 하위는 필요한 값만 꺼내 사용한다.
// src/components/searchDropdown/SearchDropdown.tsx
export const SearchDropdown = ({ children, className = BASE }: SearchDropdownProps) => {
const [searchData, setSearchData] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const handleSearch = (data: string) => setSearchData(data);
const handleToggle = useCallback(() => {
setSearchData('');
setIsOpen((prev) => !prev);
}, []);
const handleClose = useCallback(() => {
setIsOpen(false);
setSearchData('');
}, []);
const dropdownRef = useDropdownClose(handleClose);
return (
<SearchDropdownContext.Provider
value={{
searchData,
handleSearch,
isOpen,
handleToggle,
selectedUser,
setSelectedUser,
}}
>
<div ref={dropdownRef} className={className}>
{children}
</div>
</SearchDropdownContext.Provider>
);
};
SearchDropdown.Trigger = SearchDropdownTrigger;
SearchDropdown.UserList = SearchDropdownUserList;
SearchDropdown.UserItem = SearchDropdownUserItem;
핵심은 루트에서 상태를 모아두고, 사용 시점에는 아래처럼 선언적으로 조합하는 점이다.
<SearchDropdown>
<SearchDropdown.Trigger label="담당자 선택" />
<SearchDropdown.UserList items={users}>
{(it) => (
<SearchDropdown.UserItem
key={it.id}
user={it}
onSelect={() => setSelected(it)}
isSelected={selected?.id === it.id}
/>
)}
</SearchDropdown.UserList>
</SearchDropdown>
selectedUser가 있고 검색어가 비어 있으면, 입력창 위에 선택 사용자 정보를 오버레이로 표시한다.
// src/components/searchDropdown/SearchDropdownTrigger.tsx
const showSelectedPlaceholder = !!selectedUser && searchData === '';
{showSelectedPlaceholder && (
<div className="absolute ... pointer-events-none">
<Image src={selectedUser.profileImg} alt={selectedUser.content} width={22} height={22} />
<span>{selectedUser.content}</span>
</div>
)}
<input
type="text"
onClick={handleToggle}
value={searchData}
placeholder={!selectedUser ? label : ''}
onChange={(e) => handleSearch(e.target.value)}
/>
드롭다운이 열렸을 때만 목록을 렌더링하고, 검색어로 필터링한다.
// src/components/searchDropdown/SearchDropdownUserList.tsx
if (!isOpen) return null;
const filtered =
searchData === ''
? items
: items.filter((it) => it.content.toLowerCase().includes(searchData.toLowerCase()));
if (filtered.length === 0) return null;
return <div className={className}>{filtered.map(children)}</div>;
항목을 클릭하면 상위 선택 로직(onSelect) 실행 후, 컨텍스트 상태를 업데이트하고 드롭다운을 닫는다.
// src/components/searchDropdown/SearchDropdownUserItem.tsx
onClick={() => {
onSelect();
setSelectedUser(user);
handleToggle();
}}
SearchDropdown은 바깥 클릭/Esc 처리 로직을 커스텀 훅으로 분리해 재사용한다.
// src/hooks/useDropdownClose.tsx
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handlePressEsc);
이번 SearchDropdown은 단일 UI를 넘어서,
"검색 + 선택 + 닫힘 제어"를 공통 패턴으로 묶어 재사용성을 높인 사례다.
핵심은 다음 두 가지였다.
Dropdown과 동일하게 재사용한다.이렇게 분리해 두면 이후 멀티 선택, 비동기 검색, 키보드 네비게이션 같은 요구사항도 비교적 작은 변경으로 확장할 수 있다.
