fix: 드롭다운 메뉴를 body Teleport(fixed)로 띄워 잘림 해결 + 보고 모달 높이 확대

DropdownSelect 메뉴를 absolute → body Teleport + fixed 위치 계산으로 변경.
스크롤/overflow 컨테이너(보고 모달 등) 안에서도 메뉴가 잘리지 않음(스크롤·리사이즈 시 닫힘).
보고 열람/작성 모달 max-height 를 calc(100vh - 3rem) 로 확대.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:26:57 +09:00
parent b00785d056
commit 69708b2b8b
2 changed files with 72 additions and 45 deletions
+71 -44
View File
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 사용처: 프로젝트 목록·업무 목록 정렬, 내 업무 프로젝트/정렬, 활동 멤버 필터.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
interface Option {
value: T
@@ -25,36 +25,67 @@ 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 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
}
// 바깥 클릭 / Esc 로 닫기
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (open.value && root.value && !root.value.contains(e.target as Node)) {
open.value = false
}
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)
})
</script>
@@ -65,6 +96,7 @@ onBeforeUnmount(() => {
:class="{ push: pushRight, block }"
>
<button
ref="triggerRef"
type="button"
class="dd-trigger"
:class="{ open }"
@@ -90,34 +122,37 @@ onBeforeUnmount(() => {
><path d="m6 9 6 6 6-6" /></svg>
</button>
<ul
v-if="open"
class="dd-menu"
:class="align === 'right' ? 'right' : 'left'"
role="listbox"
>
<li
v-for="opt in options"
:key="opt.value"
class="dd-opt"
:class="{ active: opt.value === modelValue }"
role="option"
:aria-selected="opt.value === modelValue"
@click="select(opt.value)"
<Teleport to="body">
<ul
v-if="open"
ref="menuRef"
class="dd-menu"
:style="menuStyle"
role="listbox"
>
<span class="dd-opt-label">{{ opt.label }}</span>
<svg
v-if="opt.value === modelValue"
class="dd-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</li>
</ul>
<li
v-for="opt in options"
:key="opt.value"
class="dd-opt"
:class="{ active: opt.value === modelValue }"
role="option"
:aria-selected="opt.value === modelValue"
@click="select(opt.value)"
>
<span class="dd-opt-label">{{ opt.label }}</span>
<svg
v-if="opt.value === modelValue"
class="dd-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</li>
</ul>
</Teleport>
</div>
</template>
@@ -191,12 +226,10 @@ onBeforeUnmount(() => {
transform: rotate(180deg);
}
/* 메뉴 */
/* 메뉴 — body 로 Teleport, fixed 위치(좌표는 인라인 style) */
.dd-menu {
position: absolute;
top: calc(100% + 0.375rem);
z-index: 60;
min-width: 100%;
position: fixed;
z-index: 1000;
margin: 0;
padding: 0.25rem;
list-style: none;
@@ -205,12 +238,6 @@ onBeforeUnmount(() => {
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.14);
}
.dd-menu.left {
left: 0;
}
.dd-menu.right {
right: 0;
}
.dd-opt {
display: flex;
align-items: center;
+1 -1
View File
@@ -3576,7 +3576,7 @@ a.file-info {
/* 보고 열람 모달 */
.cp-modal {
width: min(34rem, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
max-height: calc(100vh - 3rem);
display: flex;
flex-direction: column;
background: #fff;