mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
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:
@@ -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 | — |
|
||||
|
||||
47
docs/examples/dialog/focus-trapping.vue
Normal file
47
docs/examples/dialog/focus-trapping.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
32
packages/components/dialog/src/dialog-content.ts
Normal file
32
packages/components/dialog/src/dialog-content.ts
Normal 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)
|
||||
65
packages/components/dialog/src/dialog-content.vue
Normal file
65
packages/components/dialog/src/dialog-content.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
14
packages/components/dialog/src/token.ts
Normal file
14
packages/components/dialog/src/token.ts
Normal 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'
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user