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('#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('#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('[role="dialog"]')! const first = document.querySelector('#first')! const last = document.querySelector('#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('.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('[role="dialog"]')! expect(dialog.tagName).toBe('FORM') dialog.querySelector('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('[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('[aria-labelledby="layer-lower"]')!.parentElement! const upper = document.querySelector('[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 | 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('.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 | 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('#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('[role="dialog"]')! dialog.querySelector('#first')?.remove() dialog.querySelector('#last')?.remove() const hiddenParent = document.createElement('div') hiddenParent.hidden = true hiddenParent.innerHTML = '' const ariaParent = document.createElement('div') ariaParent.setAttribute('aria-hidden', 'true') ariaParent.innerHTML = '' const inertParent = document.createElement('div') inertParent.setAttribute('inert', '') inertParent.innerHTML = '' 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']) }) })