input.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. <template>
  2. <div
  3. v-show="type !== 'hidden'"
  4. v-bind="containerAttrs"
  5. :class="containerKls"
  6. :style="containerStyle"
  7. :role="containerRole"
  8. @mouseenter="handleMouseEnter"
  9. @mouseleave="handleMouseLeave"
  10. >
  11. <!-- input -->
  12. <template v-if="type !== 'textarea'">
  13. <!-- prepend slot -->
  14. <div v-if="$slots.prepend" :class="nsInput.be('group', 'prepend')">
  15. <slot name="prepend" />
  16. </div>
  17. <div ref="wrapperRef" :class="wrapperKls">
  18. <!-- prefix slot -->
  19. <span v-if="$slots.prefix || prefixIcon" :class="nsInput.e('prefix')">
  20. <span :class="nsInput.e('prefix-inner')">
  21. <slot name="prefix" />
  22. <kk-icon v-if="prefixIcon" :class="nsInput.e('icon')">
  23. <component :is="prefixIcon" />
  24. </kk-icon>
  25. </span>
  26. </span>
  27. <input
  28. :id="inputId"
  29. ref="input"
  30. :class="nsInput.e('inner')"
  31. v-bind="attrs"
  32. :type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
  33. :disabled="inputDisabled"
  34. :formatter="formatter"
  35. :parser="parser"
  36. :readonly="readonly"
  37. :autocomplete="autocomplete"
  38. :tabindex="tabindex"
  39. :aria-label="label"
  40. :placeholder="placeholder"
  41. :style="inputStyle"
  42. :form="props.form"
  43. @compositionstart="handleCompositionStart"
  44. @compositionupdate="handleCompositionUpdate"
  45. @compositionend="handleCompositionEnd"
  46. @input="handleInput"
  47. @focus="handleFocus"
  48. @blur="handleBlur"
  49. @change="handleChange"
  50. @keydown="handleKeydown"
  51. />
  52. <!-- suffix slot -->
  53. <span v-if="suffixVisible" :class="nsInput.e('suffix')">
  54. <span :class="nsInput.e('suffix-inner')">
  55. <template
  56. v-if="!showClear || !showPwdVisible || !isWordLimitVisible"
  57. >
  58. <slot name="suffix" />
  59. <kk-icon v-if="suffixIcon" :class="nsInput.e('icon')">
  60. <component :is="suffixIcon" />
  61. </kk-icon>
  62. </template>
  63. <kk-icon
  64. v-if="showClear"
  65. :class="[nsInput.e('icon'), nsInput.e('clear')]"
  66. @mousedown.prevent="NOOP"
  67. @click="clear"
  68. >
  69. <circle-close />
  70. </kk-icon>
  71. <kk-icon
  72. v-if="showPwdVisible"
  73. :class="[nsInput.e('icon'), nsInput.e('password')]"
  74. @click="handlePasswordVisible"
  75. >
  76. <component :is="passwordIcon" />
  77. </kk-icon>
  78. <span v-if="isWordLimitVisible" :class="nsInput.e('count')">
  79. <span :class="nsInput.e('count-inner')">
  80. {{ textLength }} / {{ attrs.maxlength }}
  81. </span>
  82. </span>
  83. <kk-icon
  84. v-if="validateState && validateIcon && needStatusIcon"
  85. :class="[
  86. nsInput.e('icon'),
  87. nsInput.e('validateIcon'),
  88. nsInput.is('loading', validateState === 'validating'),
  89. ]"
  90. >
  91. <component :is="validateIcon" />
  92. </kk-icon>
  93. </span>
  94. </span>
  95. </div>
  96. <!-- append slot -->
  97. <div v-if="$slots.append" :class="nsInput.be('group', 'append')">
  98. <slot name="append" />
  99. </div>
  100. </template>
  101. <!-- textarea -->
  102. <template v-else>
  103. <textarea
  104. :id="inputId"
  105. ref="textarea"
  106. :class="nsTextarea.e('inner')"
  107. v-bind="attrs"
  108. :tabindex="tabindex"
  109. :disabled="inputDisabled"
  110. :readonly="readonly"
  111. :autocomplete="autocomplete"
  112. :style="textareaStyle"
  113. :aria-label="label"
  114. :placeholder="placeholder"
  115. :form="props.form"
  116. @compositionstart="handleCompositionStart"
  117. @compositionupdate="handleCompositionUpdate"
  118. @compositionend="handleCompositionEnd"
  119. @input="handleInput"
  120. @focus="handleFocus"
  121. @blur="handleBlur"
  122. @change="handleChange"
  123. @keydown="handleKeydown"
  124. />
  125. <span
  126. v-if="isWordLimitVisible"
  127. :style="countStyle"
  128. :class="nsInput.e('count')"
  129. >
  130. {{ textLength }} / {{ attrs.maxlength }}
  131. </span>
  132. </template>
  133. </div>
  134. </template>
  135. <script lang="ts" setup>
  136. import {
  137. computed,
  138. nextTick,
  139. onMounted,
  140. ref,
  141. shallowRef,
  142. toRef,
  143. useAttrs as useRawAttrs,
  144. useSlots,
  145. watch,
  146. } from 'vue'
  147. import { useResizeObserver } from '@vueuse/core'
  148. import { isNil } from 'lodash-unified'
  149. import { KkIcon } from '@kankan-components/components/basic/icon'
  150. import {
  151. CircleClose,
  152. Hide as IconHide,
  153. View as IconView,
  154. } from '@kankan-components/icons-vue'
  155. import {
  156. useFormDisabled,
  157. useFormItem,
  158. useFormItemInputId,
  159. useFormSize,
  160. } from '@kankan-components/components/basic/form'
  161. import {
  162. NOOP,
  163. ValidateComponentsMap,
  164. debugWarn,
  165. isClient,
  166. isKorean,
  167. isObject,
  168. } from '@kankan-components/utils'
  169. import {
  170. useAttrs,
  171. useCursor,
  172. useFocusController,
  173. useNamespace,
  174. } from '@kankan-components/hooks'
  175. import { UPDATE_MODEL_EVENT } from '@kankan-components/constants'
  176. import { calcTextareaHeight } from './utils'
  177. import { inputEmits, inputProps } from './input'
  178. import type { StyleValue } from 'vue'
  179. type TargetElement = HTMLInputElement | HTMLTextAreaElement
  180. defineOptions({
  181. name: 'ElInput',
  182. inheritAttrs: false,
  183. })
  184. const props = defineProps(inputProps)
  185. const emit = defineEmits(inputEmits)
  186. const rawAttrs = useRawAttrs()
  187. const slots = useSlots()
  188. const containerAttrs = computed(() => {
  189. const comboBoxAttrs: Record<string, unknown> = {}
  190. if (props.containerRole === 'combobox') {
  191. comboBoxAttrs['aria-haspopup'] = rawAttrs['aria-haspopup']
  192. comboBoxAttrs['aria-owns'] = rawAttrs['aria-owns']
  193. comboBoxAttrs['aria-expanded'] = rawAttrs['aria-expanded']
  194. }
  195. return comboBoxAttrs
  196. })
  197. const containerKls = computed(() => [
  198. props.type === 'textarea' ? nsTextarea.b() : nsInput.b(),
  199. nsInput.m(inputSize.value),
  200. nsInput.is('disabled', inputDisabled.value),
  201. nsInput.is('exceed', inputExceed.value),
  202. {
  203. [nsInput.b('group')]: slots.prepend || slots.append,
  204. [nsInput.bm('group', 'append')]: slots.append,
  205. [nsInput.bm('group', 'prepend')]: slots.prepend,
  206. [nsInput.m('prefix')]: slots.prefix || props.prefixIcon,
  207. [nsInput.m('suffix')]:
  208. slots.suffix || props.suffixIcon || props.clearable || props.showPassword,
  209. [nsInput.bm('suffix', 'password-clear')]:
  210. showClear.value && showPwdVisible.value,
  211. },
  212. rawAttrs.class,
  213. ])
  214. const wrapperKls = computed(() => [
  215. nsInput.e('wrapper'),
  216. nsInput.is('focus', isFocused.value),
  217. ])
  218. const attrs = useAttrs({
  219. excludeKeys: computed<string[]>(() => {
  220. return Object.keys(containerAttrs.value)
  221. }),
  222. })
  223. const { form, formItem } = useFormItem()
  224. const { inputId } = useFormItemInputId(props, {
  225. formItemContext: formItem,
  226. })
  227. const inputSize = useFormSize()
  228. const inputDisabled = useFormDisabled()
  229. const nsInput = useNamespace('input')
  230. const nsTextarea = useNamespace('textarea')
  231. const input = shallowRef<HTMLInputElement>()
  232. const textarea = shallowRef<HTMLTextAreaElement>()
  233. const hovering = ref(false)
  234. const isComposing = ref(false)
  235. const passwordVisible = ref(false)
  236. const countStyle = ref<StyleValue>()
  237. const textareaCalcStyle = shallowRef(props.inputStyle)
  238. const _ref = computed(() => input.value || textarea.value)
  239. const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
  240. _ref,
  241. {
  242. afterBlur() {
  243. if (props.validateEvent) {
  244. formItem?.validate?.('blur').catch((err) => debugWarn(err))
  245. }
  246. },
  247. }
  248. )
  249. const needStatusIcon = computed(() => form?.statusIcon ?? false)
  250. const validateState = computed(() => formItem?.validateState || '')
  251. const validateIcon = computed(
  252. () => validateState.value && ValidateComponentsMap[validateState.value]
  253. )
  254. const passwordIcon = computed(() =>
  255. passwordVisible.value ? IconView : IconHide
  256. )
  257. const containerStyle = computed<StyleValue>(() => [
  258. rawAttrs.style as StyleValue,
  259. props.inputStyle,
  260. ])
  261. const textareaStyle = computed<StyleValue>(() => [
  262. props.inputStyle,
  263. textareaCalcStyle.value,
  264. { resize: props.resize },
  265. ])
  266. const nativeInputValue = computed(() =>
  267. isNil(props.modelValue) ? '' : String(props.modelValue)
  268. )
  269. const showClear = computed(
  270. () =>
  271. props.clearable &&
  272. !inputDisabled.value &&
  273. !props.readonly &&
  274. !!nativeInputValue.value &&
  275. (isFocused.value || hovering.value)
  276. )
  277. const showPwdVisible = computed(
  278. () =>
  279. props.showPassword &&
  280. !inputDisabled.value &&
  281. !props.readonly &&
  282. !!nativeInputValue.value &&
  283. (!!nativeInputValue.value || isFocused.value)
  284. )
  285. const isWordLimitVisible = computed(
  286. () =>
  287. props.showWordLimit &&
  288. !!attrs.value.maxlength &&
  289. (props.type === 'text' || props.type === 'textarea') &&
  290. !inputDisabled.value &&
  291. !props.readonly &&
  292. !props.showPassword
  293. )
  294. const textLength = computed(() => nativeInputValue.value.length)
  295. const inputExceed = computed(
  296. () =>
  297. // show exceed style if length of initial value greater then maxlength
  298. !!isWordLimitVisible.value &&
  299. textLength.value > Number(attrs.value.maxlength)
  300. )
  301. const suffixVisible = computed(
  302. () =>
  303. !!slots.suffix ||
  304. !!props.suffixIcon ||
  305. showClear.value ||
  306. props.showPassword ||
  307. isWordLimitVisible.value ||
  308. (!!validateState.value && needStatusIcon.value)
  309. )
  310. const [recordCursor, setCursor] = useCursor(input)
  311. useResizeObserver(textarea, (entries) => {
  312. onceInitSizeTextarea()
  313. if (!isWordLimitVisible.value || props.resize !== 'both') return
  314. const entry = entries[0]
  315. const { width } = entry.contentRect
  316. countStyle.value = {
  317. /** right: 100% - width + padding(15) + right(6) */
  318. right: `calc(100% - ${width + 15 + 6}px)`,
  319. }
  320. })
  321. const resizeTextarea = () => {
  322. const { type, autosize } = props
  323. if (!isClient || type !== 'textarea' || !textarea.value) return
  324. if (autosize) {
  325. const minRows = isObject(autosize) ? autosize.minRows : undefined
  326. const maxRows = isObject(autosize) ? autosize.maxRows : undefined
  327. const textareaStyle = calcTextareaHeight(textarea.value, minRows, maxRows)
  328. // If the scrollbar is displayed, the height of the textarea needs more space than the calculated height.
  329. // If set textarea height in this case, the scrollbar will not hide.
  330. // So we need to hide scrollbar first, and reset it in next tick.
  331. // see https://github.com/element-plus/element-plus/issues/8825
  332. textareaCalcStyle.value = {
  333. overflowY: 'hidden',
  334. ...textareaStyle,
  335. }
  336. nextTick(() => {
  337. // NOTE: Force repaint to make sure the style set above is applied.
  338. textarea.value!.offsetHeight
  339. textareaCalcStyle.value = textareaStyle
  340. })
  341. } else {
  342. textareaCalcStyle.value = {
  343. minHeight: calcTextareaHeight(textarea.value).minHeight,
  344. }
  345. }
  346. }
  347. const createOnceInitResize = (resizeTextarea: () => void) => {
  348. let isInit = false
  349. return () => {
  350. if (isInit || !props.autosize) return
  351. const isElHidden = textarea.value?.offsetParent === null
  352. if (!isElHidden) {
  353. resizeTextarea()
  354. isInit = true
  355. }
  356. }
  357. }
  358. // fix: https://github.com/element-plus/element-plus/issues/12074
  359. const onceInitSizeTextarea = createOnceInitResize(resizeTextarea)
  360. const setNativeInputValue = () => {
  361. const input = _ref.value
  362. const formatterValue = props.formatter
  363. ? props.formatter(nativeInputValue.value)
  364. : nativeInputValue.value
  365. if (!input || input.value === formatterValue) return
  366. input.value = formatterValue
  367. }
  368. const handleInput = async (event: Event) => {
  369. recordCursor()
  370. let { value } = event.target as TargetElement
  371. if (props.formatter) {
  372. value = props.parser ? props.parser(value) : value
  373. }
  374. // should not emit input during composition
  375. // see: https://github.com/ElemeFE/element/issues/10516
  376. if (isComposing.value) return
  377. // hack for https://github.com/ElemeFE/element/issues/8548
  378. // should remove the following line when we don't support IE
  379. if (value === nativeInputValue.value) {
  380. setNativeInputValue()
  381. return
  382. }
  383. emit(UPDATE_MODEL_EVENT, value)
  384. emit('input', value)
  385. // ensure native input value is controlled
  386. // see: https://github.com/ElemeFE/element/issues/12850
  387. await nextTick()
  388. setNativeInputValue()
  389. setCursor()
  390. }
  391. const handleChange = (event: Event) => {
  392. emit('change', (event.target as TargetElement).value)
  393. }
  394. const handleCompositionStart = (event: CompositionEvent) => {
  395. emit('compositionstart', event)
  396. isComposing.value = true
  397. }
  398. const handleCompositionUpdate = (event: CompositionEvent) => {
  399. emit('compositionupdate', event)
  400. const text = (event.target as HTMLInputElement)?.value
  401. const lastCharacter = text[text.length - 1] || ''
  402. isComposing.value = !isKorean(lastCharacter)
  403. }
  404. const handleCompositionEnd = (event: CompositionEvent) => {
  405. emit('compositionend', event)
  406. if (isComposing.value) {
  407. isComposing.value = false
  408. handleInput(event)
  409. }
  410. }
  411. const handlePasswordVisible = () => {
  412. passwordVisible.value = !passwordVisible.value
  413. focus()
  414. }
  415. const focus = async () => {
  416. // see: https://github.com/ElemeFE/element/issues/18573
  417. await nextTick()
  418. _ref.value?.focus()
  419. }
  420. const blur = () => _ref.value?.blur()
  421. const handleMouseLeave = (evt: MouseEvent) => {
  422. hovering.value = false
  423. emit('mouseleave', evt)
  424. }
  425. const handleMouseEnter = (evt: MouseEvent) => {
  426. hovering.value = true
  427. emit('mouseenter', evt)
  428. }
  429. const handleKeydown = (evt: KeyboardEvent) => {
  430. emit('keydown', evt)
  431. }
  432. const select = () => {
  433. _ref.value?.select()
  434. }
  435. const clear = () => {
  436. emit(UPDATE_MODEL_EVENT, '')
  437. emit('change', '')
  438. emit('clear')
  439. emit('input', '')
  440. }
  441. watch(
  442. () => props.modelValue,
  443. () => {
  444. nextTick(() => resizeTextarea())
  445. if (props.validateEvent) {
  446. formItem?.validate?.('change').catch((err) => debugWarn(err))
  447. }
  448. }
  449. )
  450. // native input value is set explicitly
  451. // do not use v-model / :value in template
  452. // see: https://github.com/ElemeFE/element/issues/14521
  453. watch(nativeInputValue, () => setNativeInputValue())
  454. // when change between <input> and <textarea>,
  455. // update DOM dependent value and styles
  456. // https://github.com/ElemeFE/element/issues/14857
  457. watch(
  458. () => props.type,
  459. async () => {
  460. await nextTick()
  461. setNativeInputValue()
  462. resizeTextarea()
  463. }
  464. )
  465. onMounted(() => {
  466. if (!props.formatter && props.parser) {
  467. debugWarn(
  468. 'ElInput',
  469. 'If you set the parser, you also need to set the formatter.'
  470. )
  471. }
  472. setNativeInputValue()
  473. nextTick(resizeTextarea)
  474. })
  475. defineExpose({
  476. /** @description HTML input element */
  477. input,
  478. /** @description HTML textarea element */
  479. textarea,
  480. /** @description HTML element, input or textarea */
  481. ref: _ref,
  482. /** @description style of textarea. */
  483. textareaStyle,
  484. /** @description from props (used on unit test) */
  485. autosize: toRef(props, 'autosize'),
  486. /** @description HTML input element native method */
  487. focus,
  488. /** @description HTML input element native method */
  489. blur,
  490. /** @description HTML input element native method */
  491. select,
  492. /** @description clear input value */
  493. clear,
  494. /** @description resize textarea. */
  495. resizeTextarea,
  496. })
  497. </script>