diff --git a/packages/dialog/__tests__/dialog.spec.ts b/packages/dialog/__tests__/dialog.spec.ts index 3f02671e68..785b015267 100644 --- a/packages/dialog/__tests__/dialog.spec.ts +++ b/packages/dialog/__tests__/dialog.spec.ts @@ -1,8 +1,10 @@ import { nextTick } from 'vue' import { mount } from '@vue/test-utils' import { rAF } from '@element-plus/test-utils/tick' +import triggerCompositeClick from '@element-plus/test-utils/composite-click' import Dialog from '../' + const AXIOM = 'Rem is the best girl' const _mount = ({ slots, ...rest }: Indexable) => { @@ -150,7 +152,7 @@ describe('Dialog.vue', () => { await nextTick() expect(wrapper.find('.el-overlay').exists()).toBe(true) - await wrapper.find('.el-overlay').trigger('click') + await triggerCompositeClick(wrapper.find('.el-overlay')) expect(wrapper.vm.visible).toBe(false) }) }) @@ -249,7 +251,7 @@ describe('Dialog.vue', () => { await rAF() await nextTick() - await wrapper.find('.el-overlay').trigger('click') + await triggerCompositeClick(wrapper.find('.el-overlay')) await nextTick() await rAF() await nextTick() diff --git a/packages/dialog/src/index.vue b/packages/dialog/src/index.vue index c9236e9bab..42ce45a816 100644 --- a/packages/dialog/src/index.vue +++ b/packages/dialog/src/index.vue @@ -28,7 +28,7 @@ role="dialog" :aria-label="title || 'dialog'" :style="style" - @click="$event.stopPropagation()" + @click.stop="" >
diff --git a/packages/directives/__tests__/trap-focus.spec.ts b/packages/directives/__tests__/trap-focus.spec.ts index 726902eb93..a2f0d3130b 100644 --- a/packages/directives/__tests__/trap-focus.spec.ts +++ b/packages/directives/__tests__/trap-focus.spec.ts @@ -9,7 +9,6 @@ const isVisibleMock = jest import TrapFocus, { ITrapFocusElement, FOCUSABLE_CHILDREN, - TRAP_FOCUS_HANDLER, } from '../trap-focus' let wrapper @@ -45,9 +44,6 @@ describe('v-trap-focus', () => { expect( (wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length, ).toBe(1) - expect( - (wrapper.element as ITrapFocusElement)[TRAP_FOCUS_HANDLER].length, - ).toBeDefined() }) test('should not fetch disabled element', () => { diff --git a/packages/directives/trap-focus/index.ts b/packages/directives/trap-focus/index.ts index d9a292df5f..7f579e4610 100644 --- a/packages/directives/trap-focus/index.ts +++ b/packages/directives/trap-focus/index.ts @@ -12,52 +12,62 @@ export interface ITrapFocusElement extends HTMLElement { [TRAP_FOCUS_HANDLER]: (e: KeyboardEvent) => void } +const FOCUS_STACK = [] + +const FOCUS_HANDLER = (e: KeyboardEvent) => { + // Getting the top layer. + if (FOCUS_STACK.length === 0) return + const focusableElement = FOCUS_STACK[FOCUS_STACK.length - 1][FOCUSABLE_CHILDREN] + if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) { + if (focusableElement.length === 1) { + e.preventDefault() + if (document.activeElement !== focusableElement[0]) { + focusableElement[0].focus() + } + return + } + const goingBackward = e.shiftKey + const isFirst = e.target === focusableElement[0] + const isLast = e.target === focusableElement[focusableElement.length - 1] + if (isFirst && goingBackward) { + e.preventDefault() + focusableElement[focusableElement.length - 1].focus() + } + if (isLast && !goingBackward) { + e.preventDefault() + focusableElement[0].focus() + } + + // the is critical since jsdom did not implement user actions, you can only mock it + // DELETE ME: when testing env switches to puppeteer + if (process.env.NODE_ENV === 'test') { + + const index = focusableElement.findIndex((element: Element) => element === e.target) + if (index !== -1) { + focusableElement[goingBackward ? index - 1 : index + 1]?.focus() + } + } + } +} + const TrapFocus: ObjectDirective = { beforeMount(el: ITrapFocusElement) { el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el) - - el[TRAP_FOCUS_HANDLER] = (e: KeyboardEvent) => { - const focusableElement = el[FOCUSABLE_CHILDREN] - if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) { - if (focusableElement.length === 1) { - e.preventDefault() - if (document.activeElement !== focusableElement[0]) { - focusableElement[0].focus() - } - return - } - const goingBackward = e.shiftKey - const isFirst = e.target === focusableElement[0] - const isLast = e.target === focusableElement[focusableElement.length - 1] - if (isFirst && goingBackward) { - e.preventDefault() - focusableElement[focusableElement.length - 1].focus() - } - if (isLast && !goingBackward) { - e.preventDefault() - focusableElement[0].focus() - } - - // the is critical since jsdom did not implement user actions, you can only mock it - // DELETE ME: when testing env switches to puppeteer - if (process.env.NODE_ENV === 'test') { - - const index = focusableElement.findIndex(element => element === e.target) - if (index !== -1) { - focusableElement[goingBackward ? index - 1 : index + 1]?.focus() - } - } - } + FOCUS_STACK.push(el) + if (FOCUS_STACK.length <= 1) { + on(document, 'keydown', FOCUS_HANDLER) } - on(document, 'keydown', el[TRAP_FOCUS_HANDLER]) }, updated(el: ITrapFocusElement) { nextTick(() => { el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el) }) }, - unmounted(el: ITrapFocusElement) { - off(document, 'keydown', el[TRAP_FOCUS_HANDLER]) + unmounted() { + FOCUS_STACK.shift() + if (FOCUS_STACK.length === 0) { + off(document, 'keydown', FOCUS_HANDLER) + } }, } diff --git a/packages/drawer/src/index.vue b/packages/drawer/src/index.vue index 6ebdd4d95f..00b53495b4 100644 --- a/packages/drawer/src/index.vue +++ b/packages/drawer/src/index.vue @@ -14,50 +14,41 @@ @click="onModalClick" >
- + + + +
diff --git a/packages/hooks/__tests__/use-prevent-global.ts b/packages/hooks/__tests__/use-prevent-global.ts new file mode 100644 index 0000000000..48dd66ed81 --- /dev/null +++ b/packages/hooks/__tests__/use-prevent-global.ts @@ -0,0 +1,48 @@ +import { ref } from 'vue' +import { on, off } from '@element-plus/utils/dom' +import triggerEvent from '@element-plus/test-utils/trigger-event' +import usePreventGlobal from '../use-prevent-global' + +describe('usePreventGlobal', () => { + const evtName = 'keydown' + const evt = jest.fn() + beforeAll(() => { + on(document.body, evtName, evt) + }) + + beforeEach(() => { + evt.mockClear() + }) + + afterAll(() => { + off(document.body, evtName, evt) + }) + + it('should prevent global event from happening', () => { + const visible = ref(true) + const evt2Trigger = jest.fn().mockReturnValue(true) + usePreventGlobal(visible, evtName, evt2Trigger) + + triggerEvent(document.body, evtName) + + expect(evt).not.toBeCalled() + expect(evt2Trigger).toHaveBeenCalled() + visible.value = false + // clean up + }) + + + it('should not prevent global event from happening', () => { + const visible = ref(true) + const evt2Trigger = jest.fn().mockReturnValue(false) + usePreventGlobal(visible, evtName, evt2Trigger) + + triggerEvent(document.body, evtName) + + expect(evt).toHaveBeenCalled() + expect(evt2Trigger).toHaveBeenCalled() + + visible.value = false + }) + +}) diff --git a/packages/hooks/index.ts b/packages/hooks/index.ts index bafcae6678..a0c935ac8f 100644 --- a/packages/hooks/index.ts +++ b/packages/hooks/index.ts @@ -6,3 +6,4 @@ export { default as useModal } from './use-modal' export { default as useMigrating } from './use-migrating' export { default as useFocus } from './use-focus' export { default as useThrottleRender } from './use-throttle-render' +export { default as usePreventGlobal } from './use-prevent-global' diff --git a/packages/hooks/use-modal/index.ts b/packages/hooks/use-modal/index.ts index 2b3311f636..e17efffda5 100644 --- a/packages/hooks/use-modal/index.ts +++ b/packages/hooks/use-modal/index.ts @@ -14,6 +14,7 @@ const modalStack: ModalInstance[] = [] const closeModal = (e: KeyboardEvent) => { if (modalStack.length === 0) return if (e.code === EVENT_CODE.esc) { + e.stopPropagation() const topModal = modalStack[modalStack.length - 1] topModal.handleClose() } diff --git a/packages/hooks/use-prevent-global/index.ts b/packages/hooks/use-prevent-global/index.ts new file mode 100644 index 0000000000..105405e1b8 --- /dev/null +++ b/packages/hooks/use-prevent-global/index.ts @@ -0,0 +1,19 @@ +import { watch } from 'vue' +import { on, off } from '@element-plus/utils/dom' + +import type { Ref } from 'vue' + +export default (indicator: Ref, evt: string, cb: (e: Event) => boolean) => { + const prevent = (e: Event) => { + if (cb(e)) { + e.stopImmediatePropagation() + } + } + watch(() => indicator.value, val => { + if (val) { + on(document, evt, prevent, true) + } else { + off(document, evt, prevent, true) + } + }, { immediate: true }) +} diff --git a/packages/message-box/__tests__/message-box.spec.ts b/packages/message-box/__tests__/message-box.spec.ts index e2458b34c9..228072cb6c 100644 --- a/packages/message-box/__tests__/message-box.spec.ts +++ b/packages/message-box/__tests__/message-box.spec.ts @@ -1,18 +1,28 @@ +import { mount } from '@vue/test-utils' import MessageBox from '../src/messageBox' -import { sleep } from '@element-plus/test-utils' -import { nextTick } from 'vue' +import { rAF } from '@element-plus/test-utils/tick' +import { triggerNativeCompositeClick } from '@element-plus/test-utils/composite-click' -const selector = '.el-message-box__wrapper' +const selector = '.el-overlay' + +const _mount = (invoker: () => void) => { + return mount( + { + template: '
', + mounted() { + invoker() + }, + }, + { + attachTo: 'body', + }, + ) +} describe('MessageBox', () => { - - afterEach(() => { - const el = document.querySelector('.el-message-box__wrapper') - if (!el) return - if (el.parentNode) { - el.parentNode.removeChild(el) - } + afterEach(async () => { MessageBox.close() + await rAF() }) test('create and close', async () => { @@ -22,17 +32,23 @@ describe('MessageBox', () => { message: '这是一段内容', }) const msgbox: HTMLElement = document.querySelector(selector) + expect(msgbox).toBeDefined() - await sleep() - expect(msgbox.querySelector('.el-message-box__title span').textContent).toEqual('消息') - expect(msgbox.querySelector('.el-message-box__message').querySelector('p').textContent).toEqual('这是一段内容') + await rAF() + expect( + msgbox.querySelector('.el-message-box__title span').textContent, + ).toEqual('消息') + expect( + msgbox.querySelector('.el-message-box__message').querySelector('p') + .textContent, + ).toEqual('这是一段内容') MessageBox.close() - await sleep(250) + await rAF() expect(msgbox.style.display).toEqual('none') }) test('invoke with strings', () => { - MessageBox('消息', '这是一段内容') + MessageBox({ title: '消息', message: '这是一段内容' }) const msgbox = document.querySelector(selector) expect(msgbox).toBeDefined() }) @@ -43,7 +59,7 @@ describe('MessageBox', () => { iconClass: 'el-icon-question', message: '这是一段内容', }) - await sleep() + await rAF() const icon = document.querySelector('.el-message-box__status') expect(icon.classList.contains('el-icon-question')).toBe(true) }) @@ -54,25 +70,32 @@ describe('MessageBox', () => { dangerouslyUseHTMLString: true, message: 'html string', }) - await sleep() + await rAF() const message = document.querySelector('.el-message-box__message strong') expect(message.textContent).toEqual('html string') }) test('distinguish cancel and close', async () => { let msgAction = '' - MessageBox({ - title: '消息', - message: '这是一段内容', - distinguishCancelAndClose: true, - callback: action => { - msgAction = action - }, - }) - await sleep() - const btn = document.querySelector('.el-message-box__close') as HTMLButtonElement + const invoker = () => { + MessageBox({ + title: '消息', + message: '这是一段内容', + distinguishCancelAndClose: true, + callback: action => { + msgAction = action + }, + }) + } + + _mount(invoker) + await rAF() + + const btn = document.querySelector( + '.el-message-box__close', + ) as HTMLButtonElement btn.click() - await sleep() + await rAF() expect(msgAction).toEqual('close') }) @@ -81,26 +104,27 @@ describe('MessageBox', () => { title: '标题名称', type: 'warning', }) - await sleep() - const vModal: HTMLElement = document.querySelector('.v-modal') - vModal.click() - await sleep(250) + await rAF() + await triggerNativeCompositeClick(document.querySelector(selector)) + await rAF() const msgbox: HTMLElement = document.querySelector(selector) expect(msgbox.style.display).toEqual('') expect(msgbox.querySelector('.el-icon-warning')).toBeDefined() }) - test('confirm', async () => { + test('confirm', async () => { MessageBox.confirm('这是一段内容', { title: '标题名称', type: 'warning', }) - await sleep() - const btn = document.querySelector(selector).querySelector('.el-button--primary') as HTMLButtonElement + await rAF() + const btn = document + .querySelector(selector) + .querySelector('.el-button--primary') as HTMLButtonElement btn.click() - await sleep(250) + await rAF() const msgbox: HTMLElement = document.querySelector(selector) - expect(msgbox.style.display).toEqual('none') + expect(msgbox).toBe(null) }) test('prompt', async () => { @@ -109,9 +133,13 @@ describe('MessageBox', () => { inputPattern: /test/, inputErrorMessage: 'validation failed', }) - await sleep(0) - const inputElm = document.querySelector(selector).querySelector('.el-message-box__input') - const haveFocus = inputElm.querySelector('input').isSameNode(document.activeElement) + await rAF() + const inputElm = document + .querySelector(selector) + .querySelector('.el-message-box__input') + const haveFocus = inputElm + .querySelector('input') + .isSameNode(document.activeElement) expect(inputElm).toBeDefined() expect(haveFocus).toBe(true) }) @@ -121,8 +149,10 @@ describe('MessageBox', () => { inputType: 'textarea', title: '标题名称', }) - await sleep() - const textareaElm = document.querySelector(selector).querySelector('textarea') + await rAF() + const textareaElm = document + .querySelector(selector) + .querySelector('textarea') const haveFocus = textareaElm.isSameNode(document.activeElement) expect(haveFocus).toBe(true) }) @@ -136,55 +166,65 @@ describe('MessageBox', () => { msgAction = action }, }) - await sleep() - const closeBtn = document.querySelector('.el-message-box__close') as HTMLButtonElement + await rAF() + const closeBtn = document.querySelector( + '.el-message-box__close', + ) as HTMLButtonElement closeBtn.click() - await sleep() + await rAF() expect(msgAction).toEqual('cancel') }) - test('beforeClose', async() => { + test('beforeClose', async () => { let msgAction = '' MessageBox({ + callback: action => { + msgAction = action + }, title: '消息', message: '这是一段内容', - beforeClose: (action, instance) => { - instance.close() + beforeClose: (_, __, done) => { + done() }, - }, action => { - msgAction = action }) - await sleep(); - (document.querySelector('.el-message-box__wrapper .el-button--primary') as HTMLButtonElement).click() - await nextTick() - await sleep() + await rAF() + ;(document.querySelector( + '.el-message-box__btns .el-button--primary', + ) as HTMLButtonElement).click() + await rAF() expect(msgAction).toEqual('confirm') }) describe('promise', () => { - test('resolve',async () => { + test('resolve', async () => { let msgAction = '' - MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示') - .then(action => { + MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示').then( + action => { msgAction = action - }) - await sleep() - const btn = document.querySelector('.el-message-box__btns .el-button--primary') as HTMLButtonElement + }, + ) + await rAF() + const btn = document.querySelector( + '.el-message-box__btns .el-button--primary', + ) as HTMLButtonElement btn.click() - await sleep() + await rAF() expect(msgAction).toEqual('confirm') }) test('reject', async () => { let msgAction = '' - MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示') - .catch(action => { + MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示').catch( + action => { msgAction = action - }) - await sleep() - const btn = document.querySelectorAll('.el-message-box__btns .el-button') as NodeListOf - btn[0].click() - await sleep() + }, + ) + await rAF() + const btn = document.querySelector( + '.el-message-box__btns .el-button', + ) + ;(btn as HTMLButtonElement).click() + await rAF() expect(msgAction).toEqual('cancel') }) }) diff --git a/packages/message-box/src/index.vue b/packages/message-box/src/index.vue index ae7279b779..190cc0da0d 100644 --- a/packages/message-box/src/index.vue +++ b/packages/message-box/src/index.vue @@ -1,23 +1,32 @@ -