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