feat(components): [el-dialog] enhancement for dialog a11y (#6087)

* feat(components): [el-dialog] enhancement for dialog a11y

- Refactor dialog to script setup

* Separates dialog and its content into different components

* Remove unused code & fix a potential bug in focus-trap component

* Update dialog-content.vue

Co-authored-by: bqy <1743369777@qq.com>
This commit is contained in:
JeremyWuuuuu
2022-02-22 12:49:28 +08:00
committed by GitHub
parent 108f7dd5df
commit d2e9de9511
11 changed files with 323 additions and 159 deletions

View File

@@ -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 | — |

View File

@@ -0,0 +1,47 @@
<template>
<el-button type="text" @click="dialogVisible = true"
>click to open the Dialog</el-button
>
<div>
<p>Close dialog and the input will be focused</p>
<el-input ref="inputRef" placeholder="Please input" />
</div>
<el-dialog
v-model="dialogVisible"
destroy-on-close
title="Tips"
width="30%"
@close-auto-focus="handleCloseAutoFocus"
>
<span>This is a message</span>
<el-divider />
<el-input placeholder="Initially focused" />
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false"
>Confirm</el-button
>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { ElInput } from 'element-plus'
const dialogVisible = ref(false)
const inputRef = ref<InstanceType<typeof ElInput>>()
const handleCloseAutoFocus = () => {
inputRef.value?.focus()
}
</script>
<style scoped>
.dialog-footer button:first-child {
margin-right: 10px;
}
</style>

View File

@@ -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<typeof Dialog>).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()

View File

@@ -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)

View File

@@ -0,0 +1,65 @@
<template>
<div
:ref="composedDialogRef"
:class="[
ns.b(),
ns.is('fullscreen', fullscreen),
ns.is('draggable', draggable),
{ [ns.m('center')]: center },
customClass,
]"
aria-modal="true"
role="dialog"
:aria-label="title || 'dialog'"
:style="style"
@click.stop=""
@keydown="onKeydown"
>
<div ref="headerRef" :class="ns.e('header')">
<slot name="title">
<span :class="ns.e('title')">
{{ title }}
</span>
</slot>
</div>
<div :class="ns.e('body')">
<slot></slot>
</div>
<div v-if="$slots.footer" :class="ns.e('footer')">
<slot name="footer"></slot>
</div>
<button
v-if="showClose"
aria-label="close"
:class="ns.e('headerbtn')"
type="button"
@click="$emit('close')"
>
<el-icon :class="ns.e('close')">
<component :is="closeIcon || Close" />
</el-icon>
</button>
</div>
</template>
<script lang="ts" setup>
import { inject } from 'vue'
import { ElIcon } from '@element-plus/components/icon'
import { FOCUS_TRAP_INJECTION_KEY } from '@element-plus/components/focus-trap'
import { CloseComponents, composeRefs } from '@element-plus/utils'
import { dialogContentProps } from './dialog-content'
import { elDialogInjectionKey } from './token'
const { Close } = CloseComponents
defineProps(dialogContentProps)
const { dialogRef, headerRef, ns, style } = inject(
elDialogInjectionKey,
undefined
)!
const { focusTrapRef, onKeydown } = inject(FOCUS_TRAP_INJECTION_KEY, undefined)!
const composedDialogRef = composeRefs(focusTrapRef, dialogRef)
</script>

View File

@@ -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<typeof dialogProps>
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

View File

@@ -19,96 +19,93 @@
@mousedown="overlayEvent.onMousedown"
@mouseup="overlayEvent.onMouseup"
>
<div
ref="dialogRef"
v-trap-focus
:class="[
ns.b(),
ns.is('fullscreen', fullscreen),
ns.is('draggable', draggable),
{ [ns.m('center')]: center },
customClass,
]"
aria-modal="true"
role="dialog"
:aria-label="title || 'dialog'"
:style="style"
@click.stop=""
<el-focus-trap
v-if="rendered"
loop
trapped
@mount-on-focus="$emit('openAutoFocus')"
@unmount-on-focus="$emit('closeAutoFocus')"
>
<div ref="headerRef" :class="ns.e('header')">
<slot name="title">
<span :class="ns.e('title')">
{{ title }}
</span>
</slot>
<button
v-if="showClose"
aria-label="close"
:class="ns.e('headerbtn')"
type="button"
@click="handleClose"
>
<el-icon :class="ns.e('close')">
<component :is="closeIcon || 'close'" />
</el-icon>
</button>
</div>
<template v-if="rendered">
<div :class="ns.e('body')">
<slot></slot>
</div>
</template>
<div v-if="$slots.footer" :class="ns.e('footer')">
<slot name="footer"></slot>
</div>
</div>
<el-dialog-content
:custom-class="customClass"
:center="center"
:close-icon="closeIcon"
:draggable="draggable"
:fullscreen="fullscreen"
:show-close="showClose"
:style="style"
:title="title"
@close="handleClose"
>
<template #title>
<slot name="title" />
</template>
<slot />
<template #footer>
<slot name="footer" />
</template>
</el-dialog-content>
</el-focus-trap>
</div>
</el-overlay>
</transition>
</teleport>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from 'vue'
import { TrapFocus } from '@element-plus/directives'
<script lang="ts" setup>
import { computed, ref, provide } from 'vue'
import { ElOverlay } from '@element-plus/components/overlay'
import { ElIcon } from '@element-plus/components/icon'
import { CloseComponents } from '@element-plus/utils'
import { ElFocusTrap } from '@element-plus/components/focus-trap'
import { useNamespace, useDraggable, useSameTarget } from '@element-plus/hooks'
import ElDialogContent from './dialog-content.vue'
import { dialogProps, dialogEmits } from './dialog'
import { elDialogInjectionKey } from './token'
import { useDialog } from './use-dialog'
export default defineComponent({
import type { SetupContext, Ref } from 'vue'
import type { DialogEmits } from './dialog'
defineOptions({
name: 'ElDialog',
components: {
ElOverlay,
ElIcon,
...CloseComponents,
},
directives: {
TrapFocus,
},
props: dialogProps,
emits: dialogEmits,
setup(props, ctx) {
const ns = useNamespace('dialog')
const dialogRef = ref<HTMLElement>()
const headerRef = ref<HTMLElement>()
const dialog = useDialog(props, ctx, dialogRef)
const overlayEvent = useSameTarget(dialog.onModalClick)
const draggable = computed(() => props.draggable && !props.fullscreen)
useDraggable(dialogRef, headerRef, draggable)
return {
ns,
dialogRef,
headerRef,
overlayEvent,
...dialog,
}
},
})
const props = defineProps(dialogProps)
const emit = defineEmits(dialogEmits)
const ns = useNamespace('dialog')
const dialogRef = ref<HTMLElement | null>(null)
const headerRef = ref<HTMLElement | null>(null)
const dialog = useDialog(
props,
{ emit } as SetupContext<DialogEmits>,
dialogRef as Ref<HTMLElement>
)
const {
visible,
afterEnter,
afterLeave,
beforeLeave,
style,
handleClose,
rendered,
} = dialog
provide(elDialogInjectionKey, {
dialogRef,
headerRef,
ns,
rendered,
style,
})
const overlayEvent = useSameTarget(dialog.onModalClick)
const draggable = computed(() => props.draggable && !props.fullscreen)
useDraggable(
dialogRef as Ref<HTMLElement>,
headerRef as Ref<HTMLElement>,
draggable
)
</script>

View File

@@ -0,0 +1,14 @@
import type { ComputedRef, CSSProperties, InjectionKey, Ref } from 'vue'
import type { useNamespace } from '@element-plus/hooks'
export type DialogContext = {
dialogRef: Ref<HTMLElement | null>
headerRef: Ref<HTMLElement | null>
ns: ReturnType<typeof useNamespace>
rendered: Ref<boolean>
style: ComputedRef<CSSProperties>
}
export const elDialogInjectionKey: InjectionKey<DialogContext> = Symbol(
'elDialogInjectionKey'
)

View File

@@ -30,7 +30,7 @@ describe('<ElFocusTrap', () => {
</div>`,
}
const createComponent = (props = {}, items = null) =>
const createComponent = (props = {}, items: null | number = null) =>
mount(ElFocusTrap, {
props: {
trapped: true,
@@ -47,7 +47,7 @@ describe('<ElFocusTrap', () => {
const findDescendants = () => wrapper.findAll('.item')
afterEach(() => {
wrapper?.unmount()
// wrapper?.unmount()
document.body.innerHTML = ''
})
@@ -68,7 +68,7 @@ describe('<ElFocusTrap', () => {
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('<ElFocusTrap', () => {
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('<ElFocusTrap', () => {
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('<ElFocusTrap', () => {
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('<ElFocusTrap', () => {
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('<ElFocusTrap', () => {
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)
})
})
})

View File

@@ -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)
}
})

View File

@@ -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;