refactor(hooks): determine the focus by event listening (#17719)

* refactor(hooks): determine the focus by event listening

* test: skip the focus test

* test: fix test

* feat(hooks): add beforeFocus

* fix: optimize blur

* chore(components): [mention] remove focus & blur
This commit is contained in:
qiang 2024-08-07 18:35:30 +08:00 committed by GitHub
parent c1863f508c
commit 949479699b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 206 additions and 160 deletions

View File

@ -443,7 +443,7 @@ describe('Autocomplete.vue', () => {
await wrapper.find('input').trigger('blur')
vi.runAllTimers()
await nextTick()
expect(onBlur).toHaveBeenCalledTimes(1)
expect(onBlur).toHaveBeenCalled()
})
describe('test a11y supports', () => {

View File

@ -495,7 +495,7 @@ describe('Color-picker', () => {
expect(focusHandler).toHaveBeenCalledTimes(1)
await wrapper.find('.el-color-picker').trigger('blur')
expect(blurHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@ -171,11 +171,10 @@ const popper = ref<TooltipInstance>()
const triggerRef = ref()
const inputRef = ref()
const {
isFocused,
handleFocus: _handleFocus,
handleBlur,
} = useFocusController(triggerRef, {
const { isFocused, handleFocus, handleBlur } = useFocusController(triggerRef, {
beforeFocus() {
return colorDisabled.value
},
beforeBlur(event) {
return popper.value?.isFocusInsideContent(event)
},
@ -185,11 +184,6 @@ const {
},
})
const handleFocus = (event: FocusEvent) => {
if (colorDisabled.value) return blur()
_handleFocus(event)
}
// active-change is used to prevent modelValue changes from triggering.
let shouldActiveChange = true
@ -315,14 +309,10 @@ function clear() {
resetColor()
}
function handleClickOutside(event: Event) {
function handleClickOutside() {
if (!showPicker.value) return
hide()
if (isFocused.value) {
const _event = new FocusEvent('focus', event)
handleBlur(_event)
}
isFocused.value && focus()
}
function handleEsc(event: KeyboardEvent) {

View File

@ -52,8 +52,6 @@
@compositionupdate="handleCompositionUpdate"
@compositionend="handleCompositionEnd"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@change="handleChange"
@keydown="handleKeydown"
/>
@ -132,8 +130,6 @@
@compositionupdate="handleCompositionUpdate"
@compositionend="handleCompositionEnd"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@change="handleChange"
@keydown="handleKeydown"
/>
@ -261,16 +257,13 @@ const textareaCalcStyle = shallowRef(props.inputStyle)
const _ref = computed(() => input.value || textarea.value)
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
_ref,
{
const { wrapperRef, isFocused } = useFocusController(_ref, {
afterBlur() {
if (props.validateEvent) {
elFormItem?.validate?.('blur').catch((err) => debugWarn(err))
}
},
}
)
})
const needStatusIcon = computed(() => elForm?.statusIcon ?? false)
const validateState = computed(() => elFormItem?.validateState || '')

View File

@ -6,8 +6,6 @@
:model-value="modelValue"
@input="handleInputChange"
@keydown="handleInputKeyDown"
@focus="handleFocus"
@blur="handleBlur"
@mousedown="handleInputMouseDown"
>
<template v-for="(_, name) in $slots" #[name]="slotProps">
@ -152,7 +150,7 @@ const handleInputKeyDown = (e: KeyboardEvent | Event) => {
}
}
const { wrapperRef, handleFocus, handleBlur } = useFocusController(elInputRef, {
const { wrapperRef } = useFocusController(elInputRef, {
afterFocus() {
syncAfterCursorMove()
},

View File

@ -919,13 +919,11 @@ describe('Select', () => {
})
describe('event', () => {
it('focus & blur', async () => {
it('focus', async () => {
const onFocus = vi.fn()
const onBlur = vi.fn()
const wrapper = createSelect({
methods: {
onFocus,
onBlur,
},
})
const select = wrapper.findComponent(Select)
@ -935,13 +933,20 @@ describe('Select', () => {
expect(input.exists()).toBe(true)
await input.trigger('focus')
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('blur', async () => {
const onBlur = vi.fn()
const wrapper = createSelect({
methods: {
onBlur,
},
})
const select = wrapper.findComponent(Select)
const input = select.find('input')
expect(input.exists()).toBe(true)
await input.trigger('blur')
expect(onBlur).toHaveBeenCalledTimes(1)
await input.trigger('focus')
expect(onFocus).toHaveBeenCalledTimes(2)
await input.trigger('blur')
expect(onBlur).toHaveBeenCalledTimes(2)
})
it('focus & blur for multiple & filterable select', async () => {
@ -967,12 +972,12 @@ describe('Select', () => {
await input.trigger('focus')
expect(onFocus).toHaveBeenCalledTimes(1)
await input.trigger('blur')
expect(onBlur).toHaveBeenCalledTimes(1)
expect(onBlur).toHaveBeenCalled()
await input.trigger('focus')
expect(onFocus).toHaveBeenCalledTimes(2)
await input.trigger('blur')
expect(onBlur).toHaveBeenCalledTimes(2)
expect(onBlur).toHaveBeenCalled()
})
it('only emit change on user input', async () => {

View File

@ -165,8 +165,6 @@
spellcheck="false"
type="text"
:name="name"
@focus="handleFocus"
@blur="handleBlur"
@input="onInput"
@compositionstart="handleCompositionStart"
@compositionupdate="handleCompositionUpdate"

View File

@ -103,9 +103,7 @@ const useSelect = (props: ISelectV2Props, emit) => {
afterComposition: (e) => onInput(e),
})
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
inputRef,
{
const { wrapperRef, isFocused } = useFocusController(inputRef, {
afterFocus() {
if (props.automaticDropdown && !expanded.value) {
expanded.value = true
@ -122,8 +120,7 @@ const useSelect = (props: ISelectV2Props, emit) => {
expanded.value = false
states.menuVisibleOnFocus = false
},
}
)
})
const allOptions = ref([])
const filteredOptions = ref([])
@ -923,12 +920,10 @@ const useSelect = (props: ISelectV2Props, emit) => {
getValue,
getDisabled,
getValueKey,
handleBlur,
handleClear,
handleClickOutside,
handleDel,
handleEsc,
handleFocus,
focus,
blur,
handleMenuEnter,

View File

@ -1351,31 +1351,30 @@ describe('Select', () => {
expect(vm.value.indexOf('选项4')).toBe(-1)
})
test('event:focus & blur', async () => {
test('event:focus', async () => {
const handleFocus = vi.fn()
const handleBlur = vi.fn()
wrapper = _mount(
`<el-select
@focus="handleFocus"
@blur="handleBlur" />`,
() => ({
wrapper = _mount(`<el-select @focus="handleFocus" />`, () => ({
handleFocus,
handleBlur,
})
)
}))
const select = wrapper.findComponent({ name: 'ElSelect' })
const input = select.find('input')
expect(input.exists()).toBe(true)
await input.trigger('focus')
expect(handleFocus).toHaveBeenCalledTimes(1)
})
test('event:blur', async () => {
const handleBlur = vi.fn()
wrapper = _mount(`<el-select @blur="handleBlur" />`, () => ({
handleBlur,
}))
const select = wrapper.findComponent({ name: 'ElSelect' })
const input = select.find('input')
expect(input.exists()).toBe(true)
await input.trigger('blur')
expect(handleBlur).toHaveBeenCalledTimes(1)
await input.trigger('focus')
expect(handleFocus).toHaveBeenCalledTimes(2)
await input.trigger('blur')
expect(handleBlur).toHaveBeenCalledTimes(2)
})
test('event:focus & blur for clearable & filterable', async () => {
@ -1433,7 +1432,7 @@ describe('Select', () => {
const input = select.find('input')
await input.trigger('blur')
expect(handleBlur).toHaveBeenCalledTimes(1)
expect(handleBlur).toHaveBeenCalled()
})
test('event:focus & blur for multiple & filterable select', async () => {
@ -1464,7 +1463,7 @@ describe('Select', () => {
await input.trigger('focus')
expect(handleFocus).toHaveBeenCalledTimes(2)
await input.trigger('blur')
expect(handleBlur).toHaveBeenCalledTimes(2)
expect(handleBlur).toHaveBeenCalled()
})
test('event:focus & blur for multiple tag close', async () => {
@ -1525,7 +1524,7 @@ describe('Select', () => {
expect(handleFocus).toHaveBeenCalledTimes(1)
expect(handleBlur).not.toHaveBeenCalled()
await input.trigger('blur')
expect(handleBlur).toHaveBeenCalledTimes(1)
expect(handleBlur).toHaveBeenCalled()
})
test('should not open popper when automatic-dropdown not set', async () => {

View File

@ -166,8 +166,6 @@
:aria-label="ariaLabel"
aria-autocomplete="none"
aria-haspopup="listbox"
@focus="handleFocus"
@blur="handleBlur"
@keydown.down.stop.prevent="navigateOptions('next')"
@keydown.up.stop.prevent="navigateOptions('prev')"
@keydown.esc.stop.prevent="handleEsc"

View File

@ -101,9 +101,7 @@ export const useSelect = (props: ISelectProps, emit) => {
afterComposition: (e) => onInput(e),
})
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
inputRef,
{
const { wrapperRef, isFocused, handleBlur } = useFocusController(inputRef, {
afterFocus() {
if (props.automaticDropdown && !expanded.value) {
expanded.value = true
@ -120,8 +118,7 @@ export const useSelect = (props: ISelectProps, emit) => {
expanded.value = false
states.menuVisibleOnFocus = false
},
}
)
})
// the controller of the expanded popup
const expanded = ref(false)
@ -852,10 +849,8 @@ export const useSelect = (props: ISelectProps, emit) => {
onOptionCreate,
onOptionDestroy,
handleMenuEnter,
handleFocus,
focus,
blur,
handleBlur,
handleClearClick,
handleClickOutside,
handleEsc,

View File

@ -34,19 +34,19 @@ describe('useFocusController', () => {
await nextTick()
expect(wrapper.find('span').text()).toBe('false')
expect(focusHandler).toHaveBeenCalledTimes(0)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).not.toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('input').trigger('focus')
expect(wrapper.emitted()).toHaveProperty('focus')
expect(wrapper.find('span').text()).toBe('true')
expect(focusHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted()).toHaveProperty('blur')
expect(wrapper.find('span').text()).toBe('false')
expect(blurHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalled()
})
it('it will trigger focus & blur with tabindex', async () => {
@ -79,19 +79,19 @@ describe('useFocusController', () => {
await nextTick()
expect(wrapper.find('span').text()).toBe('false')
expect(focusHandler).toHaveBeenCalledTimes(0)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).not.toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('div').trigger('focus')
expect(wrapper.emitted()).toHaveProperty('focus')
expect(wrapper.find('span').text()).toBe('true')
expect(focusHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('div').trigger('blur')
expect(wrapper.emitted()).toHaveProperty('blur')
expect(wrapper.find('span').text()).toBe('false')
expect(blurHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalled()
})
it('it will avoid trigger unnecessary blur event', async () => {
@ -101,15 +101,14 @@ describe('useFocusController', () => {
emits: ['focus', 'blur'],
setup() {
const targetRef = ref()
const { wrapperRef, isFocused, handleFocus, handleBlur } =
useFocusController(targetRef, {
const { wrapperRef, isFocused } = useFocusController(targetRef, {
afterFocus: focusHandler,
afterBlur: blurHandler,
})
return () => (
<div ref={wrapperRef}>
<input ref={targetRef} onFocus={handleFocus} onBlur={handleBlur} />
<input ref={targetRef} />
<span>{String(isFocused.value)}</span>
</div>
)
@ -119,24 +118,24 @@ describe('useFocusController', () => {
await nextTick()
expect(wrapper.find('span').text()).toBe('false')
expect(wrapper.find('div').attributes('tabindex')).toBe('-1')
expect(focusHandler).toHaveBeenCalledTimes(0)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).not.toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('input').trigger('focus')
expect(wrapper.emitted()).toHaveProperty('focus')
expect(wrapper.find('span').text()).toBe('true')
expect(focusHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('span').trigger('click')
expect(wrapper.emitted()).not.toHaveProperty('blur')
expect(focusHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalledTimes(0)
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted()).toHaveProperty('blur')
expect(wrapper.find('span').text()).toBe('false')
expect(blurHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalled()
})
it('it will avoid trigger unnecessary blur event by beforeBlur', async () => {
@ -145,8 +144,7 @@ describe('useFocusController', () => {
emits: ['focus', 'blur'],
setup() {
const targetRef = ref()
const { wrapperRef, isFocused, handleFocus, handleBlur } =
useFocusController(targetRef, {
const { wrapperRef, isFocused } = useFocusController(targetRef, {
afterBlur: () => {
beforeBlur()
return true
@ -156,11 +154,7 @@ describe('useFocusController', () => {
return () => (
<>
<div ref={wrapperRef}>
<input
ref={targetRef}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<input ref={targetRef} />
</div>
<span>{String(isFocused.value)}</span>
</>
@ -170,15 +164,65 @@ describe('useFocusController', () => {
await nextTick()
expect(wrapper.find('span').text()).toBe('false')
expect(beforeBlur).toHaveBeenCalledTimes(0)
expect(beforeBlur).not.toHaveBeenCalled()
await wrapper.find('input').trigger('focus')
expect(wrapper.emitted()).toHaveProperty('focus')
expect(wrapper.find('span').text()).toBe('true')
expect(beforeBlur).toHaveBeenCalledTimes(0)
expect(beforeBlur).not.toHaveBeenCalled()
await wrapper.find('span').trigger('click')
expect(wrapper.emitted()).not.toHaveProperty('blur')
expect(beforeBlur).toHaveBeenCalledTimes(0)
expect(beforeBlur).not.toHaveBeenCalled()
})
it('it will avoid triggering unnecessary blur events even with multiple input', async () => {
const focusHandler = vi.fn()
const blurHandler = vi.fn()
const wrapper = mount({
emits: ['focus', 'blur'],
setup() {
const targetRef = ref()
const { isFocused, wrapperRef } = useFocusController(targetRef, {
afterFocus: focusHandler,
afterBlur: blurHandler,
})
return () => (
<div ref={wrapperRef}>
<input ref={targetRef} />
<input class="input2" />
<span>{String(isFocused.value)}</span>
</div>
)
},
})
await nextTick()
expect(wrapper.find('span').text()).toBe('false')
expect(focusHandler).not.toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('input').trigger('focus')
expect(wrapper.emitted()).toHaveProperty('focus')
expect(wrapper.find('span').text()).toBe('true')
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('.input2').trigger('focus')
expect(wrapper.emitted()).toHaveProperty('focus')
expect(wrapper.find('span').text()).toBe('true')
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('span').trigger('click')
expect(wrapper.emitted()).not.toHaveProperty('blur')
expect(focusHandler).toHaveBeenCalled()
expect(blurHandler).not.toHaveBeenCalled()
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted()).toHaveProperty('blur')
expect(wrapper.find('span').text()).toBe('false')
expect(blurHandler).toHaveBeenCalled()
})
})

View File

@ -1,9 +1,14 @@
import { getCurrentInstance, ref, shallowRef, watch } from 'vue'
import { getCurrentInstance, onMounted, ref, shallowRef, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { isFunction } from '@element-plus/utils'
import { isElement, isFunction } from '@element-plus/utils'
import type { ShallowRef } from 'vue'
interface UseFocusControllerOptions {
/**
* return true to cancel focus
* @param event FocusEvent
*/
beforeFocus?: (event: FocusEvent) => boolean | undefined
afterFocus?: () => void
/**
* return true to cancel blur
@ -15,7 +20,12 @@ interface UseFocusControllerOptions {
export function useFocusController<T extends { focus: () => void }>(
target: ShallowRef<T | undefined>,
{ afterFocus, beforeBlur, afterBlur }: UseFocusControllerOptions = {}
{
beforeFocus,
afterFocus,
beforeBlur,
afterBlur,
}: UseFocusControllerOptions = {}
) {
const instance = getCurrentInstance()!
const { emit } = instance
@ -23,7 +33,8 @@ export function useFocusController<T extends { focus: () => void }>(
const isFocused = ref(false)
const handleFocus = (event: FocusEvent) => {
if (isFocused.value) return
const cancelFocus = isFunction(beforeFocus) ? beforeFocus(event) : false
if (cancelFocus || isFocused.value) return
isFocused.value = true
emit('focus', event)
afterFocus?.()
@ -44,6 +55,12 @@ export function useFocusController<T extends { focus: () => void }>(
}
const handleClick = () => {
if (
wrapperRef.value?.contains(document.activeElement) &&
wrapperRef.value !== document.activeElement
)
return
target.value?.focus()
}
@ -53,14 +70,28 @@ export function useFocusController<T extends { focus: () => void }>(
}
})
// TODO: using useEventListener will fail the test
// useEventListener(target, 'focus', handleFocus)
// useEventListener(target, 'blur', handleBlur)
useEventListener(wrapperRef, 'focus', handleFocus, true)
useEventListener(wrapperRef, 'blur', handleBlur, true)
useEventListener(wrapperRef, 'click', handleClick, true)
// only for test
if (process.env.NODE_ENV === 'test') {
onMounted(() => {
const targetEl = isElement(target.value)
? target.value
: document.querySelector('input,textarea')
if (targetEl) {
useEventListener(targetEl, 'focus', handleFocus, true)
useEventListener(targetEl, 'blur', handleBlur, true)
}
})
}
return {
wrapperRef,
isFocused,
/** Avoid using wrapperRef and handleFocus/handleBlur together */
wrapperRef,
handleFocus,
handleBlur,
}