mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
fix(components): [time-picker] fix popup not showing (#9941)
* fix(components): [time-picker] fix popup not showing * fix(components): [focus-trap] track last active element automatically * fix(components): linting * fix(components): add preventDefault to focusout-prevented * fix(components): sSR and typecheck * fix(components): focus trap attach document events in mount * fix(components): remove document undefined check * fix(components): [select] fix click scrollbar closes popup * fix(components): allow focus-trap to refocus after pick Co-authored-by: OpenGraphica <opengraphica@gmail.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<number>(0)
|
||||
const lastAutomatedFocusTimestamp = ref<number>(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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, nextTick, provide, ref, unref, watch } from 'vue'
|
||||
import { isEqual, isNil } from 'lodash-unified'
|
||||
import { isEqual } from 'lodash-unified'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import {
|
||||
useFormItem,
|
||||
@@ -243,7 +243,11 @@ watch(pickerVisible, (val) => {
|
||||
emitChange(props.modelValue)
|
||||
})
|
||||
} else {
|
||||
valueOnOpen.value = props.modelValue
|
||||
nextTick(() => {
|
||||
if (val) {
|
||||
valueOnOpen.value = props.modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const emitChange = (
|
||||
@@ -304,7 +308,7 @@ const focusOnInputBox = () => {
|
||||
|
||||
const onPick = (date: any = '', visible = false) => {
|
||||
if (!visible) {
|
||||
focusOnInputBox()
|
||||
ignoreFocusEvent = true
|
||||
}
|
||||
pickerVisible.value = visible
|
||||
let result
|
||||
@@ -334,6 +338,7 @@ const onKeydownPopperContent = (event: KeyboardEvent) => {
|
||||
|
||||
const onHide = () => {
|
||||
pickerActualVisible.value = false
|
||||
pickerVisible.value = false
|
||||
ignoreFocusEvent = false
|
||||
emit('visible-change', false)
|
||||
}
|
||||
@@ -367,7 +372,7 @@ const handleFocusInput = (e?: FocusEvent) => {
|
||||
) {
|
||||
return
|
||||
}
|
||||
pickerVisible.value = isNil(e?.relatedTarget)
|
||||
pickerVisible.value = true
|
||||
emit('focus', e)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user