refactor: 바깥클릭/ESC 닫기 공용 훅 useClickOutside 도입 (Phase 0)

11곳에 복붙되던 document 리스너 등록/해제·바깥클릭·ESC·스크롤 닫기 로직을
useClickOutside 훅으로 통합. 재사용 드롭다운 5종에 우선 적용.

- composables/useClickOutside.ts 신설 (event/esc/closeOnScroll 옵션)
- 적용: DropdownSelect, StatusSelect, ScheduleTypeFilter,
  ScheduleDetailPanel(참석자 스택), ScheduleEventModal(참석자 멀티셀렉트)
- 모달 ESC·페이지 레벨 오버레이 조정 로직은 후속 단계(BaseModal)에서 처리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:31:45 +09:00
parent d5b54c1afb
commit 92ed6c332f
6 changed files with 79 additions and 75 deletions
+7 -26
View File
@@ -1,7 +1,8 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
interface Option {
value: T
@@ -61,31 +62,11 @@ function select(value: T): void {
open.value = false
}
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (root.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent): void {
if (open.value && e.key === 'Escape') open.value = false
}
// 스크롤/리사이즈 시 위치가 어긋나므로 닫는다(간단·안전)
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
// 바깥 클릭(트리거/메뉴 외부)·Esc·스크롤(위치 어긋남) 시 닫기 — 공용 훅
useClickOutside(open, () => (open.value = false), [root, menuRef], {
event: 'click',
esc: true,
closeOnScroll: true,
})
</script>
+7 -19
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
@@ -36,24 +37,11 @@ function pick(v: ChecklistWorkStatus): void {
emit('update:modelValue', v)
open.value = false
}
function onDoc(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (triggerRef.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDoc)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDoc)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
// 바깥 클릭·스크롤(위치 어긋남) 시 닫기 — 공용 훅(Teleport 메뉴라 트리거+메뉴 모두 내부로)
useClickOutside(open, () => (open.value = false), [triggerRef, menuRef], {
event: 'click',
esc: false,
closeOnScroll: true,
})
</script>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref, watch } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
@@ -36,15 +37,7 @@ const dur = computed(() =>
// 참석자 스택 토글
const attOpen = ref(false)
const attRef = ref<HTMLElement | null>(null)
function onDoc(e: MouseEvent): void {
if (!attOpen.value) return
if (attRef.value && !attRef.value.contains(e.target as Node)) attOpen.value = false
}
watch(attOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
useClickOutside(attOpen, () => (attOpen.value = false), [attRef], { esc: false })
const MAX = 6
const attendees = computed(() => ev.value?.attendees ?? [])
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 일정 생성/수정 모달
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import type {
ApiScheduleCategory,
@@ -90,22 +91,15 @@ function togglePerson(id: string): void {
? attendeeIds.value.filter((x) => x !== id)
: [...attendeeIds.value, id]
}
function onDoc(ev: MouseEvent): void {
if (msRef.value && !msRef.value.contains(ev.target as Node)) msOpen.value = false
}
watch(msOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
// 참석자 멀티셀렉트 바깥클릭 닫기(ESC 는 모달 닫기에서 처리하므로 제외)
useClickOutside(msOpen, () => (msOpen.value = false), [msRef], { esc: false })
// 모달 ESC = 닫기
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
function submit(): void {
if (!valid.value) return
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 유형 필터 + 카테고리 관리 드롭다운
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ApiScheduleCategory } from '@/types/schedule'
const props = defineProps<{
@@ -18,14 +19,7 @@ const emit = defineEmits<{
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
function onDoc(e: MouseEvent): void {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) open.value = false
}
watch(open, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
@@ -0,0 +1,54 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
// 바깥 클릭 / ESC / 스크롤 시 닫기 공용 훅.
// 드롭다운·팝오버·메뉴 등에서 반복되던 document 리스너 등록/해제 로직을 한 곳으로 모은다.
//
// - isOpen: 열림 상태 ref (닫혀 있으면 아무 동작 안 함)
// - close: 닫기 콜백
// - containers: "내부"로 간주할 엘리먼트 ref 목록. 클릭이 이 안이면 무시한다
// (Teleport 로 body 에 띄운 메뉴는 트리거와 메뉴 ref 를 모두 넣는다)
// - opts.event: 바깥 클릭 감지 이벤트 (기본 'mousedown')
// - opts.esc: Escape 로 닫을지 (기본 true)
// - opts.closeOnScroll: 스크롤/리사이즈 시 닫을지 — fixed 로 띄운 메뉴 위치 어긋남 방지 (기본 false)
export function useClickOutside(
isOpen: Ref<boolean>,
close: () => void,
containers: Ref<HTMLElement | null>[],
opts: {
event?: 'mousedown' | 'click'
esc?: boolean
closeOnScroll?: boolean
} = {},
): void {
const { event = 'mousedown', esc = true, closeOnScroll = false } = opts
function onPointer(e: Event): void {
if (!isOpen.value) return
const t = e.target as Node
if (containers.some((r) => r.value?.contains(t))) return
close()
}
function onKey(e: KeyboardEvent): void {
if (isOpen.value && e.key === 'Escape') close()
}
function onScroll(): void {
if (isOpen.value) close()
}
onMounted(() => {
document.addEventListener(event, onPointer, true)
if (esc) document.addEventListener('keydown', onKey)
if (closeOnScroll) {
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
}
})
onBeforeUnmount(() => {
document.removeEventListener(event, onPointer, true)
if (esc) document.removeEventListener('keydown', onKey)
if (closeOnScroll) {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
})
}