fix(ui): unify dialogs and complete theme component coverage
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user