feat(components): [popper] focus trap and a11y (#7736)

* feat(components): [popper] fix focus traps

* feat(components): [popper] add focus trap tests

Co-authored-by: JeremyWuuuuu <15975785+JeremyWuuuuu@users.noreply.github.com>
This commit is contained in:
opengraphica
2022-05-20 06:13:27 -04:00
committed by GitHub
parent 6f04af6c39
commit e1b88263e3
20 changed files with 490 additions and 255 deletions

View File

@@ -37,6 +37,7 @@
@keydown.down.prevent="highlight(highlightedIndex + 1)"
@keydown.enter="handleKeyEnter"
@keydown.tab="close"
@keydown.esc="handleKeyEscape"
>
<template v-if="$slots.prepend" #prepend>
<slot name="prepend" />
@@ -235,6 +236,13 @@ const handleKeyEnter = () => {
})
}
}
const handleKeyEscape = (e) => {
if (suggestionVisible.value) {
e.preventDefault()
e.stopPropagation()
close()
}
}
const close = () => {
activated.value = false
}

View File

@@ -321,10 +321,6 @@ export default defineComponent({
case EVENT_CODE.enter:
checkNode(target)
break
case EVENT_CODE.esc:
case EVENT_CODE.tab:
emit('close')
break
}
}

View File

@@ -616,6 +616,12 @@ export default defineComponent({
e.preventDefault()
break
case EVENT_CODE.esc:
if (popperVisible.value === true) {
e.preventDefault()
e.stopPropagation()
togglePopperVisible(false)
}
break
case EVENT_CODE.tab:
togglePopperVisible(false)
break
@@ -658,10 +664,6 @@ export default defineComponent({
case EVENT_CODE.enter:
target.click()
break
case EVENT_CODE.esc:
case EVENT_CODE.tab:
togglePopperVisible(false)
break
}
}

View File

@@ -11,7 +11,6 @@
:style="style"
tabindex="-1"
@click.stop
@keydown="onKeydown"
>
<header ref="headerRef" :class="ns.e('header')">
<slot name="header">
@@ -57,7 +56,7 @@ defineProps(dialogContentProps)
defineEmits(dialogContentEmits)
const { dialogRef, headerRef, bodyId, ns, style } = inject(dialogInjectionKey)!
const { focusTrapRef, onKeydown } = inject(FOCUS_TRAP_INJECTION_KEY)!
const { focusTrapRef } = inject(FOCUS_TRAP_INJECTION_KEY)!
const composedDialogRef = composeRefs(focusTrapRef, dialogRef)
</script>

View File

@@ -30,6 +30,7 @@
focus-start-el="container"
@focus-after-trapped="onOpenAutoFocus"
@focus-after-released="onCloseAutoFocus"
@release-requested="onCloseRequested"
>
<el-dialog-content
v-if="rendered"
@@ -117,6 +118,7 @@ const {
onModalClick,
onOpenAutoFocus,
onCloseAutoFocus,
onCloseRequested,
} = useDialog(props, dialogRef)
provide(dialogInjectionKey, {

View File

@@ -13,7 +13,6 @@ import {
useGlobalConfig,
useId,
useLockscreen,
useModal,
useZIndex,
} from '@element-plus/hooks'
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
@@ -140,13 +139,10 @@ export const useDialog = (
useLockscreen(visible)
}
if (props.closeOnPressEscape) {
useModal(
{
handleClose,
},
visible
)
function onCloseRequested() {
if (props.closeOnPressEscape) {
handleClose()
}
}
watch(
@@ -204,6 +200,7 @@ export const useDialog = (
doClose,
onOpenAutoFocus,
onCloseAutoFocus,
onCloseRequested,
titleId,
bodyId,
closed,

View File

@@ -18,62 +18,61 @@
:trapped="visible"
:focus-trap-el="drawerRef"
:focus-start-el="focusStartRef"
@release-requested="onCloseRequested"
>
<template #default="{ handleKeydown }">
<div
ref="drawerRef"
aria-modal="true"
:aria-label="title || undefined"
:aria-labelledby="!title ? titleId : undefined"
:aria-describedby="bodyId"
:class="[ns.b(), direction, visible && 'open', customClass]"
:style="
isHorizontal ? 'width: ' + drawerSize : 'height: ' + drawerSize
"
role="dialog"
@click.stop
@keydown="handleKeydown"
>
<span
ref="focusStartRef"
:class="ns.e('sr-focus')"
tabindex="-1"
/>
<header v-if="withHeader" :class="ns.e('header')">
<slot
<div
ref="drawerRef"
aria-modal="true"
:aria-label="title || undefined"
:aria-labelledby="!title ? titleId : undefined"
:aria-describedby="bodyId"
:class="[ns.b(), direction, visible && 'open', customClass]"
:style="
isHorizontal ? 'width: ' + drawerSize : 'height: ' + drawerSize
"
role="dialog"
@click.stop
>
<span ref="focusStartRef" :class="ns.e('sr-focus')" tabindex="-1" />
<header v-if="withHeader" :class="ns.e('header')">
<slot
v-if="!$slots.title"
name="header"
:close="handleClose"
:title-id="titleId"
:title-class="ns.e('title')"
>
<span
v-if="!$slots.title"
name="header"
:close="handleClose"
:title-id="titleId"
:title-class="ns.e('title')"
:id="titleId"
role="heading"
:class="ns.e('title')"
>
<span :id="titleId" role="heading" :class="ns.e('title')">
{{ title }}
</span>
</slot>
<slot v-else name="title">
<!-- DEPRECATED SLOT -->
</slot>
<button
v-if="showClose"
:aria-label="t('el.drawer.close')"
:class="ns.e('close-btn')"
type="button"
@click="handleClose"
>
<el-icon :class="ns.e('close')"><close /></el-icon>
</button>
</header>
<template v-if="rendered">
<div :id="bodyId" :class="ns.e('body')">
<slot />
</div>
</template>
<div v-if="$slots.footer" :class="ns.e('footer')">
<slot name="footer" />
{{ title }}
</span>
</slot>
<slot v-else name="title">
<!-- DEPRECATED SLOT -->
</slot>
<button
v-if="showClose"
:aria-label="t('el.drawer.close')"
:class="ns.e('close-btn')"
type="button"
@click="handleClose"
>
<el-icon :class="ns.e('close')"><close /></el-icon>
</button>
</header>
<template v-if="rendered">
<div :id="bodyId" :class="ns.e('body')">
<slot />
</div>
</template>
<div v-if="$slots.footer" :class="ns.e('footer')">
<slot name="footer" />
</div>
</template>
</div>
</el-focus-trap>
</el-overlay>
</transition>

View File

@@ -31,19 +31,17 @@
tag="div"
:view-class="ns.e('list')"
>
<el-focus-trap trapped @focus-after-trapped="onFocusAfterTrapped">
<el-roving-focus-group
:loop="loop"
:current-tab-id="currentTabId"
orientation="horizontal"
@current-tab-id-change="handleCurrentTabIdChange"
@entry-focus="handleEntryFocus"
>
<el-dropdown-collection>
<slot name="dropdown" />
</el-dropdown-collection>
</el-roving-focus-group>
</el-focus-trap>
<el-roving-focus-group
:loop="loop"
:current-tab-id="currentTabId"
orientation="horizontal"
@current-tab-id-change="handleCurrentTabIdChange"
@entry-focus="handleEntryFocus"
>
<el-dropdown-collection>
<slot name="dropdown" />
</el-dropdown-collection>
</el-roving-focus-group>
</el-scrollbar>
</template>
<template v-if="!splitButton" #default>
@@ -92,7 +90,6 @@ import ElButton from '@element-plus/components/button'
import ElTooltip from '@element-plus/components/tooltip'
import ElScrollbar from '@element-plus/components/scrollbar'
import ElIcon from '@element-plus/components/icon'
import ElFocusTrap from '@element-plus/components/focus-trap'
import ElRovingFocusGroup from '@element-plus/components/roving-focus-group'
import { addUnit } from '@element-plus/utils'
import { ArrowDown } from '@element-plus/icons-vue'
@@ -108,7 +105,6 @@ export default defineComponent({
name: 'ElDropdown',
components: {
ElButton,
ElFocusTrap,
ElButtonGroup,
ElScrollbar,
ElDropdownCollection,

View File

@@ -106,6 +106,83 @@ describe('<ElFocusTrap', () => {
expect(focusOnUnmount).toHaveBeenCalled()
expect(document.activeElement).toBe(document.body)
})
it('should be able to dispatch `release-requested` if escape key pressed while trapped', async () => {
wrapper = createComponent(
{
trapped: false,
loop: true,
},
1
)
await nextTick()
const focusContainer = findFocusContainer()
focusContainer?.trigger('keydown', {
key: EVENT_CODE.esc,
})
await nextTick()
await nextTick()
expect(wrapper.emitted('release-requested')).toBeFalsy()
await wrapper.setProps({ trapped: true })
await nextTick()
await nextTick()
const items = findDescendants()
const firstItem = items.at(0)
expect(document.activeElement).toBe(firstItem?.element)
// Expect no emit if esc while not trapped
expect(wrapper.emitted('release-requested')).toBeFalsy()
focusContainer?.trigger('keydown', {
key: EVENT_CODE.esc,
})
await nextTick()
await nextTick()
// Expect emit if esc while trapped
expect(wrapper.emitted('release-requested')?.length).toBe(1)
createComponent({ loop: true }, 3)
await nextTick()
await nextTick()
focusContainer?.trigger('keydown', {
key: EVENT_CODE.esc,
})
// Expect no emit if esc while layer paused
expect(wrapper.emitted('release-requested')).toBeFalsy()
})
it('should be able to dispatch `focusout-prevented` when trab wraps due to trapped or is blocked', async () => {
wrapper = createComponent(undefined, 3)
await nextTick()
await nextTick()
const childComponent = findFocusContainer()
const items = findDescendants()
expect(document.activeElement).toBe(items.at(0)?.element)
expect(wrapper.emitted('focusout-prevented')).toBeFalsy()
await childComponent.trigger('keydown.shift', {
key: EVENT_CODE.tab,
})
expect(document.activeElement).toBe(items.at(0)?.element)
expect(wrapper.emitted('focusout-prevented')?.length).toBe(2)
;(items.at(2)?.element as HTMLElement).focus()
await childComponent.trigger('keydown', {
key: EVENT_CODE.tab,
})
expect(wrapper.emitted('focusout-prevented')?.length).toBe(4)
})
})
describe('features', () => {
@@ -249,5 +326,29 @@ describe('<ElFocusTrap', () => {
})
expect(document.activeElement).toBe(items.at(0)?.element)
})
it('should steal focus when trapped', async () => {
wrapper = createComponent(
{
trapped: false,
loop: true,
},
1
)
await nextTick()
const beforeTrap = findBeforeTrap()
;(beforeTrap.element as HTMLElement).focus()
expect(document.activeElement).toBe(beforeTrap.element)
await wrapper.setProps({ trapped: true })
await nextTick()
await nextTick()
const items = findDescendants()
const firstItem = items.at(0)
expect(document.activeElement).toBe(firstItem?.element)
})
})
})

View File

@@ -13,6 +13,7 @@ import {
watch,
} from 'vue'
import { EVENT_CODE } from '@element-plus/constants'
import { useEscapeKeydown } from '@element-plus/hooks'
import { isString } from '@element-plus/utils'
import {
focusFirstDescendant,
@@ -45,11 +46,24 @@ export default defineComponent({
default: 'first',
},
},
emits: [ON_TRAP_FOCUS_EVT, ON_RELEASE_FOCUS_EVT],
emits: [
ON_TRAP_FOCUS_EVT,
ON_RELEASE_FOCUS_EVT,
'focusin',
'focusout',
'focusout-prevented',
'release-requested',
],
setup(props, { emit }) {
const forwardRef = ref<HTMLElement | undefined>()
let lastFocusBeforeMounted: HTMLElement | null
let lastFocusAfterMounted: HTMLElement | null
let lastFocusBeforeTrapped: HTMLElement | null
let lastFocusAfterTrapped: HTMLElement | null
useEscapeKeydown((event) => {
if (props.trapped && !focusLayer.paused) {
emit('release-requested', event)
}
})
const focusLayer: FocusLayer = {
paused: false,
@@ -75,19 +89,23 @@ export default defineComponent({
const container = currentTarget as HTMLElement
const [first, last] = getEdges(container)
const isTabbable = first && last
if (!isTabbable) {
if (currentFocusingEl === container) e.preventDefault()
if (currentFocusingEl === container) {
e.preventDefault()
emit('focusout-prevented')
}
} else {
if (!shiftKey && currentFocusingEl === last) {
e.preventDefault()
if (loop) tryFocus(first, true)
emit('focusout-prevented')
} else if (
shiftKey &&
[first, container].includes(currentFocusingEl as HTMLElement)
) {
e.preventDefault()
if (loop) tryFocus(last, true)
emit('focusout-prevented')
}
}
}
@@ -108,18 +126,40 @@ export default defineComponent({
{ immediate: true }
)
watch([forwardRef], ([forwardRef], [oldForwardRef]) => {
if (forwardRef) {
forwardRef.addEventListener('keydown', onKeydown)
forwardRef.addEventListener('focusin', onFocusIn)
forwardRef.addEventListener('focusout', onFocusOut)
}
if (oldForwardRef) {
oldForwardRef.removeEventListener('keydown', onKeydown)
oldForwardRef.removeEventListener('focusin', onFocusIn)
oldForwardRef.removeEventListener('focusout', onFocusOut)
}
})
const trapOnFocus = (e: Event) => {
emit(ON_TRAP_FOCUS_EVT, e)
}
const releaseOnFocus = (e: Event) => emit(ON_RELEASE_FOCUS_EVT, e)
const onFocusIn = (e: Event) => {
const trapContainer = unref(forwardRef)
if (focusLayer.paused || !trapContainer) return
if (!trapContainer) return
const target = e.target as HTMLElement | null
if (target && trapContainer.contains(target)) {
lastFocusAfterMounted = target
} else {
tryFocus(lastFocusAfterMounted, true)
const isFocusedInTrap = target && trapContainer.contains(target)
if (isFocusedInTrap) emit('focusin', e)
if (focusLayer.paused) return
if (props.trapped) {
if (isFocusedInTrap) {
lastFocusAfterTrapped = target
} else {
tryFocus(lastFocusAfterTrapped, true)
}
}
}
@@ -127,12 +167,18 @@ export default defineComponent({
const trapContainer = unref(forwardRef)
if (focusLayer.paused || !trapContainer) return
if (
!trapContainer.contains(
(e as FocusEvent).relatedTarget as HTMLElement | null
)
) {
tryFocus(lastFocusAfterMounted, true)
if (props.trapped) {
if (
!trapContainer.contains(
(e as FocusEvent).relatedTarget as HTMLElement | null
)
) {
tryFocus(lastFocusAfterTrapped, true)
}
} else {
const target = e.target as HTMLElement | null
const isFocusedInTrap = target && trapContainer.contains(target)
if (!isFocusedInTrap) emit('focusout', e)
}
}
@@ -143,7 +189,7 @@ export default defineComponent({
if (trapContainer) {
focusableStack.push(focusLayer)
const prevFocusedElement = document.activeElement
lastFocusBeforeMounted = prevFocusedElement as HTMLElement | null
lastFocusBeforeTrapped = prevFocusedElement as HTMLElement | null
const isPrevFocusContained = trapContainer.contains(prevFocusedElement)
if (!isPrevFocusContained) {
const focusEvent = new Event(
@@ -154,9 +200,14 @@ export default defineComponent({
trapContainer.dispatchEvent(focusEvent)
if (!focusEvent.defaultPrevented) {
nextTick(() => {
if (!isString(props.focusStartEl)) {
tryFocus(props.focusStartEl)
} else if (props.focusStartEl === 'first') {
let focusStartEl = props.focusStartEl
if (!isString(focusStartEl)) {
tryFocus(focusStartEl)
if (document.activeElement !== focusStartEl) {
focusStartEl = 'first'
}
}
if (focusStartEl === 'first') {
focusFirstDescendant(
obtainAllFocusableElements(trapContainer),
true
@@ -164,7 +215,7 @@ export default defineComponent({
}
if (
document.activeElement === prevFocusedElement ||
props.focusStartEl === 'container'
focusStartEl === 'container'
) {
tryFocus(trapContainer)
}
@@ -172,13 +223,9 @@ export default defineComponent({
}
}
}
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
}
function stopTrap() {
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
const trapContainer = unref(forwardRef)
if (trapContainer) {
@@ -192,7 +239,7 @@ export default defineComponent({
trapContainer.dispatchEvent(releasedEvent)
if (!releasedEvent.defaultPrevented) {
tryFocus(lastFocusBeforeMounted ?? document.body, true)
tryFocus(lastFocusBeforeTrapped ?? document.body, true)
}
trapContainer.removeEventListener(FOCUS_AFTER_RELEASED, trapOnFocus)

View File

@@ -22,7 +22,7 @@ export const obtainAllFocusableElements = (
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'
if (node.disabled || node.hidden || isHiddenInput)
return NodeFilter.FILTER_SKIP
return node.tabIndex >= 0
return node.tabIndex >= 0 || node === document.activeElement
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP
},

View File

@@ -21,128 +21,126 @@
:trapped="visible"
:focus-trap-el="rootRef"
:focus-start-el="focusStartRef"
@release-requested="onCloseRequested"
>
<template #default="{ handleKeydown }">
<div
ref="rootRef"
:class="[
ns.b(),
customClass,
ns.is('draggable', draggable),
{ [ns.m('center')]: center },
]"
:style="customStyle"
tabindex="-1"
@click.stop=""
>
<div
ref="rootRef"
:class="[
ns.b(),
customClass,
ns.is('draggable', draggable),
{ [ns.m('center')]: center },
]"
:style="customStyle"
tabindex="-1"
@click.stop=""
@keydown="handleKeydown"
v-if="title !== null && title !== undefined"
ref="headerRef"
:class="ns.e('header')"
>
<div
v-if="title !== null && title !== undefined"
ref="headerRef"
:class="ns.e('header')"
<div :class="ns.e('title')">
<el-icon
v-if="iconComponent && center"
:class="[ns.e('status'), typeClass]"
>
<component :is="iconComponent" />
</el-icon>
<span>{{ title }}</span>
</div>
<button
v-if="showClose"
type="button"
:class="ns.e('headerbtn')"
:aria-label="t('el.messagebox.close')"
@click="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
@keydown.prevent.enter="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
>
<div :class="ns.e('title')">
<el-icon
v-if="iconComponent && center"
:class="[ns.e('status'), typeClass]"
>
<component :is="iconComponent" />
</el-icon>
<span>{{ title }}</span>
</div>
<button
v-if="showClose"
type="button"
:class="ns.e('headerbtn')"
:aria-label="t('el.messagebox.close')"
@click="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
@keydown.prevent.enter="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
<el-icon :class="ns.e('close')">
<close />
</el-icon>
</button>
</div>
<div :id="contentId" :class="ns.e('content')">
<div :class="ns.e('container')">
<el-icon
v-if="iconComponent && !center && hasMessage"
:class="[ns.e('status'), typeClass]"
>
<el-icon :class="ns.e('close')">
<close />
</el-icon>
</button>
</div>
<div :id="contentId" :class="ns.e('content')">
<div :class="ns.e('container')">
<el-icon
v-if="iconComponent && !center && hasMessage"
:class="[ns.e('status'), typeClass]"
>
<component :is="iconComponent" />
</el-icon>
<div v-if="hasMessage" :class="ns.e('message')">
<slot>
<component
:is="showInput ? 'label' : 'p'"
v-if="!dangerouslyUseHTMLString"
:for="showInput ? inputId : undefined"
>
{{ !dangerouslyUseHTMLString ? message : '' }}
</component>
<component
:is="showInput ? 'label' : 'p'"
v-else
:for="showInput ? inputId : undefined"
v-html="message"
/>
</slot>
</div>
</div>
<div v-show="showInput" :class="ns.e('input')">
<el-input
:id="inputId"
ref="inputRef"
v-model="inputValue"
:type="inputType"
:placeholder="inputPlaceholder"
:aria-invalid="validateError"
:class="{ invalid: validateError }"
@keydown.enter="handleInputEnter"
/>
<div
:class="ns.e('errormsg')"
:style="{
visibility: !!editorErrorMessage ? 'visible' : 'hidden',
}"
>
{{ editorErrorMessage }}
</div>
<component :is="iconComponent" />
</el-icon>
<div v-if="hasMessage" :class="ns.e('message')">
<slot>
<component
:is="showInput ? 'label' : 'p'"
v-if="!dangerouslyUseHTMLString"
:for="showInput ? inputId : undefined"
>
{{ !dangerouslyUseHTMLString ? message : '' }}
</component>
<component
:is="showInput ? 'label' : 'p'"
v-else
:for="showInput ? inputId : undefined"
v-html="message"
/>
</slot>
</div>
</div>
<div :class="ns.e('btns')">
<el-button
v-if="showCancelButton"
:loading="cancelButtonLoading"
:class="[cancelButtonClass]"
:round="roundButton"
:size="btnSize"
@click="handleAction('cancel')"
@keydown.prevent.enter="handleAction('cancel')"
<div v-show="showInput" :class="ns.e('input')">
<el-input
:id="inputId"
ref="inputRef"
v-model="inputValue"
:type="inputType"
:placeholder="inputPlaceholder"
:aria-invalid="validateError"
:class="{ invalid: validateError }"
@keydown.enter="handleInputEnter"
/>
<div
:class="ns.e('errormsg')"
:style="{
visibility: !!editorErrorMessage ? 'visible' : 'hidden',
}"
>
{{ cancelButtonText || t('el.messagebox.cancel') }}
</el-button>
<el-button
v-show="showConfirmButton"
ref="confirmRef"
type="primary"
:loading="confirmButtonLoading"
:class="[confirmButtonClasses]"
:round="roundButton"
:disabled="confirmButtonDisabled"
:size="btnSize"
@click="handleAction('confirm')"
@keydown.prevent.enter="handleAction('confirm')"
>
{{ confirmButtonText || t('el.messagebox.confirm') }}
</el-button>
{{ editorErrorMessage }}
</div>
</div>
</div>
</template>
<div :class="ns.e('btns')">
<el-button
v-if="showCancelButton"
:loading="cancelButtonLoading"
:class="[cancelButtonClass]"
:round="roundButton"
:size="btnSize"
@click="handleAction('cancel')"
@keydown.prevent.enter="handleAction('cancel')"
>
{{ cancelButtonText || t('el.messagebox.cancel') }}
</el-button>
<el-button
v-show="showConfirmButton"
ref="confirmRef"
type="primary"
:loading="confirmButtonLoading"
:class="[confirmButtonClasses]"
:round="roundButton"
:disabled="confirmButtonDisabled"
:size="btnSize"
@click="handleAction('confirm')"
@keydown.prevent.enter="handleAction('confirm')"
>
{{ confirmButtonText || t('el.messagebox.confirm') }}
</el-button>
</div>
</div>
</el-focus-trap>
</div>
</el-overlay>
@@ -167,9 +165,7 @@ import {
useId,
useLocale,
useLockscreen,
useModal,
useNamespace,
usePreventGlobal,
useRestoreActive,
useSameTarget,
useSize,
@@ -184,7 +180,6 @@ import {
off,
on,
} from '@element-plus/utils'
import { EVENT_CODE } from '@element-plus/constants'
import { ElIcon } from '@element-plus/components/icon'
import ElFocusTrap from '@element-plus/components/focus-trap'
@@ -457,19 +452,10 @@ export default defineComponent({
// props.beforeClose method to make a intermediate state by callout a message box
// for some verification or alerting. then if we allow global event liek this
// to dispatch, it could callout another message box.
if (props.closeOnPressEscape) {
useModal(
{
handleClose,
},
visible
)
} else {
usePreventGlobal(
visible,
'keydown',
(e: KeyboardEvent) => e.code === EVENT_CODE.esc
)
const onCloseRequested = () => {
if (props.closeOnPressEscape) {
handleClose()
}
}
// locks the screen to prevent scroll
@@ -499,6 +485,7 @@ export default defineComponent({
confirmRef,
doClose, // for outside usage
handleClose, // for out side usage
onCloseRequested,
handleWrapperClick,
handleInputEnter,
handleAction,

View File

@@ -1,5 +1,5 @@
import { nextTick, ref } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POPPER_INJECTION_KEY } from '@element-plus/tokens'
import ElContent from '../src/content.vue'
@@ -12,7 +12,7 @@ const popperInjection = {
}
const mountContent = (props = {}) =>
shallowMount(ElContent, {
mount(ElContent, {
props,
slots: {
default: () => AXIOM,

View File

@@ -55,6 +55,14 @@ export const usePopperContentProps = buildProps({
default: true,
},
pure: Boolean,
focusOnShow: {
type: Boolean,
default: false,
},
trapping: {
type: Boolean,
default: false,
},
popperClass: {
type: definePropType<ClassType>([String, Array, Object]),
},
@@ -72,9 +80,18 @@ export const usePopperContentProps = buildProps({
type: String,
default: undefined,
},
virtualTriggering: Boolean,
zIndex: Number,
} as const)
export const usePopperContentEmits = [
'mouseenter',
'mouseleave',
'focus',
'blur',
'close',
]
export type UsePopperContentProps = ExtractPropTypes<
typeof usePopperContentProps
>

View File

@@ -10,7 +10,19 @@
@mouseenter="(e) => $emit('mouseenter', e)"
@mouseleave="(e) => $emit('mouseleave', e)"
>
<slot />
<el-focus-trap
:trapped="trapped"
:trap-on-focus-in="true"
:focus-trap-el="popperContentRef"
:focus-start-el="focusStartRef"
@focus-after-trapped="onFocusAfterTrapped"
@focus-after-released="onFocusAfterReleased"
@focusin="onFocusInTrap"
@focusout-prevented="onFocusoutPrevented"
@release-requested="onReleaseRequested"
>
<slot />
</el-focus-trap>
</div>
</template>
@@ -18,13 +30,14 @@
import { computed, inject, onMounted, provide, ref, unref, watch } from 'vue'
import { NOOP } from '@vue/shared'
import { createPopper } from '@popperjs/core'
import ElFocusTrap from '@element-plus/components/focus-trap'
import { useNamespace, useZIndex } from '@element-plus/hooks'
import {
POPPER_CONTENT_INJECTION_KEY,
POPPER_INJECTION_KEY,
formItemContextKey,
} from '@element-plus/tokens'
import { usePopperContentProps } from './content'
import { usePopperContentEmits, usePopperContentProps } from './content'
import { buildPopperOptions, unwrapMeasurableEl } from './utils'
import type { WatchStopHandle } from 'vue'
@@ -33,7 +46,7 @@ defineOptions({
name: 'ElPopperContent',
})
defineEmits(['mouseenter', 'mouseleave'])
const emit = defineEmits(usePopperContentEmits)
const props = defineProps(usePopperContentProps)
@@ -45,6 +58,7 @@ const formItemContext = inject(formItemContextKey, undefined)
const { nextZIndex } = useZIndex()
const ns = useNamespace('popper')
const popperContentRef = ref<HTMLElement>()
const focusStartRef = ref<string | HTMLElement>('first')
const arrowRef = ref<HTMLElement>()
const arrowOffset = ref<number>()
provide(POPPER_CONTENT_INJECTION_KEY, {
@@ -64,7 +78,8 @@ if (
})
}
const contentZIndex = ref(props.zIndex || nextZIndex())
const contentZIndex = ref<number>(props.zIndex || nextZIndex())
const trapped = ref<boolean>(false)
const computedReference = computed(
() => unwrapMeasurableEl(props.referenceEl) || unref(triggerRef)
@@ -106,6 +121,43 @@ const togglePopperAlive = () => {
modifiers: [...(options.modifiers || []), monitorable],
}))
updatePopper(false)
if (props.visible && props.focusOnShow) {
trapped.value = true
} else if (props.visible === false) {
trapped.value = false
}
}
const onFocusAfterTrapped = () => {
emit('focus')
}
const onFocusAfterReleased = () => {
focusStartRef.value = 'first'
emit('blur')
}
const onFocusInTrap = (event: FocusEvent) => {
if (props.visible && !trapped.value) {
if (event.relatedTarget) {
;(event.relatedTarget as HTMLElement)?.focus()
}
if (event.target) {
focusStartRef.value = event.target as typeof focusStartRef.value
}
trapped.value = true
}
}
const onFocusoutPrevented = () => {
if (!props.trapping) {
trapped.value = false
}
}
const onReleaseRequested = () => {
trapped.value = false
emit('close')
}
onMounted(() => {

View File

@@ -155,7 +155,7 @@
@keydown="resetInputState"
@keydown.down.prevent="navigateOptions('next')"
@keydown.up.prevent="navigateOptions('prev')"
@keydown.esc.stop.prevent="visible = false"
@keydown.esc="handleKeydownEscape"
@keydown.enter.stop.prevent="selectOption"
@keydown.delete="deletePrevTag"
@keydown.tab="visible = false"
@@ -189,7 +189,7 @@
@keydown.down.stop.prevent="navigateOptions('next')"
@keydown.up.stop.prevent="navigateOptions('prev')"
@keydown.enter.stop.prevent="selectOption"
@keydown.esc.stop.prevent="visible = false"
@keydown.esc="handleKeydownEscape"
@keydown.tab="visible = false"
@mouseenter="inputHovering = true"
@mouseleave="inputHovering = false"
@@ -435,6 +435,7 @@ export default defineComponent({
handleBlur,
handleClearClick,
handleClose,
handleKeydownEscape,
toggleMenu,
selectOption,
getValueKey,
@@ -608,6 +609,7 @@ export default defineComponent({
handleBlur,
handleClearClick,
handleClose,
handleKeydownEscape,
toggleMenu,
selectOption,
getValueKey,

View File

@@ -761,6 +761,14 @@ export const useSelect = (props, states: States, ctx) => {
states.visible = false
}
const handleKeydownEscape = (event: KeyboardEvent) => {
if (states.visible) {
event.preventDefault()
event.stopPropagation()
states.visible = false
}
}
const toggleMenu = () => {
if (props.automaticDropdown) return
if (!selectDisabled.value) {
@@ -860,6 +868,7 @@ export const useSelect = (props, states: States, ctx) => {
handleBlur,
handleClearClick,
handleClose,
handleKeydownEscape,
toggleMenu,
selectOption,
getValueKey,

View File

@@ -32,6 +32,8 @@
:z-index="zIndex"
@mouseenter="onContentEnter"
@mouseleave="onContentLeave"
@blur="onBlur"
@close="onClose"
>
<!-- Workaround bug #6378 -->
<template v-if="!destroyed">
@@ -55,7 +57,6 @@ import {
import { onClickOutside } from '@vueuse/core'
import { ElPopperContent } from '@element-plus/components/popper'
import { composeEventHandlers } from '@element-plus/utils'
import { useEscapeKeydown } from '@element-plus/hooks'
import { useTooltipContentProps } from './tooltip'
import { TOOLTIP_INJECTION_KEY } from './tokens'
@@ -110,8 +111,6 @@ export default defineComponent({
const ariaHidden = computed(() => !unref(open))
useEscapeKeydown(onClose)
const onTransitionLeave = () => {
onHide()
}
@@ -145,6 +144,12 @@ export default defineComponent({
onShow()
}
const onBlur = () => {
if (!props.virtualTriggering) {
onClose()
}
}
let stopHandle: ReturnType<typeof onClickOutside>
watch(
@@ -183,6 +188,7 @@ export default defineComponent({
destroyed,
shouldRender,
shouldShow,
onClose,
open,
onAfterShow,
onBeforeEnter,
@@ -190,6 +196,7 @@ export default defineComponent({
onContentEnter,
onContentLeave,
onTransitionLeave,
onBlur,
}
},
})

View File

@@ -32,6 +32,7 @@
:strategy="strategy"
:teleported="teleported"
:transition="transition"
:virtual-triggering="virtualTriggering"
:z-index="zIndex"
:append-to="appendTo"
>

View File

@@ -1,19 +1,32 @@
import { onBeforeUnmount, onMounted } from 'vue'
import { off, on } from '@element-plus/utils'
import { isClient } from '@vueuse/core'
import { EVENT_CODE } from '@element-plus/constants'
export const useEscapeKeydown = (handler?: (e: KeyboardEvent) => void) => {
let registeredEscapeHandlers: ((e: KeyboardEvent) => void)[] = []
export const useEscapeKeydown = (handler: (e: KeyboardEvent) => void) => {
const cachedHandler = (e: Event) => {
const event = e as KeyboardEvent
if (event.key === EVENT_CODE.esc) {
handler?.(event)
registeredEscapeHandlers.forEach((registeredHandler) =>
registeredHandler(event)
)
}
}
onMounted(() => {
on(document, 'keydown', cachedHandler)
if (registeredEscapeHandlers.length === 0) {
document.addEventListener('keydown', cachedHandler)
}
if (isClient) registeredEscapeHandlers.push(handler)
})
onBeforeUnmount(() => {
off(document, 'keydown', cachedHandler)
registeredEscapeHandlers = registeredEscapeHandlers.filter(
(registeredHandler) => registeredHandler !== handler
)
if (registeredEscapeHandlers.length === 0) {
if (isClient) document.removeEventListener('keydown', cachedHandler)
}
})
}