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:
Giwayume
2022-10-13 02:42:27 -04:00
committed by GitHub
parent 2985bd5188
commit 8f1285f880
7 changed files with 154 additions and 29 deletions

View File

@@ -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)
}

View File

@@ -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'

View File

@@ -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,
})
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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')

View File

@@ -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)
}