277 lines
15 KiB
TypeScript
277 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { createApp, h, nextTick, ref } from 'vue'
|
|
import AppSheet from './AppSheet.vue'
|
|
import AppDialog from './AppDialog.vue'
|
|
|
|
const cleanups: Array<() => void> = []
|
|
afterEach(() => { cleanups.splice(0).forEach((fn) => fn()); document.body.innerHTML = '' })
|
|
|
|
async function mountSheet(options: { busy?: boolean; initialFocus?: string; modal?: boolean } = {}) {
|
|
const host = document.createElement('main')
|
|
const opener = document.createElement('button')
|
|
opener.textContent = 'open'
|
|
document.body.append(host, opener)
|
|
opener.focus()
|
|
const open = ref(true)
|
|
const close = vi.fn(() => { open.value = false })
|
|
const app = createApp({
|
|
setup: () => () => h(AppSheet, {
|
|
open: open.value,
|
|
titleId: 'sheet-title',
|
|
descriptionId: 'sheet-description',
|
|
busy: options.busy,
|
|
modal: options.modal,
|
|
initialFocus: options.initialFocus,
|
|
onClose: close,
|
|
}, {
|
|
default: () => [h('h2', { id: 'sheet-title' }, '标题'), h('p', { id: 'sheet-description' }, '说明'), h('button', { id: 'first' }, 'first'), h('button', { id: 'last' }, 'last')],
|
|
}),
|
|
})
|
|
app.mount(host)
|
|
cleanups.push(() => app.unmount())
|
|
for (const element of document.querySelectorAll<HTMLElement>('#first,#last')) {
|
|
Object.defineProperty(element, 'getClientRects', { configurable: true, value: () => [{ width: 20, height: 20 }] })
|
|
}
|
|
await nextTick(); await nextTick()
|
|
return { host, opener, open, close }
|
|
}
|
|
|
|
describe('AppSheet', () => {
|
|
it('teleports an accessible modal and makes application background inert', async () => {
|
|
const { host } = await mountSheet({ initialFocus: '#last' })
|
|
const dialog = document.querySelector<HTMLElement>('#overlay-root [role="dialog"]')!
|
|
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
expect(dialog.getAttribute('aria-labelledby')).toBe('sheet-title')
|
|
expect(dialog.getAttribute('aria-describedby')).toBe('sheet-description')
|
|
expect(document.activeElement?.id).toBe('last')
|
|
expect(host.hasAttribute('inert')).toBe(true)
|
|
expect(host.getAttribute('aria-hidden')).toBe('true')
|
|
})
|
|
|
|
it('traps Tab and restores focus after closing', async () => {
|
|
const { opener } = await mountSheet({ initialFocus: '#first' })
|
|
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
|
const first = document.querySelector<HTMLButtonElement>('#first')!
|
|
const last = document.querySelector<HTMLButtonElement>('#last')!
|
|
last.focus()
|
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
|
|
expect(document.activeElement).toBe(first)
|
|
first.focus()
|
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
|
|
expect(document.activeElement).toBe(last)
|
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
|
await nextTick(); await nextTick()
|
|
expect(document.activeElement).toBe(opener)
|
|
})
|
|
|
|
it('blocks scrim and Escape closing while busy', async () => {
|
|
const { close } = await mountSheet({ busy: true })
|
|
document.querySelector<HTMLElement>('.app-overlay')!.click()
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
|
expect(close).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders a real form when submit listeners are provided', async () => {
|
|
const host = document.createElement('div'); document.body.append(host)
|
|
const submitted = vi.fn()
|
|
const app = createApp({ setup: () => () => h(AppSheet, { open:true, titleId:'form-title', onSubmit:(event: Event) => { event.preventDefault(); submitted() } }, {
|
|
default:() => [h('h2',{id:'form-title'},'form'), h('button',{type:'submit'},'save')],
|
|
}) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
|
expect(dialog.tagName).toBe('FORM')
|
|
dialog.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
|
expect(submitted).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps desktop non-modal details inline without inerting the app', async () => {
|
|
const host = document.createElement('main'); document.body.append(host)
|
|
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:false, titleId:'detail-title' }, {
|
|
default:() => [h('h2',{id:'detail-title'},'detail'), h('button','close')],
|
|
}) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
const dialog = host.querySelector<HTMLElement>('[role="dialog"]')!
|
|
expect(dialog).not.toBeNull()
|
|
expect(dialog.getAttribute('aria-modal')).toBeNull()
|
|
expect(document.querySelector('#overlay-root [role="dialog"]')).toBeNull()
|
|
expect(host.hasAttribute('inert')).toBe(false)
|
|
})
|
|
|
|
it('activates and deactivates the overlay when modal changes while open', async () => {
|
|
const host = document.createElement('main'); document.body.append(host)
|
|
const modal = ref(false)
|
|
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:modal.value, titleId:'dynamic-title' }, {
|
|
default:() => [h('h2',{id:'dynamic-title'},'detail'), h('button',{id:'dynamic-close'},'close')],
|
|
}) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
|
expect(host.hasAttribute('inert')).toBe(false)
|
|
|
|
modal.value = true; await nextTick(); await nextTick()
|
|
expect(document.querySelector('#overlay-root [role="dialog"]')).not.toBeNull()
|
|
expect(host.hasAttribute('inert')).toBe(true)
|
|
|
|
modal.value = false; await nextTick(); await nextTick()
|
|
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
|
expect(host.hasAttribute('inert')).toBe(false)
|
|
})
|
|
|
|
it('keeps background inert until the last stacked modal closes', async () => {
|
|
const host = document.createElement('main'); document.body.append(host)
|
|
const first = ref(true); const second = ref(true)
|
|
const app = createApp({ setup: () => () => h('div', [
|
|
h(AppSheet, { open:first.value, titleId:'stack-one', onClose:() => { first.value=false } }, { default:() => h('h2',{id:'stack-one'},'one') }),
|
|
h(AppSheet, { open:second.value, titleId:'stack-two', onClose:() => { second.value=false } }, { default:() => h('h2',{id:'stack-two'},'two') }),
|
|
]) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
expect(host.hasAttribute('inert')).toBe(true)
|
|
second.value=false; await nextTick(); await nextTick()
|
|
expect(host.hasAttribute('inert')).toBe(true)
|
|
first.value=false; await nextTick(); await nextTick()
|
|
expect(host.hasAttribute('inert')).toBe(false)
|
|
expect(host.getAttribute('aria-hidden')).toBeNull()
|
|
})
|
|
|
|
it('places a newly opened stacked modal above the existing modal', async () => {
|
|
const host = document.createElement('main'); document.body.append(host)
|
|
const second = ref(false)
|
|
const app = createApp({ setup: () => () => h('div', [
|
|
h(AppSheet, { open:true, titleId:'layer-lower' }, { default:() => h('h2',{id:'layer-lower'},'lower') }),
|
|
h(AppSheet, { open:second.value, titleId:'layer-upper' }, { default:() => h('h2',{id:'layer-upper'},'upper') }),
|
|
]) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
second.value = true; await nextTick(); await nextTick()
|
|
const lower = document.querySelector<HTMLElement>('[aria-labelledby="layer-lower"]')!.parentElement!
|
|
const upper = document.querySelector<HTMLElement>('[aria-labelledby="layer-upper"]')!.parentElement!
|
|
expect(Number(upper.style.zIndex)).toBeGreaterThan(Number(lower.style.zIndex))
|
|
})
|
|
|
|
it('focuses prompt input and confirm dialog cancel action', async () => {
|
|
const host = document.createElement('div'); document.body.append(host)
|
|
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
|
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
|
void dialog.value!.show({ title:'prompt', label:'name' }); await nextTick(); await nextTick()
|
|
expect(document.activeElement?.tagName).toBe('INPUT')
|
|
document.querySelector<HTMLButtonElement>('.app-dialog .secondary')!.click(); await nextTick()
|
|
void dialog.value!.show({ title:'confirm' }); await nextTick(); await nextTick()
|
|
expect(document.activeElement).toBe(document.querySelector('.app-dialog .secondary'))
|
|
})
|
|
|
|
it('settles replaced and unmounted dialog promises safely', async () => {
|
|
const host = document.createElement('div'); document.body.append(host)
|
|
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
|
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
|
const first = dialog.value!.show({ title:'one' })
|
|
const second = dialog.value!.show({ title:'two', label:'name' })
|
|
await expect(first).resolves.toBe(false)
|
|
app.unmount()
|
|
await expect(second).resolves.toBe(null)
|
|
})
|
|
|
|
it('returns focus to the lower overlay when same-tick upper overlay closes', async () => {
|
|
const host = document.createElement('main')
|
|
const opener = document.createElement('button')
|
|
opener.id = 'stack-opener'
|
|
document.body.append(host, opener)
|
|
opener.focus()
|
|
const lowerOpen = ref(true); const upperOpen = ref(true)
|
|
const visibleRef = (element: unknown) => {
|
|
if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] })
|
|
}
|
|
const app = createApp({ setup: () => () => h('div', [
|
|
h(AppSheet, { open:lowerOpen.value, titleId:'focus-lower' }, { default:() => [h('h2',{id:'focus-lower'},'lower'), h('button',{id:'focus-lower-button', ref:visibleRef},'lower button')] }),
|
|
h(AppSheet, { open:upperOpen.value, titleId:'focus-upper' }, { default:() => [h('h2',{id:'focus-upper'},'upper'), h('button',{id:'focus-upper-button', ref:visibleRef},'upper button')] }),
|
|
]) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
expect(document.activeElement?.id).toBe('focus-upper-button')
|
|
|
|
upperOpen.value=false; await nextTick(); await nextTick()
|
|
expect(document.activeElement?.id).toBe('focus-lower-button')
|
|
})
|
|
|
|
it('restores the background opener only after the last stacked overlay closes', async () => {
|
|
const host = document.createElement('main')
|
|
const opener = document.createElement('button')
|
|
opener.id = 'last-stack-opener'
|
|
document.body.append(host, opener)
|
|
opener.focus()
|
|
const lowerOpen = ref(true); const upperOpen = ref(true)
|
|
const app = createApp({ setup: () => () => h('div', [
|
|
h(AppSheet, { open:lowerOpen.value, titleId:'last-lower' }, { default:() => [h('h2',{id:'last-lower'},'lower'), h('button',{id:'last-lower-button'},'lower button')] }),
|
|
h(AppSheet, { open:upperOpen.value, titleId:'last-upper' }, { default:() => h('h2',{id:'last-upper'},'upper') }),
|
|
]) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
|
|
upperOpen.value=false; await nextTick(); await nextTick()
|
|
expect(document.activeElement).not.toBe(opener)
|
|
lowerOpen.value=false; await nextTick(); await nextTick()
|
|
expect(document.activeElement).toBe(opener)
|
|
})
|
|
|
|
it('keeps focus in the upper overlay when a non-top lower overlay closes', async () => {
|
|
const host = document.createElement('main')
|
|
const opener = document.createElement('button')
|
|
opener.id = 'lower-opener'
|
|
document.body.append(host, opener)
|
|
opener.focus()
|
|
const first = ref(true); const second = ref(false)
|
|
const app = createApp({ setup: () => () => h('div', [
|
|
h(AppSheet, { open:first.value, titleId:'lower' }, { default:() => [h('h2',{id:'lower'},'lower'), h('button',{id:'lower-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'lower button')] }),
|
|
h(AppSheet, { open:second.value, titleId:'upper' }, { default:() => [h('h2',{id:'upper'},'upper'), h('button',{id:'upper-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'upper button')] }),
|
|
]) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
second.value=true; await nextTick(); await nextTick()
|
|
const upper = document.querySelector<HTMLButtonElement>('#upper-button')!
|
|
expect(document.activeElement).toBe(upper)
|
|
first.value=false; await nextTick(); await nextTick()
|
|
expect(document.activeElement).toBe(upper)
|
|
})
|
|
|
|
it('skips focusables hidden by ancestors, aria-hidden, inert, styles, disabled state, or empty client rects', async () => {
|
|
const { host } = await mountSheet({ initialFocus: '#first' })
|
|
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
|
dialog.querySelector('#first')?.remove()
|
|
dialog.querySelector('#last')?.remove()
|
|
const hiddenParent = document.createElement('div')
|
|
hiddenParent.hidden = true
|
|
hiddenParent.innerHTML = '<button id="hidden-child">hidden</button>'
|
|
const ariaParent = document.createElement('div')
|
|
ariaParent.setAttribute('aria-hidden', 'true')
|
|
ariaParent.innerHTML = '<button id="aria-child">aria</button>'
|
|
const inertParent = document.createElement('div')
|
|
inertParent.setAttribute('inert', '')
|
|
inertParent.innerHTML = '<button id="inert-child">inert</button>'
|
|
const displayNone = document.createElement('button')
|
|
displayNone.id = 'display-none'; displayNone.style.display = 'none'
|
|
const invisible = document.createElement('button')
|
|
invisible.id = 'invisible'; invisible.style.visibility = 'hidden'
|
|
const disabled = document.createElement('button')
|
|
disabled.id = 'disabled'; disabled.disabled = true
|
|
const noRect = document.createElement('button')
|
|
noRect.id = 'no-rect'
|
|
const visible = document.createElement('button')
|
|
visible.id = 'visible'
|
|
Object.defineProperty(visible, 'getClientRects', { value: () => [{ width: 20, height: 20 }] })
|
|
dialog.append(hiddenParent, ariaParent, inertParent, displayNone, invisible, disabled, noRect, visible)
|
|
dialog.focus()
|
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key:'Tab', bubbles:true, cancelable:true }))
|
|
expect(document.activeElement).toBe(visible)
|
|
host.remove()
|
|
})
|
|
|
|
it('only closes the top overlay on Escape', async () => {
|
|
const host = document.createElement('div'); document.body.append(host)
|
|
const first = ref(true); const second = ref(true); const calls: string[] = []
|
|
const app = createApp({ setup: () => () => h('div', [
|
|
h(AppSheet, { open:first.value, titleId:'one', onClose:() => { calls.push('one'); first.value=false } }, { default:() => h('h2',{id:'one'},'one') }),
|
|
h(AppSheet, { open:second.value, titleId:'two', onClose:() => { calls.push('two'); second.value=false } }, { default:() => h('h2',{id:'two'},'two') }),
|
|
]) })
|
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', bubbles:true, cancelable:true }))
|
|
await nextTick()
|
|
expect(calls).toEqual(['two'])
|
|
})
|
|
})
|