mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
:strategy="strategy"
|
||||
:teleported="teleported"
|
||||
:transition="transition"
|
||||
:virtual-triggering="virtualTriggering"
|
||||
:z-index="zIndex"
|
||||
:append-to="appendTo"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user