feat(10-11): teleport FolderRow three-dot menu to body with getBoundingClientRect positioning

- Add menuTriggerEl ref, menuStyle, and updateMenuPosition() using getBoundingClientRect
- Wrap three-dot menu in Teleport to body with data-test="folder-row-menu"
- toggleMenu() captures position before opening
- Add window scroll/resize listeners with cleanup in onUnmounted
- Update outside-click handler to also exclude teleported menu element
- All 4 UX-13 dropdown tests now pass; full test suite 208/208 green
This commit is contained in:
curo1305
2026-06-16 08:25:30 +02:00
parent e0606b49f1
commit 892abca8cf
+50 -6
View File
@@ -36,6 +36,7 @@
<!-- Three-dot menu -->
<div class="relative shrink-0" @click.stop>
<button
ref="menuTriggerEl"
@click="toggleMenu"
aria-label="Folder actions"
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors min-h-[36px] min-w-[36px] flex items-center justify-center"
@@ -44,11 +45,17 @@
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</div>
</div>
<!-- Dropdown menu -->
<!-- Teleported three-dot menu avoids overflow:hidden clipping -->
<Teleport to="body">
<div
v-if="menuOpen"
class="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-md py-1 min-w-[140px] z-10"
:style="menuStyle"
data-test="folder-row-menu"
class="fixed z-[9999] bg-white border border-gray-200 rounded-lg shadow-md py-1 min-w-[140px]"
@click.stop
>
<button
@click="startRename"
@@ -63,8 +70,7 @@
Delete folder
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
@@ -90,6 +96,8 @@ const props = defineProps({
})
const menuOpen = ref(false)
const menuTriggerEl = ref(null)
const menuStyle = ref({})
const renaming = ref(false)
const renameValue = ref('')
const renameError = ref('')
@@ -100,8 +108,33 @@ function handleNavigate() {
if (props.onNavigate) props.onNavigate(props.folder.id)
}
function updateMenuPosition() {
if (!menuTriggerEl.value) return
const rect = menuTriggerEl.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 100 // approximate height of 2-item menu
if (spaceBelow >= dropH || spaceBelow > 60) {
menuStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${Math.max(0, rect.right - 160)}px`,
minWidth: '140px',
}
} else {
menuStyle.value = {
bottom: `${window.innerHeight - rect.top + 4}px`,
left: `${Math.max(0, rect.right - 160)}px`,
minWidth: '140px',
}
}
}
function toggleMenu() {
menuOpen.value = !menuOpen.value
if (menuOpen.value) {
menuOpen.value = false
} else {
updateMenuPosition()
menuOpen.value = true
}
}
function closeMenu() {
@@ -146,16 +179,27 @@ function handleDelete() {
// Close menu on outside click
function handleOutsideClick(e) {
if (!e.target.closest('.relative')) {
if (
!e.target.closest('[data-test="folder-row-menu"]') &&
!e.target.closest('.relative')
) {
closeMenu()
}
}
function onScroll() {
if (menuOpen.value) updateMenuPosition()
}
onMounted(() => {
document.addEventListener('click', handleOutsideClick)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onUnmounted(() => {
document.removeEventListener('click', handleOutsideClick)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
</script>