fix(ui): unify dialogs and complete theme component coverage

This commit is contained in:
2026-09-05 23:56:02 +08:00
parent 6107b7ff1b
commit af2556d29e
22 changed files with 254 additions and 35 deletions
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import AppDialog from './AppDialog.vue'
const mounted: VueWrapper[] = []
afterEach(() => { mounted.splice(0).reverse().forEach(w => w.unmount()); document.body.innerHTML = ''; document.body.style.cssText = ''; document.documentElement.style.cssText = '' })
it('locks all scroll ancestors and restores focus and inline styles', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
const host = document.createElement('div'); host.style.setProperty('overflow', 'auto', 'important'); document.body.append(host)
const w = mount(AppDialog, { props:{label:'测试'}, slots:{default:'<section class="modal"><input autofocus /></section>'}, attachTo:host }); mounted.push(w)
expect(w.get('dialog').element.open).toBe(true)
expect(host.style.overflow).toBe('hidden')
expect(document.body.style.overflow).toBe('hidden')
await w.get('dialog').trigger('keydown', {key:'Escape'})
expect(w.emitted('close')).toHaveLength(1)
w.unmount(); mounted.pop()
expect(host.style.overflow).toBe('auto')
expect(host.style.getPropertyPriority('overflow')).toBe('important')
expect(document.body.style.overflow).toBe('')
expect(document.activeElement).toBe(opener)
})
it('retains scroll locks until the last nested dialog closes', () => {
const first = mount(AppDialog, {props:{label:'父弹窗'}, attachTo:document.body}); mounted.push(first)
const second = mount(AppDialog, {props:{label:'子弹窗'}, attachTo:document.body}); mounted.push(second)
first.unmount(); mounted.splice(0,1)
expect(document.body.style.overflow).toBe('hidden')
second.unmount(); mounted.pop()
expect(document.body.style.overflow).toBe('')
})
it('does not dismiss permission or busy dialogs through Escape or backdrop', async () => {
const w = mount(AppDialog, {props:{label:'权限确认',dismissible:false},attachTo:document.body}); mounted.push(w)
await w.get('dialog').trigger('keydown',{key:'Escape'})
await w.get('dialog').trigger('cancel')
await w.get('dialog').trigger('click')
expect(w.emitted('close')).toBeUndefined()
})
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { lockDialogScroll } from './dialogScroll'
const props = withDefaults(defineProps<{ label: string; dismissible?: boolean }>(), { dismissible: true })
const emit = defineEmits<{ close: [] }>()
const dialog = ref<HTMLDialogElement>()
let restoreScroll: (() => void) | undefined
let previousFocus: HTMLElement | null = null
function dismiss() { if (props.dismissible) emit('close') }
function keydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() }
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement | null
if (!dialog.value) return
restoreScroll = lockDialogScroll(dialog.value)
dialog.value.showModal()
const first = dialog.value.querySelector<HTMLElement>('[autofocus], input:not(:disabled):not([type="hidden"]), textarea:not(:disabled), select:not(:disabled), button:not(:disabled)')
;(first ?? dialog.value).focus()
})
onBeforeUnmount(() => {
dialog.value?.close()
restoreScroll?.()
if (previousFocus?.isConnected) previousFocus.focus()
})
</script>
<template>
<dialog ref="dialog" class="app-dialog" :aria-label="label" tabindex="-1" @cancel.prevent="dismiss" @keydown="keydown" @click.self="dismiss">
<slot />
</dialog>
</template>
<style scoped>
.app-dialog { position: fixed; inset: 0; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; border: 0; padding: clamp(12px, 3vw, 24px); background: transparent; color: var(--color-text-primary); overflow: hidden; overscroll-behavior: contain; }
.app-dialog[open] { display: grid; place-items: center; }
.app-dialog::backdrop { background: var(--color-background-overlay); }
.app-dialog :deep(> .modal), .app-dialog :deep(> .modal-card) { min-width: 0; max-width: 100%; max-height: 100%; overflow: auto; overscroll-behavior: contain; }
</style>
@@ -0,0 +1,26 @@
// Reference counts keep the underlying page locked when dialogs are nested.
const locks = new WeakMap<HTMLElement, { count: number; value: string; priority: string }>()
export function lockDialogScroll(dialog: HTMLElement): () => void {
const elements: HTMLElement[] = []
for (let element = dialog.parentElement; element; element = element.parentElement) {
const lock = locks.get(element)
if (lock) lock.count++
else {
locks.set(element, { count: 1, value: element.style.getPropertyValue('overflow'), priority: element.style.getPropertyPriority('overflow') })
element.style.setProperty('overflow', 'hidden', 'important')
}
elements.push(element)
}
let released = false
return () => {
if (released) return
released = true
for (const element of elements) {
const lock = locks.get(element)!
if (--lock.count) continue
if (lock.value) element.style.setProperty('overflow', lock.value, lock.priority)
else element.style.removeProperty('overflow')
locks.delete(element)
}
}
}