fix(components): [input] lose focus when click suffix icon (#13264)

* feat(hooks): add useFocusController

* fix(components): [input] lose focus when click suffix icon

closed #13153, #13159

* refactor(hooks): use-focus-controller

* test: fix test

* test: update

* fix(theme-chalk): [input] add cursor style to the wrapper
This commit is contained in:
qiang 2023-07-13 16:41:18 +08:00 committed by GitHub
parent a1dd70eead
commit 704399fadd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 140 additions and 21 deletions

View File

@ -15,10 +15,10 @@
<slot name="prepend" />
</div>
<div :class="wrapperKls">
<div ref="wrapperRef" :class="wrapperKls">
<!-- prefix slot -->
<span v-if="$slots.prefix || prefixIcon" :class="nsInput.e('prefix')">
<span :class="nsInput.e('prefix-inner')" @click="focus">
<span :class="nsInput.e('prefix-inner')">
<slot name="prefix" />
<el-icon v-if="prefixIcon" :class="nsInput.e('icon')">
<component :is="prefixIcon" />
@ -54,7 +54,7 @@
<!-- suffix slot -->
<span v-if="suffixVisible" :class="nsInput.e('suffix')">
<span :class="nsInput.e('suffix-inner')" @click="focus">
<span :class="nsInput.e('suffix-inner')">
<template
v-if="!showClear || !showPwdVisible || !isWordLimitVisible"
>
@ -172,7 +172,12 @@ import {
isKorean,
isObject,
} from '@element-plus/utils'
import { useAttrs, useCursor, useNamespace } from '@element-plus/hooks'
import {
useAttrs,
useCursor,
useFocusController,
useNamespace,
} from '@element-plus/hooks'
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
import { calcTextareaHeight } from './utils'
import { inputEmits, inputProps } from './input'
@ -220,7 +225,7 @@ const containerKls = computed(() => [
const wrapperKls = computed(() => [
nsInput.e('wrapper'),
nsInput.is('focus', focused.value),
nsInput.is('focus', isFocused.value),
])
const attrs = useAttrs({
@ -240,7 +245,6 @@ const nsTextarea = useNamespace('textarea')
const input = shallowRef<HTMLInputElement>()
const textarea = shallowRef<HTMLTextAreaElement>()
const focused = ref(false)
const hovering = ref(false)
const isComposing = ref(false)
const passwordVisible = ref(false)
@ -249,6 +253,17 @@ const textareaCalcStyle = shallowRef(props.inputStyle)
const _ref = computed(() => input.value || textarea.value)
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
_ref,
{
afterBlur() {
if (props.validateEvent) {
formItem?.validate?.('blur').catch((err) => debugWarn(err))
}
},
}
)
const needStatusIcon = computed(() => form?.statusIcon ?? false)
const validateState = computed(() => formItem?.validateState || '')
const validateIcon = computed(
@ -275,7 +290,7 @@ const showClear = computed(
!inputDisabled.value &&
!props.readonly &&
!!nativeInputValue.value &&
(focused.value || hovering.value)
(isFocused.value || hovering.value)
)
const showPwdVisible = computed(
() =>
@ -283,7 +298,7 @@ const showPwdVisible = computed(
!inputDisabled.value &&
!props.readonly &&
!!nativeInputValue.value &&
(!!nativeInputValue.value || focused.value)
(!!nativeInputValue.value || isFocused.value)
)
const isWordLimitVisible = computed(
() =>
@ -445,19 +460,6 @@ const focus = async () => {
const blur = () => _ref.value?.blur()
const handleFocus = (event: FocusEvent) => {
focused.value = true
emit('focus', event)
}
const handleBlur = (event: FocusEvent) => {
focused.value = false
emit('blur', event)
if (props.validateEvent) {
formItem?.validate?.('blur').catch((err) => debugWarn(err))
}
}
const handleMouseLeave = (evt: MouseEvent) => {
hovering.value = false
emit('mouseleave', evt)

View File

@ -0,0 +1,55 @@
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useFocusController } from '../use-focus-controller'
describe('useFocusController', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('it will avoid trigger unnecessary blur event', async () => {
const focusHandler = vi.fn()
const blurHandler = vi.fn()
const wrapper = mount({
emits: ['focus', 'blur'],
setup() {
const targetRef = ref()
const { wrapperRef, isFocused, handleFocus, handleBlur } =
useFocusController(targetRef, {
afterFocus: focusHandler,
afterBlur: blurHandler,
})
return () => (
<div ref={wrapperRef}>
<input ref={targetRef} onFocus={handleFocus} onBlur={handleBlur} />
<span>{String(isFocused.value)}</span>
</div>
)
},
})
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)
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)
await wrapper.find('span').trigger('click')
expect(wrapper.emitted()).not.toHaveProperty('blur')
expect(focusHandler).toHaveBeenCalledTimes(1)
expect(blurHandler).toHaveBeenCalledTimes(0)
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted()).toHaveProperty('blur')
expect(wrapper.find('span').text()).toBe('false')
expect(blurHandler).toHaveBeenCalledTimes(1)
})
})

View File

@ -26,3 +26,4 @@ export * from './use-floating'
export * from './use-cursor'
export * from './use-ordered-children'
export * from './use-size'
export * from './use-focus-controller'

View File

@ -0,0 +1,60 @@
import { getCurrentInstance, ref, shallowRef, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import type { ShallowRef } from 'vue'
interface UseFocusControllerOptions {
afterFocus?: () => void
afterBlur?: () => void
}
export function useFocusController<T extends HTMLElement>(
target: ShallowRef<T | undefined>,
{ afterFocus, afterBlur }: UseFocusControllerOptions = {}
) {
const instance = getCurrentInstance()!
const { emit } = instance
const wrapperRef = shallowRef<HTMLElement>()
const isFocused = ref(false)
const handleFocus = (event: FocusEvent) => {
if (isFocused.value) return
isFocused.value = true
emit('focus', event)
afterFocus?.()
}
const handleBlur = (event: FocusEvent) => {
if (
event.relatedTarget &&
wrapperRef.value?.contains(event.relatedTarget as Node)
)
return
isFocused.value = false
emit('blur', event)
afterBlur?.()
}
const handleClick = () => {
target.value?.focus()
}
watch(wrapperRef, (el) => {
if (el) {
el.setAttribute('role', 'button')
el.setAttribute('tabindex', '-1')
}
})
// TODO: using useEventListener will fail the test
// useEventListener(target, 'focus', handleFocus)
// useEventListener(target, 'blur', handleBlur)
useEventListener(wrapperRef, 'click', handleClick)
return {
wrapperRef,
isFocused,
handleFocus,
handleBlur,
}
}

View File

@ -180,6 +180,7 @@
'input-border-radius',
map.get($input, 'border-radius')
);
cursor: text;
transition: getCssVar('transition-box-shadow');
transform: translate3d(0, 0, 0);
@include inset-input-border(