Files
dodo/frontend/src/components/AppSheet.test.ts
T
bboysoul 432a133a42
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m37s
feat: redesign task detail paper flow
2026-09-18 18:26:31 +08:00

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'])
})
})