diff --git a/packages/components/focus-trap/src/focus-trap.vue b/packages/components/focus-trap/src/focus-trap.vue index a913ca6b55..677cb27cfb 100644 --- a/packages/components/focus-trap/src/focus-trap.vue +++ b/packages/components/focus-trap/src/focus-trap.vue @@ -17,11 +17,14 @@ import { EVENT_CODE } from '@element-plus/constants' import { useEscapeKeydown } from '@element-plus/hooks' import { isString } from '@element-plus/utils' import { + createFocusOutPreventedEvent, focusFirstDescendant, focusableStack, getEdges, + isFocusCausedByUserEvent, obtainAllFocusableElements, tryFocus, + useFocusReason, } from './utils' import { FOCUS_AFTER_RELEASED, @@ -60,6 +63,8 @@ export default defineComponent({ let lastFocusBeforeTrapped: HTMLElement | null let lastFocusAfterTrapped: HTMLElement | null + const { focusReason } = useFocusReason() + useEscapeKeydown((event) => { if (props.trapped && !focusLayer.paused) { emit('release-requested', event) @@ -92,21 +97,36 @@ export default defineComponent({ const isTabbable = first && last if (!isTabbable) { if (currentFocusingEl === container) { - e.preventDefault() - emit('focusout-prevented') + const focusoutPreventedEvent = createFocusOutPreventedEvent({ + focusReason: focusReason.value, + }) + emit('focusout-prevented', focusoutPreventedEvent) + if (!focusoutPreventedEvent.defaultPrevented) { + e.preventDefault() + } } } else { if (!shiftKey && currentFocusingEl === last) { - e.preventDefault() - if (loop) tryFocus(first, true) - emit('focusout-prevented') + const focusoutPreventedEvent = createFocusOutPreventedEvent({ + focusReason: focusReason.value, + }) + emit('focusout-prevented', focusoutPreventedEvent) + if (!focusoutPreventedEvent.defaultPrevented) { + e.preventDefault() + if (loop) tryFocus(first, true) + } } else if ( shiftKey && [first, container].includes(currentFocusingEl as HTMLElement) ) { - e.preventDefault() - if (loop) tryFocus(last, true) - emit('focusout-prevented') + const focusoutPreventedEvent = createFocusOutPreventedEvent({ + focusReason: focusReason.value, + }) + emit('focusout-prevented', focusoutPreventedEvent) + if (!focusoutPreventedEvent.defaultPrevented) { + e.preventDefault() + if (loop) tryFocus(last, true) + } } } } @@ -145,12 +165,22 @@ export default defineComponent({ } const releaseOnFocus = (e: Event) => emit(ON_RELEASE_FOCUS_EVT, e) - const onFocusIn = (e: Event) => { + const onFocusIn = (e: FocusEvent) => { const trapContainer = unref(forwardRef) if (!trapContainer) return const target = e.target as HTMLElement | null + const relatedTarget = e.relatedTarget as HTMLElement | null const isFocusedInTrap = target && trapContainer.contains(target) + + if (!props.trapped) { + const isPrevFocusedInTrap = + relatedTarget && trapContainer.contains(relatedTarget) + if (!isPrevFocusedInTrap) { + lastFocusBeforeTrapped = relatedTarget + } + } + if (isFocusedInTrap) emit('focusin', e) if (focusLayer.paused) return @@ -176,7 +206,13 @@ export default defineComponent({ // And only reclaim focus if it should currently be trapping setTimeout(() => { if (!focusLayer.paused && props.trapped) { - tryFocus(lastFocusAfterTrapped, true) + const focusoutPreventedEvent = createFocusOutPreventedEvent({ + focusReason: focusReason.value, + }) + emit('focusout-prevented', focusoutPreventedEvent) + if (!focusoutPreventedEvent.defaultPrevented) { + tryFocus(lastFocusAfterTrapped, true) + } } }, 0) } @@ -193,7 +229,11 @@ export default defineComponent({ const trapContainer = unref(forwardRef) if (trapContainer) { focusableStack.push(focusLayer) - const prevFocusedElement = document.activeElement + const prevFocusedElement = trapContainer.contains( + document.activeElement + ) + ? lastFocusBeforeTrapped + : document.activeElement lastFocusBeforeTrapped = prevFocusedElement as HTMLElement | null const isPrevFocusContained = trapContainer.contains(prevFocusedElement) if (!isPrevFocusContained) { @@ -236,14 +276,19 @@ export default defineComponent({ if (trapContainer) { trapContainer.removeEventListener(FOCUS_AFTER_TRAPPED, trapOnFocus) - const releasedEvent = new Event( - FOCUS_AFTER_RELEASED, - FOCUS_AFTER_TRAPPED_OPTS - ) + const releasedEvent = new CustomEvent(FOCUS_AFTER_RELEASED, { + ...FOCUS_AFTER_TRAPPED_OPTS, + detail: { + focusReason: focusReason.value, + }, + }) trapContainer.addEventListener(FOCUS_AFTER_RELEASED, releaseOnFocus) trapContainer.dispatchEvent(releasedEvent) - if (!releasedEvent.defaultPrevented) { + if ( + !releasedEvent.defaultPrevented && + (focusReason.value == 'keyboard' || !isFocusCausedByUserEvent()) + ) { tryFocus(lastFocusBeforeTrapped ?? document.body, true) } diff --git a/packages/components/focus-trap/src/tokens.ts b/packages/components/focus-trap/src/tokens.ts index 48793ccd20..0f4aff1a28 100644 --- a/packages/components/focus-trap/src/tokens.ts +++ b/packages/components/focus-trap/src/tokens.ts @@ -2,10 +2,15 @@ import type { InjectionKey, Ref } from 'vue' export const FOCUS_AFTER_TRAPPED = 'focus-trap.focus-after-trapped' export const FOCUS_AFTER_RELEASED = 'focus-trap.focus-after-released' +export const FOCUSOUT_PREVENTED = 'focus-trap.focusout-prevented' export const FOCUS_AFTER_TRAPPED_OPTS: EventInit = { cancelable: true, bubbles: false, } +export const FOCUSOUT_PREVENTED_OPTS: EventInit = { + cancelable: true, + bubbles: false, +} export const ON_TRAP_FOCUS_EVT = 'focusAfterTrapped' export const ON_RELEASE_FOCUS_EVT = 'focusAfterReleased' diff --git a/packages/components/focus-trap/src/utils.ts b/packages/components/focus-trap/src/utils.ts index 456c02a571..9f7de1341b 100644 --- a/packages/components/focus-trap/src/utils.ts +++ b/packages/components/focus-trap/src/utils.ts @@ -1,3 +1,11 @@ +import { onBeforeUnmount, onMounted, ref } from 'vue' +import { FOCUSOUT_PREVENTED, FOCUSOUT_PREVENTED_OPTS } from './tokens' + +const focusReason = ref<'pointer' | 'keyboard'>() +const lastUserFocusTimestamp = ref(0) +const lastAutomatedFocusTimestamp = ref(0) +let focusReasonUserCount = 0 + export type FocusLayer = { paused: boolean pause: () => void @@ -74,6 +82,7 @@ export const tryFocus = ( if (element && element.focus) { const prevFocusedElement = document.activeElement element.focus({ preventScroll: true }) + lastAutomatedFocusTimestamp.value = window.performance.now() if ( element !== prevFocusedElement && isSelectable(element) && @@ -132,3 +141,56 @@ export const focusFirstDescendant = ( } export const focusableStack = createFocusableStack() + +export const isFocusCausedByUserEvent = (): boolean => { + return lastUserFocusTimestamp.value > lastAutomatedFocusTimestamp.value +} + +const notifyFocusReasonPointer = () => { + focusReason.value = 'pointer' + lastUserFocusTimestamp.value = window.performance.now() +} + +const notifyFocusReasonKeydown = () => { + focusReason.value = 'keyboard' + lastUserFocusTimestamp.value = window.performance.now() +} + +export const useFocusReason = (): { + focusReason: typeof focusReason + lastUserFocusTimestamp: typeof lastUserFocusTimestamp + lastAutomatedFocusTimestamp: typeof lastAutomatedFocusTimestamp +} => { + onMounted(() => { + if (focusReasonUserCount === 0) { + document.addEventListener('mousedown', notifyFocusReasonPointer) + document.addEventListener('touchstart', notifyFocusReasonPointer) + document.addEventListener('keydown', notifyFocusReasonKeydown) + } + focusReasonUserCount++ + }) + + onBeforeUnmount(() => { + focusReasonUserCount-- + if (focusReasonUserCount <= 0) { + document.removeEventListener('mousedown', notifyFocusReasonPointer) + document.removeEventListener('touchstart', notifyFocusReasonPointer) + document.removeEventListener('keydown', notifyFocusReasonKeydown) + } + }) + + return { + focusReason, + lastUserFocusTimestamp, + lastAutomatedFocusTimestamp, + } +} + +export const createFocusOutPreventedEvent = ( + detail: CustomEventInit['detail'] +) => { + return new CustomEvent(FOCUSOUT_PREVENTED, { + ...FOCUSOUT_PREVENTED_OPTS, + detail, + }) +} diff --git a/packages/components/popper/src/content.vue b/packages/components/popper/src/content.vue index cc5b9b2aaa..41fb65dc59 100644 --- a/packages/components/popper/src/content.vue +++ b/packages/components/popper/src/content.vue @@ -147,9 +147,11 @@ const onFocusAfterTrapped = () => { emit('focus') } -const onFocusAfterReleased = () => { - focusStartRef.value = 'first' - emit('blur') +const onFocusAfterReleased = (event: CustomEvent) => { + if (event.detail?.focusReason !== 'pointer') { + focusStartRef.value = 'first' + emit('blur') + } } const onFocusInTrap = (event: FocusEvent) => { @@ -158,14 +160,14 @@ const onFocusInTrap = (event: FocusEvent) => { focusStartRef.value = event.target as typeof focusStartRef.value } trapped.value = true - if (event.relatedTarget) { - ;(event.relatedTarget as HTMLElement)?.focus() - } } } -const onFocusoutPrevented = () => { +const onFocusoutPrevented = (event: CustomEvent) => { if (!props.trapping) { + if (event.detail.focusReason === 'pointer') { + event.preventDefault() + } trapped.value = false } } diff --git a/packages/components/select/src/useSelect.ts b/packages/components/select/src/useSelect.ts index c58ad60079..b59b676b95 100644 --- a/packages/components/select/src/useSelect.ts +++ b/packages/components/select/src/useSelect.ts @@ -809,7 +809,9 @@ export const useSelect = (props, states: States, ctx) => { if (states.menuVisibleOnFocus) { states.menuVisibleOnFocus = false } else { - states.visible = !states.visible + if (!tooltipRef.value || !tooltipRef.value.isFocusInsideContent()) { + states.visible = !states.visible + } } if (states.visible) { ;(input.value || reference.value)?.focus() diff --git a/packages/components/time-picker/__tests__/time-picker.test.tsx b/packages/components/time-picker/__tests__/time-picker.test.tsx index 9102258fee..f74a406ffb 100644 --- a/packages/components/time-picker/__tests__/time-picker.test.tsx +++ b/packages/components/time-picker/__tests__/time-picker.test.tsx @@ -845,8 +845,12 @@ describe('TimePicker(range)', () => { await nextTick() const picker = findPicker() const input = findInput() - picker.vm.onPick('', false) + input.vm.$emit('input', 'a') await rAF() + expect(document.querySelector('.el-time-panel')).toBeTruthy() + picker.vm.onPick('', false) + await rAF() // Picker triggers popup close, event propagation + await rAF() // Focus trap recognizes focusout event, and propagation expect(document.activeElement).toBe(wrapper.find('input').element) expect(document.querySelector('.el-time-panel')).toBeFalsy() input.vm.$emit('input', 'a') diff --git a/packages/components/time-picker/src/common/picker.vue b/packages/components/time-picker/src/common/picker.vue index 8758c36a7e..e006bec58c 100644 --- a/packages/components/time-picker/src/common/picker.vue +++ b/packages/components/time-picker/src/common/picker.vue @@ -169,7 +169,7 @@