diff --git a/docs/en-US/component/dialog.md b/docs/en-US/component/dialog.md index 93bbf62c0a..1110204a15 100644 --- a/docs/en-US/component/dialog.md +++ b/docs/en-US/component/dialog.md @@ -23,6 +23,22 @@ dialog/basic-usage ::: +## Focus trapping + +Dialog traps focus inside the dialog content which enables your users to navigate the content via keyboard. + +:::tip + +Focusing on other element after the dialog is closed will only work when `destroy-on-close` is enabled + +::: + +:::demo + +dialog/focus-trapping + +::: + ## Customizations The content of Dialog can be anything, even a table or a form. This example shows how to use Element Plus Table and Form with Dialog。 @@ -118,9 +134,11 @@ When using `modal` = false, please make sure that `append-to-body` was set to ** ## Events -| Event Name | Description | Parameters | -| ---------- | ----------------------------------------------- | ---------- | -| open | triggers when the Dialog opens | — | -| opened | triggers when the Dialog opening animation ends | — | -| close | triggers when the Dialog closes | — | -| closed | triggers when the Dialog closing animation ends | — | +| Event Name | Description | Parameters | +| ---------------- | ------------------------------------------------ | ---------- | +| open | triggers when the Dialog opens | — | +| opened | triggers when the Dialog opening animation ends | — | +| close | triggers when the Dialog closes | — | +| closed | triggers when the Dialog closing animation ends | — | +| open-auto-focus | triggers after Dialog opens and content focused | — | +| close-auto-focus | triggers after Dialog closed and content focused | — | diff --git a/docs/examples/dialog/focus-trapping.vue b/docs/examples/dialog/focus-trapping.vue new file mode 100644 index 0000000000..002d1da1ad --- /dev/null +++ b/docs/examples/dialog/focus-trapping.vue @@ -0,0 +1,47 @@ + + + + diff --git a/packages/components/dialog/__tests__/dialog.spec.ts b/packages/components/dialog/__tests__/dialog.spec.ts index 9d8fad8910..7d91843eba 100644 --- a/packages/components/dialog/__tests__/dialog.spec.ts +++ b/packages/components/dialog/__tests__/dialog.spec.ts @@ -1,9 +1,9 @@ -import { nextTick } from 'vue' +import { nextTick, markRaw } 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 { Delete } from '@element-plus/icons-vue' -import Dialog from '../' +import Dialog from '../src/dialog.vue' const AXIOM = 'Rem is the best girl' @@ -83,7 +83,7 @@ describe('Dialog.vue', () => { }) await nextTick() expect( - document.body.firstElementChild.classList.contains('el-overlay') + document.body.firstElementChild!.classList.contains('el-overlay') ).toBe(true) wrapper.unmount() }) @@ -128,7 +128,7 @@ describe('Dialog.vue', () => { }) await nextTick() await wrapper.find('.el-dialog__headerbtn').trigger('click') - expect(wrapper.vm.visible).toBe(false) + expect((wrapper.vm as InstanceType).visible).toBe(false) }) describe('mask related', () => { @@ -265,7 +265,7 @@ describe('Dialog.vue', () => { const wrapper = _mount({ props: { modelValue: true, - closeIcon: Delete, + closeIcon: markRaw(Delete), }, }) await nextTick() diff --git a/packages/components/dialog/src/dialog-content.ts b/packages/components/dialog/src/dialog-content.ts new file mode 100644 index 0000000000..9d04748854 --- /dev/null +++ b/packages/components/dialog/src/dialog-content.ts @@ -0,0 +1,32 @@ +import { iconPropType, buildProps } from '@element-plus/utils' + +export const dialogContentProps = buildProps({ + center: { + type: Boolean, + default: false, + }, + closeIcon: { + type: iconPropType, + default: '', + }, + customClass: { + type: String, + default: '', + }, + draggable: { + type: Boolean, + default: false, + }, + fullscreen: { + type: Boolean, + default: false, + }, + showClose: { + type: Boolean, + default: true, + }, + title: { + type: String, + default: '', + }, +} as const) diff --git a/packages/components/dialog/src/dialog-content.vue b/packages/components/dialog/src/dialog-content.vue new file mode 100644 index 0000000000..d0a3cf7a07 --- /dev/null +++ b/packages/components/dialog/src/dialog-content.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/components/dialog/src/dialog.ts b/packages/components/dialog/src/dialog.ts index 68513c5706..1e6c2f8d65 100644 --- a/packages/components/dialog/src/dialog.ts +++ b/packages/components/dialog/src/dialog.ts @@ -1,8 +1,11 @@ -import { buildProps, definePropType, iconPropType } from '@element-plus/utils' +import { buildProps, definePropType } from '@element-plus/utils' import { UPDATE_MODEL_EVENT } from '@element-plus/constants' +import { dialogContentProps } from './dialog-content' + import type { ExtractPropTypes } from 'vue' export const dialogProps = buildProps({ + ...dialogContentProps, appendToBody: { type: Boolean, default: false, @@ -14,18 +17,6 @@ export const dialogProps = buildProps({ type: Boolean, default: false, }, - center: { - type: Boolean, - default: false, - }, - customClass: { - type: String, - default: '', - }, - closeIcon: { - type: iconPropType, - default: '', - }, closeOnClickModal: { type: Boolean, default: true, @@ -34,14 +25,6 @@ export const dialogProps = buildProps({ type: Boolean, default: true, }, - fullscreen: { - type: Boolean, - default: false, - }, - draggable: { - type: Boolean, - default: false, - }, lockScroll: { type: Boolean, default: true, @@ -50,14 +33,6 @@ export const dialogProps = buildProps({ type: Boolean, default: true, }, - showClose: { - type: Boolean, - default: true, - }, - title: { - type: String, - default: '', - }, openDelay: { type: Number, default: 0, @@ -81,6 +56,11 @@ export const dialogProps = buildProps({ type: Number, }, } as const) + +export const dialogContentEmits = { + close: () => true, +} + export type DialogProps = ExtractPropTypes export const dialogEmits = { @@ -89,5 +69,7 @@ export const dialogEmits = { close: () => true, closed: () => true, [UPDATE_MODEL_EVENT]: (value: boolean) => typeof value === 'boolean', + openAutoFocus: () => true, + closeAutoFocus: () => true, } export type DialogEmits = typeof dialogEmits diff --git a/packages/components/dialog/src/dialog.vue b/packages/components/dialog/src/dialog.vue index f8e082c667..cc1a9221dc 100644 --- a/packages/components/dialog/src/dialog.vue +++ b/packages/components/dialog/src/dialog.vue @@ -19,96 +19,93 @@ @mousedown="overlayEvent.onMousedown" @mouseup="overlayEvent.onMouseup" > -
-
- - - {{ title }} - - - -
- -
- -
-
+ + + + + + - diff --git a/packages/components/dialog/src/token.ts b/packages/components/dialog/src/token.ts new file mode 100644 index 0000000000..0191ffab82 --- /dev/null +++ b/packages/components/dialog/src/token.ts @@ -0,0 +1,14 @@ +import type { ComputedRef, CSSProperties, InjectionKey, Ref } from 'vue' +import type { useNamespace } from '@element-plus/hooks' + +export type DialogContext = { + dialogRef: Ref + headerRef: Ref + ns: ReturnType + rendered: Ref + style: ComputedRef +} + +export const elDialogInjectionKey: InjectionKey = Symbol( + 'elDialogInjectionKey' +) diff --git a/packages/components/focus-trap/__tests__/focus-trap.spec.ts b/packages/components/focus-trap/__tests__/focus-trap.spec.ts index d996061922..ce7d63ccac 100644 --- a/packages/components/focus-trap/__tests__/focus-trap.spec.ts +++ b/packages/components/focus-trap/__tests__/focus-trap.spec.ts @@ -30,7 +30,7 @@ describe(' { `, } - const createComponent = (props = {}, items = null) => + const createComponent = (props = {}, items: null | number = null) => mount(ElFocusTrap, { props: { trapped: true, @@ -47,7 +47,7 @@ describe(' { const findDescendants = () => wrapper.findAll('.item') afterEach(() => { - wrapper?.unmount() + // wrapper?.unmount() document.body.innerHTML = '' }) @@ -68,7 +68,7 @@ describe(' { const descendants = findDescendants() expect(descendants).toHaveLength(3) - expect(document.activeElement).toBe(descendants.at(0).element) + expect(document.activeElement).toBe(descendants.at(0)?.element) }) }) @@ -105,7 +105,7 @@ describe(' { const childComponent = findFocusComponent() const items = findDescendants() - expect(document.activeElement).toBe(items.at(0).element) + expect(document.activeElement).toBe(items.at(0)?.element) /** * NOTE: @@ -117,14 +117,14 @@ describe(' { await childComponent.trigger('keydown.shift', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(0).element) - ;(items.at(2).element as HTMLElement).focus() - expect(document.activeElement).toBe(items.at(2).element) + expect(document.activeElement).toBe(items.at(0)?.element) + ;(items.at(2)?.element as HTMLElement).focus() + expect(document.activeElement).toBe(items.at(2)?.element) await childComponent.trigger('keydown', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(2).element) + expect(document.activeElement).toBe(items.at(2)?.element) // set loop to true so that tab can tabbing from last to first and back forth await wrapper.setProps({ @@ -134,12 +134,12 @@ describe(' { await childComponent.trigger('keydown', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(0).element) + expect(document.activeElement).toBe(items.at(0)?.element) await childComponent.trigger('keydown.shift', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(2).element) + expect(document.activeElement).toBe(items.at(2)?.element) }) it('should not be able to navigate when no focusable element contained', async () => { @@ -166,13 +166,13 @@ describe(' { const focusComponent = findFocusComponent() const items = findDescendants() - expect(document.activeElement).toBe(items.at(0).element) + expect(document.activeElement).toBe(items.at(0)?.element) await focusComponent.trigger('keydown', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(0).element) + expect(document.activeElement).toBe(items.at(0)?.element) }) it('should not be able to navigate if the current layer is paused', async () => { @@ -186,31 +186,30 @@ describe(' { const focusComponent = findFocusComponent() const items = findDescendants() - expect(document.activeElement).toBe(items.at(0).element) + expect(document.activeElement).toBe(items.at(0)?.element) await focusComponent.trigger('keydown.shift', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(2).element) + expect(document.activeElement).toBe(items.at(2)?.element) - const newFocusTrap = createComponent() + const newFocusTrap = createComponent({ loop: true }, 3) await nextTick() - expect(document.activeElement).toBe( - newFocusTrap.find(`.${childKls}`).element - ) + expect(document.activeElement).toBe(newFocusTrap.find('.item').element) await focusComponent.trigger('keydown', { key: EVENT_CODE.tab, }) - expect(document.activeElement).not.toBe(items.at(0).element) - + expect(document.activeElement).not.toBe(items.at(0)?.element) newFocusTrap.unmount() - expect(document.activeElement).toBe(items.at(2).element) + await nextTick() + + expect(document.activeElement).toBe(items.at(2)?.element) await focusComponent.trigger('keydown', { key: EVENT_CODE.tab, }) - expect(document.activeElement).toBe(items.at(0).element) + expect(document.activeElement).toBe(items.at(0)?.element) }) }) }) diff --git a/packages/components/focus-trap/src/focus-trap.vue b/packages/components/focus-trap/src/focus-trap.vue index 0d0748184a..c98b51ce7f 100644 --- a/packages/components/focus-trap/src/focus-trap.vue +++ b/packages/components/focus-trap/src/focus-trap.vue @@ -10,8 +10,8 @@ import { provide, unref, watch, + nextTick, } from 'vue' -import { on, off } from '@element-plus/utils' import { EVENT_CODE } from '@element-plus/constants' import { focusableStack, @@ -117,6 +117,11 @@ export default defineComponent({ } } + const cleanupDocumentListeners = () => { + document.removeEventListener('focusin', onFocusIn) + document.removeEventListener('focusout', onFocusOut) + } + onMounted(() => { const trapContainer = unref(forwardRef) if (trapContainer) { @@ -126,16 +131,18 @@ export default defineComponent({ const isPrevFocusContained = trapContainer.contains(prevFocusedElement) if (!isPrevFocusContained) { const mountEvent = new Event(FOCUS_ON_MOUNT, FOCUS_ON_MOUNT_OPTS) - on(trapContainer, FOCUS_ON_MOUNT, focusOnMount) + trapContainer.addEventListener(FOCUS_ON_MOUNT, focusOnMount) trapContainer.dispatchEvent(mountEvent) if (!mountEvent.defaultPrevented) { - focusFirstDescendant( - obtainAllFocusableElements(trapContainer), - true - ) - if (document.activeElement === prevFocusedElement) { - tryFocus(trapContainer) - } + nextTick(() => { + focusFirstDescendant( + obtainAllFocusableElements(trapContainer), + true + ) + if (document.activeElement === prevFocusedElement) { + tryFocus(trapContainer) + } + }) } } } @@ -144,11 +151,10 @@ export default defineComponent({ () => props.trapped, (trapped) => { if (trapped) { - on(document, 'focusin', onFocusIn) - on(document, 'focusout', onFocusOut) + document.addEventListener('focusin', onFocusIn) + document.addEventListener('focusout', onFocusOut) } else { - off(document, 'focusin', onFocusIn) - off(document, 'focusout', onFocusOut) + cleanupDocumentListeners() } }, { immediate: true } @@ -156,21 +162,21 @@ export default defineComponent({ }) onBeforeUnmount(() => { + cleanupDocumentListeners() const trapContainer = unref(forwardRef) if (trapContainer) { - off(trapContainer, FOCUS_ON_MOUNT, focusOnMount) + trapContainer.removeEventListener(FOCUS_ON_MOUNT, focusOnMount) const unmountEvent = new Event(FOCUS_ON_UNMOUNT, FOCUS_ON_MOUNT_OPTS) - on(trapContainer, FOCUS_ON_UNMOUNT, focusOnUnmount) + trapContainer.addEventListener(FOCUS_ON_UNMOUNT, focusOnUnmount) trapContainer.dispatchEvent(unmountEvent) if (!unmountEvent.defaultPrevented) { tryFocus(lastFocusBeforeMounted ?? document.body, true) } - off(trapContainer, FOCUS_ON_UNMOUNT, focusOnUnmount) - + trapContainer.removeEventListener(FOCUS_ON_UNMOUNT, focusOnMount) focusableStack.remove(focusLayer) } }) diff --git a/packages/theme-chalk/src/dialog.scss b/packages/theme-chalk/src/dialog.scss index 8b702cc5d9..39b5d577f1 100644 --- a/packages/theme-chalk/src/dialog.scss +++ b/packages/theme-chalk/src/dialog.scss @@ -45,13 +45,17 @@ @include e(header) { padding: var(--el-dialog-padding-primary); padding-bottom: 10px; + margin-right: 16px; + word-break: break-all; } @include e(headerbtn) { position: absolute; - top: var(--el-dialog-padding-primary); - right: var(--el-dialog-padding-primary); + top: 6px; + right: 0; padding: 0; + width: 54px; + height: 54px; background: transparent; border: none; outline: none;