123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 |
- <template>
- <div
- v-show="type !== 'hidden'"
- v-bind="containerAttrs"
- :class="containerKls"
- :style="containerStyle"
- :role="containerRole"
- @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" />
- <kk-icon v-if="prefixIcon" :class="nsInput.e('icon')">
- <component :is="prefixIcon" />
- </kk-icon>
- </span>
- </span>
- <input
- :id="inputId"
- ref="input"
- :class="nsInput.e('inner')"
- v-bind="attrs"
- :type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
- :disabled="inputDisabled"
- :formatter="formatter"
- :parser="parser"
- :readonly="readonly"
- :autocomplete="autocomplete"
- :tabindex="tabindex"
- :aria-label="label"
- :placeholder="placeholder"
- :style="inputStyle"
- :form="props.form"
- @compositionstart="handleCompositionStart"
- @compositionupdate="handleCompositionUpdate"
- @compositionend="handleCompositionEnd"
- @input="handleInput"
- @focus="handleFocus"
- @blur="handleBlur"
- @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" />
- <kk-icon v-if="suffixIcon" :class="nsInput.e('icon')">
- <component :is="suffixIcon" />
- </kk-icon>
- </template>
- <kk-icon
- v-if="showClear"
- :class="[nsInput.e('icon'), nsInput.e('clear')]"
- @mousedown.prevent="NOOP"
- @click="clear"
- >
- <circle-close />
- </kk-icon>
- <kk-icon
- v-if="showPwdVisible"
- :class="[nsInput.e('icon'), nsInput.e('password')]"
- @click="handlePasswordVisible"
- >
- <component :is="passwordIcon" />
- </kk-icon>
- <span v-if="isWordLimitVisible" :class="nsInput.e('count')">
- <span :class="nsInput.e('count-inner')">
- {{ textLength }} / {{ attrs.maxlength }}
- </span>
- </span>
- <kk-icon
- v-if="validateState && validateIcon && needStatusIcon"
- :class="[
- nsInput.e('icon'),
- nsInput.e('validateIcon'),
- nsInput.is('loading', validateState === 'validating'),
- ]"
- >
- <component :is="validateIcon" />
- </kk-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')"
- v-bind="attrs"
- :tabindex="tabindex"
- :disabled="inputDisabled"
- :readonly="readonly"
- :autocomplete="autocomplete"
- :style="textareaStyle"
- :aria-label="label"
- :placeholder="placeholder"
- :form="props.form"
- @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 }} / {{ attrs.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 { KkIcon } from '@kankan-components/components/basic/icon'
- import {
- CircleClose,
- Hide as IconHide,
- View as IconView,
- } from '@kankan-components/icons-vue'
- import {
- useFormDisabled,
- useFormItem,
- useFormItemInputId,
- useFormSize,
- } from '@kankan-components/components/basic/form'
- import {
- NOOP,
- ValidateComponentsMap,
- debugWarn,
- isClient,
- isKorean,
- isObject,
- } from '@kankan-components/utils'
- import {
- useAttrs,
- useCursor,
- useFocusController,
- useNamespace,
- } from '@kankan-components/hooks'
- import { UPDATE_MODEL_EVENT } from '@kankan-components/constants'
- import { calcTextareaHeight } from './utils'
- import { inputEmits, inputProps } from './input'
- import type { StyleValue } from 'vue'
- type TargetElement = HTMLInputElement | HTMLTextAreaElement
- defineOptions({
- name: 'ElInput',
- inheritAttrs: false,
- })
- const props = defineProps(inputProps)
- const emit = defineEmits(inputEmits)
- const rawAttrs = useRawAttrs()
- const slots = useSlots()
- const containerAttrs = computed(() => {
- const comboBoxAttrs: Record<string, unknown> = {}
- if (props.containerRole === 'combobox') {
- comboBoxAttrs['aria-haspopup'] = rawAttrs['aria-haspopup']
- comboBoxAttrs['aria-owns'] = rawAttrs['aria-owns']
- comboBoxAttrs['aria-expanded'] = rawAttrs['aria-expanded']
- }
- return comboBoxAttrs
- })
- 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.bm('group', 'append')]: slots.append,
- [nsInput.bm('group', 'prepend')]: slots.prepend,
- [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,
- },
- rawAttrs.class,
- ])
- const wrapperKls = computed(() => [
- nsInput.e('wrapper'),
- nsInput.is('focus', isFocused.value),
- ])
- const attrs = useAttrs({
- excludeKeys: computed<string[]>(() => {
- return Object.keys(containerAttrs.value)
- }),
- })
- const { form, formItem } = useFormItem()
- const { inputId } = useFormItemInputId(props, {
- formItemContext: formItem,
- })
- 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 isComposing = ref(false)
- const passwordVisible = ref(false)
- const countStyle = ref<StyleValue>()
- 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(
- () => validateState.value && ValidateComponentsMap[validateState.value]
- )
- const passwordIcon = computed(() =>
- passwordVisible.value ? IconView : IconHide
- )
- const containerStyle = computed<StyleValue>(() => [
- rawAttrs.style as StyleValue,
- props.inputStyle,
- ])
- 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 &&
- !props.readonly &&
- !!nativeInputValue.value &&
- (!!nativeInputValue.value || isFocused.value)
- )
- const isWordLimitVisible = computed(
- () =>
- props.showWordLimit &&
- !!attrs.value.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(attrs.value.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) {
- value = props.parser ? props.parser(value) : 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', value)
- // ensure native input value is controlled
- // see: https://github.com/ElemeFE/element/issues/12850
- await nextTick()
- setNativeInputValue()
- setCursor()
- }
- const handleChange = (event: Event) => {
- emit('change', (event.target as TargetElement).value)
- }
- const handleCompositionStart = (event: CompositionEvent) => {
- emit('compositionstart', event)
- isComposing.value = true
- }
- const handleCompositionUpdate = (event: CompositionEvent) => {
- emit('compositionupdate', event)
- const text = (event.target as HTMLInputElement)?.value
- const lastCharacter = text[text.length - 1] || ''
- isComposing.value = !isKorean(lastCharacter)
- }
- const handleCompositionEnd = (event: CompositionEvent) => {
- emit('compositionend', event)
- if (isComposing.value) {
- isComposing.value = false
- handleInput(event)
- }
- }
- const handlePasswordVisible = () => {
- passwordVisible.value = !passwordVisible.value
- focus()
- }
- const focus = async () => {
- // see: https://github.com/ElemeFE/element/issues/18573
- await nextTick()
- _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', '')
- emit('clear')
- emit('input', '')
- }
- watch(
- () => props.modelValue,
- () => {
- nextTick(() => resizeTextarea())
- if (props.validateEvent) {
- formItem?.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(
- 'ElInput',
- '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 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>
|