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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user