refactor: Teleport 드롭다운 위치/토글 로직 useDropdownMenu 로 통합 (Phase 2)

DropdownSelect·StatusSelect 에 중복되던 fixed 위치 계산·open 토글·
바깥클릭/ESC/스크롤 닫기 로직을 useDropdownMenu 훅으로 추출.

- composables/useDropdownMenu.ts 신설 (align/matchWidth/gap/esc 옵션)
- DropdownSelect, StatusSelect 가 훅을 사용하도록 정리
- 절대배치 메뉴(일정 멀티셀렉트/유형필터/참석자 스택)는 Phase 0 useClickOutside 로 이미 공유

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:44:48 +09:00
parent 0c4f0817e6
commit 7425d57134
3 changed files with 74 additions and 64 deletions
+8 -38
View File
@@ -1,8 +1,8 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
interface Option {
value: T
@@ -24,55 +24,25 @@ const props = withDefaults(
)
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: props.align,
matchWidth: true,
})
// 현재 선택된 옵션 라벨(트리거에 표시)
const selectedLabel = computed(
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
)
// 트리거 위치 기준으로 메뉴 좌표 계산(뷰포트 fixed)
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = {
top: `${r.bottom + 6}px`,
minWidth: `${r.width}px`,
}
if (props.align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function select(value: T): void {
emit('update:modelValue', value)
open.value = false
close()
}
// 바깥 클릭(트리거/메뉴 외부)·Esc·스크롤(위치 어긋남) 시 닫기 — 공용 훅
useClickOutside(open, () => (open.value = false), [root, menuRef], {
event: 'click',
esc: true,
closeOnScroll: true,
})
</script>
<template>
<div
ref="root"
class="dd"
:class="{ push: pushRight, block }"
>
+8 -26
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
@@ -15,34 +15,16 @@ const META: Record<ChecklistWorkStatus, { label: string; color: string; weak: st
const ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
const cur = computed(() => META[props.modelValue])
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅(우측 정렬, 너비는 CSS 고정)
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: 'right',
gap: 5,
})
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
menuStyle.value = {
top: `${r.bottom + 5}px`,
right: `${Math.max(0, window.innerWidth - r.right)}px`,
}
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function pick(v: ChecklistWorkStatus): void {
emit('update:modelValue', v)
open.value = false
close()
}
// 바깥 클릭·스크롤(위치 어긋남) 시 닫기 — 공용 훅(Teleport 메뉴라 트리거+메뉴 모두 내부로)
useClickOutside(open, () => (open.value = false), [triggerRef, menuRef], {
event: 'click',
esc: false,
closeOnScroll: true,
})
</script>
<template>
@@ -0,0 +1,58 @@
import { nextTick, ref } from 'vue'
import { useClickOutside } from './useClickOutside'
// Teleport(body) + fixed 위치로 띄우는 드롭다운 메뉴 공용 훅.
// 트리거 기준 좌표 계산 · open 토글 · 바깥클릭/ESC/스크롤 닫기를 한 곳으로 모은다.
// (DropdownSelect, StatusSelect 등 스크롤/overflow 컨테이너 안에서도 안 잘리는 메뉴에 사용)
//
// - align: 트리거 좌/우 끝 기준 정렬 (기본 'left')
// - matchWidth: 메뉴 최소 너비를 트리거 너비에 맞출지 (기본 false)
// - gap: 트리거 하단과 메뉴 사이 간격(px) (기본 6)
// - esc: ESC 로 닫을지 (기본 true)
export function useDropdownMenu(
opts: {
align?: 'left' | 'right'
matchWidth?: boolean
gap?: number
esc?: boolean
} = {},
) {
const { align = 'left', matchWidth = false, gap = 6, esc = true } = opts
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
function position(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = { top: `${r.bottom + gap}px` }
if (matchWidth) style.minWidth = `${r.width}px`
if (align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(position)
}
function close(): void {
open.value = false
}
// 바깥클릭(트리거/메뉴 외부)·ESC·스크롤(위치 어긋남) 닫기
useClickOutside(open, close, [triggerRef, menuRef], {
event: 'click',
esc,
closeOnScroll: true,
})
return { open, triggerRef, menuRef, menuStyle, toggle, close, position }
}