props · ...props · ?. · ?? · useState · useEffect · useRef · useLayoutEffect · 위/아래 자동 펼침까지 한 페이지 정리
기본 <select> 대신 직접 만든 커스텀 드롭다운입니다. 버튼을 클릭하면 목록이 열리고, 항목을 선택하면 선택값이 표시됩니다. 바깥 클릭 시 닫히며 화면 아래 공간이 부족하면 목록을 위쪽으로 펼칩니다.
export default function DropdownList({
className,
label = '부서명',
items = ['항목', '항목', '항목'],
selectedIndex = null,
onChange,
...props
}) {
| prop | 역할 |
|---|---|
| className | 외부에서 추가할 CSS 클래스 |
| label | 아직 선택하지 않았을 때 표시할 문구 |
| items | 드롭다운에 표시할 항목 배열 |
| selectedIndex | 선택된 항목의 index |
| onChange | 항목 선택 후 부모에게 값을 전달하는 함수 |
| ...props | 위에서 따로 꺼내지 않은 나머지 props |
label = '\uBD80\uC11C\uBA85' items = ['\uD56D\uBAA9', '\uD56D\uBAA9', '\uD56D\uBAA9']
위 코드는 실제로 다음과 같습니다.
label = '부서명' items = ['항목', '항목', '항목']
<DropdownList
label="부서 선택"
items={['개발팀', '디자인팀', '기획팀']}
selectedIndex={0}
onChange={handleChange}
id="departmentDropdown"
title="부서를 선택하세요"
data-type="department"
/>
label, items, selectedIndex, onChange는 미리 꺼냈으므로 나머지만 props 객체에 들어갑니다.
props = {
id: 'departmentDropdown',
title: '부서를 선택하세요',
'data-type': 'department'
}
아래처럼 다시 펼치면:
<div
className={cn(styles.dropdownList, className)}
ref={dropdownRef}
{...props}
>
실제 DOM에는 개념적으로 다음과 같이 들어갑니다.
<div id="departmentDropdown" title="부서를 선택하세요" data-type="department" >
<DropdownList
label="부서 선택"
items={['개발팀', '디자인팀', '기획팀']}
id="departmentDropdown"
title="부서를 선택하세요"
data-code="DEP001"
onMouseEnter={() => console.log('마우스 진입')}
/>
id, title, data-code, onMouseEnter는 별도로 꺼내지 않았기 때문에 ...props에 들어가 최상위 div에 전달됩니다.
const [isOpen, setIsOpen] = useState(false)
const [currentIndex, setCurrentIndex] = useState(selectedIndex)
const [direction, setDirection] = useState('down')
| state | 의미 |
|---|---|
| isOpen | 드롭다운 열림 여부 |
| currentIndex | 현재 선택된 항목의 index |
| direction | 목록이 아래 또는 위로 펼쳐질지 |
const buttonText = selectedItem ?? label
??는 왼쪽 값이 null 또는 undefined일 때만 오른쪽 값을 사용합니다.
selectedItem이 값이 있으면 → selectedItem 사용 selectedItem이 null 또는 undefined이면 → label 사용
따라서 다음 코드와 거의 같은 의미입니다.
let buttonText
if (selectedItem === null || selectedItem === undefined) {
buttonText = label
} else {
buttonText = selectedItem
}
const selectedItem = '개발팀' const buttonText = selectedItem ?? '부서명' // 결과 buttonText === '개발팀'
const selectedItem = null const buttonText = selectedItem ?? '부서명' // 결과 buttonText === '부서명'
??는 null, undefined만 없는 값으로 판단합니다.||는 '', 0, false도 없는 값처럼 판단합니다.
0 ?? 10 // 0 0 || 10 // 10 '' ?? '기본값' // '' '' || '기본값' // '기본값'
?.는 앞의 값이 null 또는 undefined가 아닐 때만 뒤의 속성, 메서드, 함수를 실행합니다.
onChange?.(item, index)
의미는:
onChange 함수가 존재하면 → onChange(item, index) 실행 onChange가 undefined 또는 null이면 → 아무것도 하지 않음
일반 if문으로 작성하면:
if (onChange) {
onChange(item, index)
}
따라서 부모가 onChange를 전달하지 않아도 에러가 발생하지 않습니다.
<DropdownList
items={['개발팀', '기획팀']}
/>
// onChange가 없어도 정상 동작
dropdownRef.current?.contains(event.target)
의미는:
dropdownRef.current가 존재하면 → contains(event.target) 실행 dropdownRef.current가 null 또는 undefined이면 → contains()를 실행하지 않음 → 결과는 undefined
일반적으로 풀어쓰면:
dropdownRef.current && dropdownRef.current.contains(event.target)
if (!dropdownRef.current?.contains(event.target)) {
setIsOpen(false)
}
이 코드는 처음 보면 복잡해 보이지만 세 단계로 나누면 쉽습니다.
dropdownRef.current
dropdownRef.current?.contains(event.target)
내부를 클릭했다면:
true
외부를 클릭했다면:
false
!true // false !false // true
그래서 최종 의미는:
조금 길게 풀어쓴다면 다음처럼 생각할 수 있습니다.
if (dropdownRef.current) {
const isInside =
dropdownRef.current.contains(event.target)
if (!isInside) {
setIsOpen(false)
}
}
useEffect(() => {
setCurrentIndex(selectedIndex)
}, [selectedIndex])
부모의 selectedIndex가 바뀌면 내부의 currentIndex도 같은 값으로 맞춥니다.
const dropdownRef = useRef(null)
<div ref={dropdownRef}>
렌더링 후 dropdownRef.current에는 실제 div DOM이 들어갑니다.
useEffect(() => {
function handleClickOutside(event) {
if (!dropdownRef.current?.contains(event.target)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () =>
document.removeEventListener(
'mousedown',
handleClickOutside
)
}, [])
document 전체에서 mousedown을 감지해서 DropdownList 바깥을 클릭했는지 검사합니다. return 부분은 이벤트를 제거하는 cleanup입니다.
const gap = 5 const maxListHeight = 121 const listHeight = Math.min(items.length * 40, maxListHeight) const rect = dropdownRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom const spaceAbove = rect.top
setDirection(
spaceBelow < listHeight + gap &&
spaceAbove > spaceBelow
? 'up'
: 'down'
)
아래 공간이 부족하면서 위 공간이 더 크면 up, 아니면 down입니다.
function handleSelect(item, index) {
setCurrentIndex(index)
setIsOpen(false)
onChange?.(item, index)
}
{items.map((item, index) => (
<li key={`${item}-${index}`}>
<button
className={cn(
styles.option,
index === currentIndex &&
styles.selected
)}
onClick={() =>
handleSelect(item, index)
}
>
<span>{item}</span>
{index === currentIndex && (
<span className={styles.check} />
)}
</button>
</li>
))}
배열을 반복하여 항목을 만들고, 선택된 index에는 selected 클래스와 체크 표시를 추가합니다.
import { useState } from 'react'
import DropdownList from './DropdownList'
export default function App() {
const [departmentIndex, setDepartmentIndex] =
useState(null)
const departments = [
'개발팀',
'디자인팀',
'기획팀'
]
function handleDepartmentChange(item, index) {
setDepartmentIndex(index)
console.log('선택한 부서:', item)
console.log('선택 index:', index)
}
return (
<DropdownList
label="부서 선택"
items={departments}
selectedIndex={departmentIndex}
onChange={handleDepartmentChange}
id="departmentDropdown"
title="부서를 선택하세요"
data-code="DEP001"
/>
)
}
| 문법 | 이름 | 역할 | 현재 코드 예 |
|---|---|---|---|
| ?. | Optional Chaining | 앞의 값이 null/undefined가 아닐 때만 접근 또는 실행 | onChange?.(item, index)dropdownRef.current?.contains(...) |
| ?? | Nullish Coalescing | 왼쪽 값이 null/undefined이면 오른쪽 기본값 사용 | selectedItem ?? label |
| 문법/기능 | 기억할 내용 |
|---|---|
| ...props | 따로 꺼내지 않은 나머지 props를 모아서 DOM 등에 전달 |
| selectedItem ?? label | selectedItem이 null/undefined이면 label 사용 |
| onChange?.(...) | onChange 함수가 존재할 때만 실행 |
| ref.current?.contains(...) | ref.current가 존재할 때만 contains 실행 |
| !contains(...) | 내부가 아니라면 true가 되어 외부 클릭 처리 |
| useState | 열림 여부, 선택 index, 방향 저장 |
| useEffect | 값 동기화, document 이벤트 관리 |
| useRef | 실제 DOM 참조 |
| useLayoutEffect | DOM 위치를 측정해 펼침 방향 결정 |
| map() | items 배열을 반복하여 UI 생성 |