fix: 멤버 초대 자동완성 드롭다운이 모달 밖에서 잘리는 문제 수정

드롭다운을 body 로 Teleport + fixed 위치로 띄워(DropdownSelect 와 동일 패턴)
모달의 overflow 에 잘리지 않게 한다. 입력창 기준 좌표를 계산하고 뷰포트
하단을 넘지 않도록 최대높이를 제한하며 스크롤/리사이즈 시 위치를 갱신한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:31:34 +09:00
parent 9a79e169c8
commit a65b5aac05
+80 -44
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 프로젝트 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달 (백엔드 연동)
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
@@ -140,6 +140,29 @@ const usersLoading = ref(false)
const showUserDropdown = ref(false)
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
// 자동완성 드롭다운은 body 로 Teleport + fixed 위치로 띄운다 — 모달의 overflow 에 잘리지 않도록.
const emailInputRef = ref<HTMLInputElement | null>(null)
const ddStyle = ref<Record<string, string>>({})
// 입력창 기준으로 드롭다운 좌표/최대높이 계산(뷰포트 하단을 넘지 않게 제한)
function positionDropdown() {
const el = emailInputRef.value
if (!el) return
const r = el.getBoundingClientRect()
const gap = 6
const margin = 12 // 뷰포트 하단 여백
const below = window.innerHeight - r.bottom - gap - margin
ddStyle.value = {
top: `${r.bottom + gap}px`,
left: `${r.left}px`,
width: `${r.width}px`,
maxHeight: `${Math.min(256, Math.max(120, below))}px`,
}
}
// 스크롤/리사이즈 시 열려 있으면 위치 재계산
function onDropdownReposition() {
if (showUserDropdown.value) positionDropdown()
}
// 현재 프로젝트에 이미 속한 사용자 id (자동완성에서 제외)
const memberIds = computed(() => new Set(members.value.map((m) => m.user.id)))
const userOptions = computed(() =>
@@ -163,10 +186,12 @@ function scheduleUserSearch() {
}
function onEmailFocus() {
showUserDropdown.value = true
void nextTick(positionDropdown)
void runUserSearch() // 포커스 시 전체(또는 현재 입력 기준) 목록 표시
}
function onEmailInput() {
showUserDropdown.value = true
void nextTick(positionDropdown)
scheduleUserSearch()
}
function onEmailBlur() {
@@ -216,8 +241,17 @@ function onKeydown(e: KeyboardEvent) {
openRoleFor.value = null
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
onMounted(() => {
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', onDropdownReposition)
// capture=true — 모달 본문 등 내부 스크롤도 포착해 위치를 따라가게 한다
window.addEventListener('scroll', onDropdownReposition, true)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', onDropdownReposition)
window.removeEventListener('scroll', onDropdownReposition, true)
})
</script>
<template>
@@ -472,6 +506,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<div class="form-field uc-field">
<label class="form-label">초대할 사람 (이메일)</label>
<input
ref="emailInputRef"
v-model="inviteEmail"
class="form-input"
type="email"
@@ -481,40 +516,43 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<!-- 사용자 자동완성 드롭다운 body Teleport(모달 overflow 밖에서 잘리지 않도록) -->
<Teleport to="body">
<div
v-if="usersLoading"
class="user-dd-msg"
v-if="showUserDropdown"
class="user-dd"
:style="ddStyle"
>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
v-if="usersLoading"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
불러오는
</div>
</template>
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</Teleport>
</div>
<div class="form-field">
@@ -833,21 +871,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
.invite-body .uc-field {
position: relative;
}
.invite-body .user-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
/* 자동완성 드롭다운 — body 로 Teleport 되므로 .invite-body 후손 선택자를 쓰지 않는다.
위치/최대높이(top/left/width/maxHeight)는 컴포넌트가 인라인 style 로 지정(fixed). */
.user-dd {
position: fixed;
z-index: 1000; /* 모달 backdrop(100) 위 */
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 16rem;
overflow-y: auto;
}
.invite-body .user-dd-item {
.user-dd-item {
display: flex;
align-items: center;
gap: 0.625rem;
@@ -860,22 +896,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
font-family: inherit;
text-align: left;
}
.invite-body .user-dd-item:hover {
.user-dd-item:hover {
background: #f5f6f8;
}
.invite-body .user-av {
.user-dd .user-av {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
.invite-body .user-nm {
.user-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-body .user-em {
.user-em {
font-size: 0.75rem;
color: var(--text-3);
margin-left: auto;
@@ -885,7 +921,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
max-width: 14rem;
padding-left: 0.5rem;
}
.invite-body .user-dd-msg {
.user-dd-msg {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);