mirror of
https://github.com/element-plus/element-plus.git
synced 2025-08-16 20:34:20 +08:00

* feat(components): add custom clear icon function added `clear-icon` property to Cascader, Input, and InputTag components to customize the clear icon. * Update docs/en-US/component/cascader.md Co-authored-by: btea <2356281422@qq.com> * Update docs/en-US/component/cascader.md Co-authored-by: btea <2356281422@qq.com> * feat(components): add custom clear icon use case * docs(components): reduce cascader clear-icon use case options * Update packages/components/input-tag/src/input-tag.vue Co-authored-by: btea <2356281422@qq.com> * Update packages/components/input/src/input.vue Co-authored-by: btea <2356281422@qq.com> --------- Co-authored-by: btea <2356281422@qq.com>
535 lines
14 KiB
Vue
535 lines
14 KiB
Vue
<template>
|
|
<div
|
|
:class="[
|
|
containerKls,
|
|
{
|
|
[nsInput.bm('group', 'append')]: $slots.append,
|
|
[nsInput.bm('group', 'prepend')]: $slots.prepend,
|
|
},
|
|
]"
|
|
:style="containerStyle"
|
|
@mouseenter="handleMouseEnter"
|
|
@mouseleave="handleMouseLeave"
|
|
>
|
|
<!-- input -->
|
|
<template v-if="type !== 'textarea'">
|
|
<!-- prepend slot -->
|
|
<div v-if="$slots.prepend" :class="nsInput.be('group', 'prepend')">
|
|
<slot name="prepend" />
|
|
</div>
|
|
|
|
<div ref="wrapperRef" :class="wrapperKls">
|
|
<!-- prefix slot -->
|
|
<span v-if="$slots.prefix || prefixIcon" :class="nsInput.e('prefix')">
|
|
<span :class="nsInput.e('prefix-inner')">
|
|
<slot name="prefix" />
|
|
<el-icon v-if="prefixIcon" :class="nsInput.e('icon')">
|
|
<component :is="prefixIcon" />
|
|
</el-icon>
|
|
</span>
|
|
</span>
|
|
|
|
<input
|
|
:id="inputId"
|
|
ref="input"
|
|
:class="nsInput.e('inner')"
|
|
v-bind="attrs"
|
|
:name="name"
|
|
:minlength="minlength"
|
|
:maxlength="maxlength"
|
|
:type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
|
|
:disabled="inputDisabled"
|
|
:readonly="readonly"
|
|
:autocomplete="autocomplete"
|
|
:tabindex="tabindex"
|
|
:aria-label="ariaLabel"
|
|
:placeholder="placeholder"
|
|
:style="inputStyle"
|
|
:form="form"
|
|
:autofocus="autofocus"
|
|
:role="containerRole"
|
|
:inputmode="inputmode"
|
|
@compositionstart="handleCompositionStart"
|
|
@compositionupdate="handleCompositionUpdate"
|
|
@compositionend="handleCompositionEnd"
|
|
@input="handleInput"
|
|
@change="handleChange"
|
|
@keydown="handleKeydown"
|
|
/>
|
|
|
|
<!-- suffix slot -->
|
|
<span v-if="suffixVisible" :class="nsInput.e('suffix')">
|
|
<span :class="nsInput.e('suffix-inner')">
|
|
<template
|
|
v-if="!showClear || !showPwdVisible || !isWordLimitVisible"
|
|
>
|
|
<slot name="suffix" />
|
|
<el-icon v-if="suffixIcon" :class="nsInput.e('icon')">
|
|
<component :is="suffixIcon" />
|
|
</el-icon>
|
|
</template>
|
|
<el-icon
|
|
v-if="showClear"
|
|
:class="[nsInput.e('icon'), nsInput.e('clear')]"
|
|
@mousedown.prevent="NOOP"
|
|
@click="clear"
|
|
>
|
|
<component :is="clearIcon" />
|
|
</el-icon>
|
|
<el-icon
|
|
v-if="showPwdVisible"
|
|
:class="[nsInput.e('icon'), nsInput.e('password')]"
|
|
@click="handlePasswordVisible"
|
|
>
|
|
<component :is="passwordIcon" />
|
|
</el-icon>
|
|
<span v-if="isWordLimitVisible" :class="nsInput.e('count')">
|
|
<span :class="nsInput.e('count-inner')">
|
|
{{ textLength }} / {{ maxlength }}
|
|
</span>
|
|
</span>
|
|
<el-icon
|
|
v-if="validateState && validateIcon && needStatusIcon"
|
|
:class="[
|
|
nsInput.e('icon'),
|
|
nsInput.e('validateIcon'),
|
|
nsInput.is('loading', validateState === 'validating'),
|
|
]"
|
|
>
|
|
<component :is="validateIcon" />
|
|
</el-icon>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- append slot -->
|
|
<div v-if="$slots.append" :class="nsInput.be('group', 'append')">
|
|
<slot name="append" />
|
|
</div>
|
|
</template>
|
|
|
|
<!-- textarea -->
|
|
<template v-else>
|
|
<textarea
|
|
:id="inputId"
|
|
ref="textarea"
|
|
:class="[nsTextarea.e('inner'), nsInput.is('focus', isFocused)]"
|
|
v-bind="attrs"
|
|
:minlength="minlength"
|
|
:maxlength="maxlength"
|
|
:tabindex="tabindex"
|
|
:disabled="inputDisabled"
|
|
:readonly="readonly"
|
|
:autocomplete="autocomplete"
|
|
:style="textareaStyle"
|
|
:aria-label="ariaLabel"
|
|
:placeholder="placeholder"
|
|
:form="form"
|
|
:autofocus="autofocus"
|
|
:rows="rows"
|
|
:role="containerRole"
|
|
@compositionstart="handleCompositionStart"
|
|
@compositionupdate="handleCompositionUpdate"
|
|
@compositionend="handleCompositionEnd"
|
|
@input="handleInput"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
@change="handleChange"
|
|
@keydown="handleKeydown"
|
|
/>
|
|
<span
|
|
v-if="isWordLimitVisible"
|
|
:style="countStyle"
|
|
:class="nsInput.e('count')"
|
|
>
|
|
{{ textLength }} / {{ maxlength }}
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onMounted,
|
|
ref,
|
|
shallowRef,
|
|
toRef,
|
|
useAttrs as useRawAttrs,
|
|
useSlots,
|
|
watch,
|
|
} from 'vue'
|
|
import { useResizeObserver } from '@vueuse/core'
|
|
import { isNil } from 'lodash-unified'
|
|
import { ElIcon } from '@element-plus/components/icon'
|
|
import { Hide as IconHide, View as IconView } from '@element-plus/icons-vue'
|
|
import {
|
|
useFormDisabled,
|
|
useFormItem,
|
|
useFormItemInputId,
|
|
useFormSize,
|
|
} from '@element-plus/components/form'
|
|
import {
|
|
NOOP,
|
|
ValidateComponentsMap,
|
|
debugWarn,
|
|
isClient,
|
|
isObject,
|
|
} from '@element-plus/utils'
|
|
import {
|
|
useAttrs,
|
|
useComposition,
|
|
useCursor,
|
|
useFocusController,
|
|
useNamespace,
|
|
} from '@element-plus/hooks'
|
|
import {
|
|
CHANGE_EVENT,
|
|
INPUT_EVENT,
|
|
UPDATE_MODEL_EVENT,
|
|
} from '@element-plus/constants'
|
|
import { calcTextareaHeight } from './utils'
|
|
import { inputEmits, inputProps } from './input'
|
|
|
|
import type { StyleValue } from 'vue'
|
|
|
|
type TargetElement = HTMLInputElement | HTMLTextAreaElement
|
|
|
|
const COMPONENT_NAME = 'ElInput'
|
|
defineOptions({
|
|
name: COMPONENT_NAME,
|
|
inheritAttrs: false,
|
|
})
|
|
const props = defineProps(inputProps)
|
|
const emit = defineEmits(inputEmits)
|
|
|
|
const rawAttrs = useRawAttrs()
|
|
const attrs = useAttrs()
|
|
const slots = useSlots()
|
|
|
|
const containerKls = computed(() => [
|
|
props.type === 'textarea' ? nsTextarea.b() : nsInput.b(),
|
|
nsInput.m(inputSize.value),
|
|
nsInput.is('disabled', inputDisabled.value),
|
|
nsInput.is('exceed', inputExceed.value),
|
|
{
|
|
[nsInput.b('group')]: slots.prepend || slots.append,
|
|
[nsInput.m('prefix')]: slots.prefix || props.prefixIcon,
|
|
[nsInput.m('suffix')]:
|
|
slots.suffix || props.suffixIcon || props.clearable || props.showPassword,
|
|
[nsInput.bm('suffix', 'password-clear')]:
|
|
showClear.value && showPwdVisible.value,
|
|
[nsInput.b('hidden')]: props.type === 'hidden',
|
|
},
|
|
rawAttrs.class,
|
|
])
|
|
|
|
const wrapperKls = computed(() => [
|
|
nsInput.e('wrapper'),
|
|
nsInput.is('focus', isFocused.value),
|
|
])
|
|
|
|
const { form: elForm, formItem: elFormItem } = useFormItem()
|
|
const { inputId } = useFormItemInputId(props, {
|
|
formItemContext: elFormItem,
|
|
})
|
|
const inputSize = useFormSize()
|
|
const inputDisabled = useFormDisabled()
|
|
const nsInput = useNamespace('input')
|
|
const nsTextarea = useNamespace('textarea')
|
|
|
|
const input = shallowRef<HTMLInputElement>()
|
|
const textarea = shallowRef<HTMLTextAreaElement>()
|
|
|
|
const hovering = ref(false)
|
|
const passwordVisible = ref(false)
|
|
const countStyle = ref<StyleValue>()
|
|
const textareaCalcStyle = shallowRef(props.inputStyle)
|
|
|
|
const _ref = computed(() => input.value || textarea.value)
|
|
|
|
// wrapperRef for type="text", handleFocus and handleBlur for type="textarea"
|
|
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
|
|
_ref,
|
|
{
|
|
disabled: inputDisabled,
|
|
afterBlur() {
|
|
if (props.validateEvent) {
|
|
elFormItem?.validate?.('blur').catch((err) => debugWarn(err))
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
const needStatusIcon = computed(() => elForm?.statusIcon ?? false)
|
|
const validateState = computed(() => elFormItem?.validateState || '')
|
|
const validateIcon = computed(
|
|
() => validateState.value && ValidateComponentsMap[validateState.value]
|
|
)
|
|
const passwordIcon = computed(() =>
|
|
passwordVisible.value ? IconView : IconHide
|
|
)
|
|
const containerStyle = computed<StyleValue>(() => [
|
|
rawAttrs.style as StyleValue,
|
|
])
|
|
const textareaStyle = computed<StyleValue>(() => [
|
|
props.inputStyle,
|
|
textareaCalcStyle.value,
|
|
{ resize: props.resize },
|
|
])
|
|
const nativeInputValue = computed(() =>
|
|
isNil(props.modelValue) ? '' : String(props.modelValue)
|
|
)
|
|
const showClear = computed(
|
|
() =>
|
|
props.clearable &&
|
|
!inputDisabled.value &&
|
|
!props.readonly &&
|
|
!!nativeInputValue.value &&
|
|
(isFocused.value || hovering.value)
|
|
)
|
|
const showPwdVisible = computed(
|
|
() => props.showPassword && !inputDisabled.value && !!nativeInputValue.value
|
|
)
|
|
const isWordLimitVisible = computed(
|
|
() =>
|
|
props.showWordLimit &&
|
|
!!props.maxlength &&
|
|
(props.type === 'text' || props.type === 'textarea') &&
|
|
!inputDisabled.value &&
|
|
!props.readonly &&
|
|
!props.showPassword
|
|
)
|
|
const textLength = computed(() => nativeInputValue.value.length)
|
|
const inputExceed = computed(
|
|
() =>
|
|
// show exceed style if length of initial value greater then maxlength
|
|
!!isWordLimitVisible.value && textLength.value > Number(props.maxlength)
|
|
)
|
|
const suffixVisible = computed(
|
|
() =>
|
|
!!slots.suffix ||
|
|
!!props.suffixIcon ||
|
|
showClear.value ||
|
|
props.showPassword ||
|
|
isWordLimitVisible.value ||
|
|
(!!validateState.value && needStatusIcon.value)
|
|
)
|
|
|
|
const [recordCursor, setCursor] = useCursor(input)
|
|
|
|
useResizeObserver(textarea, (entries) => {
|
|
onceInitSizeTextarea()
|
|
if (!isWordLimitVisible.value || props.resize !== 'both') return
|
|
const entry = entries[0]
|
|
const { width } = entry.contentRect
|
|
countStyle.value = {
|
|
/** right: 100% - width + padding(15) + right(6) */
|
|
right: `calc(100% - ${width + 15 + 6}px)`,
|
|
}
|
|
})
|
|
|
|
const resizeTextarea = () => {
|
|
const { type, autosize } = props
|
|
|
|
if (!isClient || type !== 'textarea' || !textarea.value) return
|
|
|
|
if (autosize) {
|
|
const minRows = isObject(autosize) ? autosize.minRows : undefined
|
|
const maxRows = isObject(autosize) ? autosize.maxRows : undefined
|
|
const textareaStyle = calcTextareaHeight(textarea.value, minRows, maxRows)
|
|
|
|
// If the scrollbar is displayed, the height of the textarea needs more space than the calculated height.
|
|
// If set textarea height in this case, the scrollbar will not hide.
|
|
// So we need to hide scrollbar first, and reset it in next tick.
|
|
// see https://github.com/element-plus/element-plus/issues/8825
|
|
textareaCalcStyle.value = {
|
|
overflowY: 'hidden',
|
|
...textareaStyle,
|
|
}
|
|
|
|
nextTick(() => {
|
|
// NOTE: Force repaint to make sure the style set above is applied.
|
|
textarea.value!.offsetHeight
|
|
textareaCalcStyle.value = textareaStyle
|
|
})
|
|
} else {
|
|
textareaCalcStyle.value = {
|
|
minHeight: calcTextareaHeight(textarea.value).minHeight,
|
|
}
|
|
}
|
|
}
|
|
|
|
const createOnceInitResize = (resizeTextarea: () => void) => {
|
|
let isInit = false
|
|
return () => {
|
|
if (isInit || !props.autosize) return
|
|
const isElHidden = textarea.value?.offsetParent === null
|
|
if (!isElHidden) {
|
|
resizeTextarea()
|
|
isInit = true
|
|
}
|
|
}
|
|
}
|
|
// fix: https://github.com/element-plus/element-plus/issues/12074
|
|
const onceInitSizeTextarea = createOnceInitResize(resizeTextarea)
|
|
|
|
const setNativeInputValue = () => {
|
|
const input = _ref.value
|
|
const formatterValue = props.formatter
|
|
? props.formatter(nativeInputValue.value)
|
|
: nativeInputValue.value
|
|
if (!input || input.value === formatterValue) return
|
|
input.value = formatterValue
|
|
}
|
|
|
|
const handleInput = async (event: Event) => {
|
|
recordCursor()
|
|
|
|
let { value } = event.target as TargetElement
|
|
|
|
if (props.formatter && props.parser) {
|
|
value = props.parser(value)
|
|
}
|
|
|
|
// should not emit input during composition
|
|
// see: https://github.com/ElemeFE/element/issues/10516
|
|
if (isComposing.value) return
|
|
|
|
// hack for https://github.com/ElemeFE/element/issues/8548
|
|
// should remove the following line when we don't support IE
|
|
if (value === nativeInputValue.value) {
|
|
setNativeInputValue()
|
|
return
|
|
}
|
|
|
|
emit(UPDATE_MODEL_EVENT, value)
|
|
emit(INPUT_EVENT, value)
|
|
|
|
// ensure native input value is controlled
|
|
// see: https://github.com/ElemeFE/element/issues/12850
|
|
await nextTick()
|
|
setNativeInputValue()
|
|
setCursor()
|
|
}
|
|
|
|
const handleChange = (event: Event) => {
|
|
let { value } = event.target as TargetElement
|
|
|
|
if (props.formatter && props.parser) {
|
|
value = props.parser(value)
|
|
}
|
|
emit(CHANGE_EVENT, value)
|
|
}
|
|
|
|
const {
|
|
isComposing,
|
|
handleCompositionStart,
|
|
handleCompositionUpdate,
|
|
handleCompositionEnd,
|
|
} = useComposition({ emit, afterComposition: handleInput })
|
|
|
|
const handlePasswordVisible = () => {
|
|
recordCursor()
|
|
passwordVisible.value = !passwordVisible.value
|
|
// The native input needs a little time to regain focus
|
|
setTimeout(setCursor)
|
|
}
|
|
|
|
const focus = () => _ref.value?.focus()
|
|
|
|
const blur = () => _ref.value?.blur()
|
|
|
|
const handleMouseLeave = (evt: MouseEvent) => {
|
|
hovering.value = false
|
|
emit('mouseleave', evt)
|
|
}
|
|
|
|
const handleMouseEnter = (evt: MouseEvent) => {
|
|
hovering.value = true
|
|
emit('mouseenter', evt)
|
|
}
|
|
|
|
const handleKeydown = (evt: KeyboardEvent) => {
|
|
emit('keydown', evt)
|
|
}
|
|
|
|
const select = () => {
|
|
_ref.value?.select()
|
|
}
|
|
|
|
const clear = () => {
|
|
emit(UPDATE_MODEL_EVENT, '')
|
|
emit(CHANGE_EVENT, '')
|
|
emit('clear')
|
|
emit(INPUT_EVENT, '')
|
|
}
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
() => {
|
|
nextTick(() => resizeTextarea())
|
|
if (props.validateEvent) {
|
|
elFormItem?.validate?.('change').catch((err) => debugWarn(err))
|
|
}
|
|
}
|
|
)
|
|
|
|
// native input value is set explicitly
|
|
// do not use v-model / :value in template
|
|
// see: https://github.com/ElemeFE/element/issues/14521
|
|
watch(nativeInputValue, () => setNativeInputValue())
|
|
|
|
// when change between <input> and <textarea>,
|
|
// update DOM dependent value and styles
|
|
// https://github.com/ElemeFE/element/issues/14857
|
|
watch(
|
|
() => props.type,
|
|
async () => {
|
|
await nextTick()
|
|
setNativeInputValue()
|
|
resizeTextarea()
|
|
}
|
|
)
|
|
|
|
onMounted(() => {
|
|
if (!props.formatter && props.parser) {
|
|
debugWarn(
|
|
COMPONENT_NAME,
|
|
'If you set the parser, you also need to set the formatter.'
|
|
)
|
|
}
|
|
setNativeInputValue()
|
|
nextTick(resizeTextarea)
|
|
})
|
|
|
|
defineExpose({
|
|
/** @description HTML input element */
|
|
input,
|
|
/** @description HTML textarea element */
|
|
textarea,
|
|
/** @description HTML element, input or textarea */
|
|
ref: _ref,
|
|
/** @description style of textarea. */
|
|
textareaStyle,
|
|
|
|
/** @description from props (used on unit test) */
|
|
autosize: toRef(props, 'autosize'),
|
|
|
|
/** @description is input composing */
|
|
isComposing,
|
|
|
|
/** @description HTML input element native method */
|
|
focus,
|
|
/** @description HTML input element native method */
|
|
blur,
|
|
/** @description HTML input element native method */
|
|
select,
|
|
/** @description clear input value */
|
|
clear,
|
|
/** @description resize textarea. */
|
|
resizeTextarea,
|
|
})
|
|
</script>
|