Merge pull request #7319 from ouchihao/codex/bug-dialog-scroll-lock

fix(dialog): keep body locked while another dialog is open
This commit is contained in:
Wesley Liddick
2026-09-22 21:55:30 +08:00
committed by GitHub
2 changed files with 61 additions and 3 deletions
+10 -3
View File
@@ -44,6 +44,7 @@
<script lang="ts">
let dialogIdCounter = 0
const openDialogs = new Set<string>()
</script>
<script setup lang="ts">
@@ -115,6 +116,12 @@ const handleEscape = (event: KeyboardEvent) => {
}
}
const updateScrollLock = (isOpen: boolean) => {
if (isOpen) openDialogs.add(dialogId)
else openDialogs.delete(dialogId)
document.body.classList.toggle('modal-open', openDialogs.size > 0)
}
// Prevent body scroll when modal is open and manage focus
watch(
() => props.show,
@@ -123,7 +130,7 @@ watch(
// 保存当前焦点元素
previousActiveElement = document.activeElement as HTMLElement
// 使用CSS类而不是直接操作style,更易于管理多个对话框
document.body.classList.add('modal-open')
updateScrollLock(true)
// 等待DOM更新后设置焦点到对话框
await nextTick()
@@ -137,7 +144,7 @@ watch(
firstFocusable?.focus()
}
} else {
document.body.classList.remove('modal-open')
updateScrollLock(false)
// 恢复之前的焦点
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
previousActiveElement.focus()
@@ -155,6 +162,6 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
// 确保组件卸载时移除滚动锁定
document.body.classList.remove('modal-open')
updateScrollLock(false)
})
</script>
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import BaseDialog from '../BaseDialog.vue'
enableAutoUnmount(afterEach)
function dialog(show = true) {
return mount(BaseDialog, { props: { show, title: 'Details' }, global: { stubs: { Icon: true } } })
}
const locked = () => document.body.classList.contains('modal-open')
describe('dialog body scroll lock', () => {
it('keeps the body locked when a hidden sibling mounts or unmounts', () => {
dialog()
const hidden = dialog(false)
expect(locked()).toBe(true)
hidden.unmount()
expect(locked()).toBe(true)
})
it('keeps the lock until the last visible dialog closes', async () => {
const first = dialog()
const second = dialog()
await second.setProps({ show: false })
expect(locked()).toBe(true)
await first.setProps({ show: false })
expect(locked()).toBe(false)
})
it('keeps the lock until the last visible dialog unmounts', () => {
const first = dialog()
const second = dialog()
second.unmount()
expect(locked()).toBe(true)
first.unmount()
expect(locked()).toBe(false)
})
it('releases the lock after repeated open and close cycles', async () => {
const wrapper = dialog(false)
expect(locked()).toBe(false)
await wrapper.setProps({ show: true })
expect(locked()).toBe(true)
await wrapper.setProps({ show: false })
expect(locked()).toBe(false)
await wrapper.setProps({ show: true })
wrapper.unmount()
expect(locked()).toBe(false)
})
})