mirror of
https://gitee.com/element-plus/element-plus.git
synced 2024-12-03 19:58:09 +08:00
Feat/input (#216)
* feat(input): migrate input component simply re #95 * feat(input): mainly complete input component & add more input tests * fix: address pr comments Co-authored-by: 陈婉玉 <simonaliachen@gmail.com>
This commit is contained in:
parent
ac2f477e44
commit
869cec59ba
@ -29,6 +29,7 @@ import ElPopper from '@element-plus/popper'
|
||||
import ElTabs from '@element-plus/tabs'
|
||||
import ElTooltip from '@element-plus/tooltip'
|
||||
import ElSlider from '@element-plus/slider'
|
||||
import ElInput from '@element-plus/input'
|
||||
|
||||
export {
|
||||
ElAlert,
|
||||
@ -60,6 +61,7 @@ export {
|
||||
ElTabs,
|
||||
ElTooltip,
|
||||
ElSlider,
|
||||
ElInput,
|
||||
}
|
||||
|
||||
export default function install(app: App): void {
|
||||
@ -93,4 +95,5 @@ export default function install(app: App): void {
|
||||
ElTabs(app)
|
||||
ElTooltip(app)
|
||||
ElSlider(app)
|
||||
ElInput(app)
|
||||
}
|
||||
|
419
packages/input/__tests__/input.spec.ts
Normal file
419
packages/input/__tests__/input.spec.ts
Normal file
@ -0,0 +1,419 @@
|
||||
import { ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { sleep, defineGetter } from '@element-plus/test-utils'
|
||||
import Input from '../src/index.vue'
|
||||
|
||||
const _mount = options => mount({
|
||||
components: {
|
||||
'el-input': Input,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
describe('Input.vue', () => {
|
||||
|
||||
test('create', async () => {
|
||||
const handleFocus = jest.fn()
|
||||
const wrapper = _mount({
|
||||
template: `
|
||||
<el-input
|
||||
:minlength="3"
|
||||
:maxlength="5"
|
||||
placeholder="请输入内容"
|
||||
@focus="handleFocus"
|
||||
:model-value="input">
|
||||
</el-input>
|
||||
`,
|
||||
setup() {
|
||||
const input = ref('input')
|
||||
|
||||
return {
|
||||
input,
|
||||
handleFocus,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const inputElm = wrapper.find('input')
|
||||
const vm = wrapper.vm as any
|
||||
|
||||
await inputElm.trigger('focus')
|
||||
|
||||
expect(inputElm.exists()).toBe(true)
|
||||
expect(handleFocus).toHaveBeenCalled()
|
||||
expect(wrapper.attributes('placeholder')).toBe('请输入内容')
|
||||
expect(inputElm.element.value).toBe('input')
|
||||
expect(wrapper.attributes('minlength')).toBe('3')
|
||||
expect(wrapper.attributes('maxlength')).toBe('5')
|
||||
|
||||
vm.input = 'text'
|
||||
await sleep()
|
||||
expect(inputElm.element.value).toBe('text')
|
||||
})
|
||||
|
||||
test('default to empty', () => {
|
||||
const wrapper = _mount({
|
||||
template: '<el-input />',
|
||||
})
|
||||
const inputElm = wrapper.find('input')
|
||||
expect(inputElm.element.value).toBe('')
|
||||
})
|
||||
|
||||
test('disabled', () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input disabled />`,
|
||||
})
|
||||
const inputElm = wrapper.find('input')
|
||||
expect(inputElm.element.disabled).not.toBeNull()
|
||||
})
|
||||
|
||||
test('suffixIcon', () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input suffix-icon="time" />`,
|
||||
})
|
||||
const icon = wrapper.find('.el-input__icon')
|
||||
expect(icon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('prefixIcon', () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input prefix-icon="time" />`,
|
||||
})
|
||||
const icon = wrapper.find('.el-input__icon')
|
||||
expect(icon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('size', () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input size="large" />`,
|
||||
})
|
||||
expect(wrapper.classes('el-input--large')).toBe(true)
|
||||
})
|
||||
|
||||
test('type', () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input type="textarea" />`,
|
||||
})
|
||||
expect(wrapper.classes('el-textarea')).toBe(true)
|
||||
})
|
||||
|
||||
test('rows', () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input type="textarea" :rows="3" />`,
|
||||
})
|
||||
expect(wrapper.find('textarea').element.rows).toEqual(3)
|
||||
})
|
||||
|
||||
test('resize', async() => {
|
||||
const wrapper = _mount({
|
||||
template: `
|
||||
<div>
|
||||
<el-input type="textarea" :resize="resize" />
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
resize: 'none',
|
||||
}
|
||||
},
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
const textarea = wrapper.find('textarea').element
|
||||
await sleep()
|
||||
expect(textarea.style.resize).toEqual(vm.resize)
|
||||
vm.resize = 'horizontal'
|
||||
await sleep()
|
||||
expect(textarea.style.resize).toEqual(vm.resize)
|
||||
})
|
||||
|
||||
// TODO: Due to jsdom's reason this case cannot run well, may be fixed later using headlesschrome or puppeteer
|
||||
// test('autosize', async() => {
|
||||
// const wrapper = _mount({
|
||||
// template: `<div>
|
||||
// <el-input
|
||||
// ref="limitSize"
|
||||
// type="textarea"
|
||||
// :autosize="{minRows: 3, maxRows: 5}"
|
||||
// v-model="textareaValue"
|
||||
// />
|
||||
// </div>`,
|
||||
// data() {
|
||||
// return {
|
||||
// textareaValue: 'sda\ndasd\nddasdsda\ndasd\nddasdsda\ndasd\nddasdsda\ndasd\nddasd',
|
||||
// }
|
||||
// },
|
||||
// })
|
||||
// const limitSizeInput = wrapper.vm.$refs.limitSize
|
||||
// const limitlessSizeInput = wrapper.vm.$refs.limitlessSize
|
||||
// await sleep()
|
||||
// expect(limitSizeInput.textareaStyle.height).toEqual('117px')
|
||||
// expect(limitlessSizeInput.textareaStyle.height).toEqual('201px')
|
||||
|
||||
// wrapper.vm.textareaValue = ''
|
||||
// await sleep()
|
||||
// expect(limitSizeInput.textareaStyle.height).toEqual('75px')
|
||||
// expect(limitlessSizeInput.textareaStyle.height).toEqual('33px')
|
||||
// })
|
||||
|
||||
test('sets value on textarea / input type change', async () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input :type="type" v-model="val" />`,
|
||||
data() {
|
||||
return {
|
||||
type: 'text',
|
||||
val: '123',
|
||||
}
|
||||
},
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.$el.querySelector('input').value).toEqual('123')
|
||||
vm.type = 'textarea'
|
||||
await sleep()
|
||||
expect(vm.$el.querySelector('textarea').value).toEqual('123')
|
||||
vm.type = 'password'
|
||||
await sleep()
|
||||
expect(vm.$el.querySelector('input').value).toEqual('123')
|
||||
})
|
||||
|
||||
test('limit input and show word count', async () => {
|
||||
const wrapper = _mount({
|
||||
template: `
|
||||
<div>
|
||||
<el-input
|
||||
class="test-text"
|
||||
type="text"
|
||||
v-model="input1"
|
||||
maxlength="10"
|
||||
:show-word-limit="show">
|
||||
</el-input>
|
||||
<el-input
|
||||
class="test-textarea"
|
||||
type="textarea"
|
||||
v-model="input2"
|
||||
maxlength="10"
|
||||
show-word-limit>
|
||||
</el-input>
|
||||
<el-input
|
||||
class="test-password"
|
||||
type="password"
|
||||
v-model="input3"
|
||||
maxlength="10"
|
||||
show-word-limit>
|
||||
</el-input>
|
||||
<el-input
|
||||
class="test-initial-exceed"
|
||||
type="text"
|
||||
v-model="input4"
|
||||
maxlength="2"
|
||||
show-word-limit>
|
||||
</el-input>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
input1: '',
|
||||
input2: '',
|
||||
input3: '',
|
||||
input4: 'exceed',
|
||||
show: false,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const inputElm1 = wrapper.vm.$el.querySelector('.test-text')
|
||||
const inputElm2 = wrapper.vm.$el.querySelector('.test-textarea')
|
||||
const inputElm3 = wrapper.vm.$el.querySelector('.test-password')
|
||||
const inputElm4 = wrapper.vm.$el.querySelector('.test-initial-exceed')
|
||||
|
||||
expect(inputElm1.querySelectorAll('.el-input__count').length).toEqual(0)
|
||||
expect(inputElm2.querySelectorAll('.el-input__count').length).toEqual(1)
|
||||
expect(inputElm3.querySelectorAll('.el-input__count').length).toEqual(0)
|
||||
expect(inputElm4.classList.contains('is-exceed')).toBe(true)
|
||||
|
||||
const vm = wrapper.vm as any
|
||||
vm.show = true
|
||||
await sleep()
|
||||
expect(inputElm1.querySelectorAll('.el-input__count').length).toEqual(1)
|
||||
|
||||
vm.input4 = '1'
|
||||
await sleep()
|
||||
expect(inputElm4.classList.contains('is-exceed')).toBe(false)
|
||||
})
|
||||
|
||||
describe('Input Methods', () => {
|
||||
|
||||
test('method:select', async () => {
|
||||
const testContent = 'test'
|
||||
const wrapper = _mount({
|
||||
template: `<el-input v-model="text" />`,
|
||||
data() {
|
||||
return {
|
||||
text: testContent,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input').element
|
||||
// mock selectionRange behaviour, due to jsdom's reason this case cannot run well, may be fixed later using headlesschrome or puppeteer
|
||||
let selected = false
|
||||
defineGetter(input, 'selectionStart', function() {
|
||||
return selected ? 0 : this.value.length
|
||||
})
|
||||
defineGetter(input, 'selectionEnd', function() {
|
||||
return this.value.length
|
||||
})
|
||||
|
||||
expect(input.selectionStart).toEqual(testContent.length)
|
||||
expect(input.selectionEnd).toEqual(testContent.length)
|
||||
|
||||
input.select()
|
||||
selected = true
|
||||
await sleep()
|
||||
expect(input.selectionStart).toEqual(0)
|
||||
expect(input.selectionEnd).toEqual(testContent.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input Events', () => {
|
||||
const handleFocus = jest.fn()
|
||||
const handleBlur = jest.fn()
|
||||
|
||||
test('event:focus & blur', async () => {
|
||||
const wrapper = _mount({
|
||||
template: `<el-input
|
||||
placeholder="请输入内容"
|
||||
:model-value="input"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>`,
|
||||
setup() {
|
||||
const input = ref('')
|
||||
|
||||
return {
|
||||
input,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.trigger('focus')
|
||||
expect(handleFocus).toBeCalled()
|
||||
|
||||
await input.trigger('blur')
|
||||
expect(handleBlur).toBeCalled()
|
||||
})
|
||||
|
||||
test('event:change', async() => {
|
||||
// NOTE: should be same as native's change behavior
|
||||
const wrapper = _mount({
|
||||
template: `
|
||||
<el-input
|
||||
placeholder="请输入内容"
|
||||
:model-value="input"
|
||||
@change="handleChange"
|
||||
/>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
input: 'a',
|
||||
val: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleChange(val) {
|
||||
this.val = val
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const el = wrapper.find('input').element
|
||||
const vm = wrapper.vm as any
|
||||
const simulateEvent = (text, event) => {
|
||||
el.value = text
|
||||
el.dispatchEvent(new Event(event))
|
||||
}
|
||||
|
||||
// simplified test, component should emit change when native does
|
||||
simulateEvent('2', 'change')
|
||||
await sleep()
|
||||
expect(vm.val).toBe('2')
|
||||
simulateEvent('1', 'input')
|
||||
await sleep()
|
||||
expect(vm.val).toBe('2')
|
||||
})
|
||||
|
||||
test('event:clear', async() => {
|
||||
const handleClear = jest.fn()
|
||||
const wrapper = _mount({
|
||||
template: `
|
||||
<el-input
|
||||
placeholder="请输入内容"
|
||||
clearable
|
||||
v-model="input"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
`,
|
||||
setup() {
|
||||
const input = ref('a')
|
||||
|
||||
return {
|
||||
input,
|
||||
handleClear,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const vm = wrapper.vm as any
|
||||
// focus to show clear button
|
||||
await input.trigger('focus')
|
||||
await sleep()
|
||||
vm.$el.querySelector('.el-input__clear').click()
|
||||
await sleep()
|
||||
expect(vm.input).toEqual('')
|
||||
expect(handleClear).toBeCalled()
|
||||
})
|
||||
|
||||
test('event:input', async() => {
|
||||
const handleInput = jest.fn()
|
||||
const wrapper = _mount({
|
||||
template: `
|
||||
<el-input
|
||||
placeholder="请输入内容"
|
||||
clearable
|
||||
:model-value="input"
|
||||
@input="handleInput"
|
||||
/>
|
||||
`,
|
||||
setup() {
|
||||
const input = ref('a')
|
||||
return {
|
||||
input,
|
||||
handleInput,
|
||||
}
|
||||
},
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
const inputWrapper = wrapper.find('input')
|
||||
const nativeInput = inputWrapper.element
|
||||
nativeInput.value = '1'
|
||||
await inputWrapper.trigger('compositionstart')
|
||||
await inputWrapper.trigger('input')
|
||||
nativeInput.value = '2'
|
||||
await inputWrapper.trigger('compositionupdate')
|
||||
await inputWrapper.trigger('input')
|
||||
await inputWrapper.trigger('compositionend')
|
||||
expect(handleInput).toBeCalled()
|
||||
// native input value is controlled
|
||||
expect(vm.input).toEqual('a')
|
||||
expect(nativeInput.value).toEqual('a')
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: validateEvent & input containes select cases should be added after the rest components finished
|
||||
// ...
|
||||
|
||||
})
|
66
packages/input/doc/basic.vue
Normal file
66
packages/input/doc/basic.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="demo-input">
|
||||
<h3>基础用法</h3>
|
||||
<el-input v-model="input1" placeholder="请输入内容" />
|
||||
</div>
|
||||
<div class="demo-input">
|
||||
<h3>禁用状态</h3>
|
||||
<el-input v-model="input1" placeholder="请输入内容" :disabled="true" />
|
||||
</div>
|
||||
<div class="demo-input">
|
||||
<h3>可清空</h3>
|
||||
<el-input v-model="input2" placeholder="请输入内容" clearable />
|
||||
</div>
|
||||
<div class="demo-input">
|
||||
<h3>密码框</h3>
|
||||
<el-input v-model="input3" placeholder="请输入密码" show-password />
|
||||
</div>
|
||||
<div class="demo-input">
|
||||
<h3>带 icon 的输入框</h3>
|
||||
<div class="demo-input-suffix">
|
||||
属性方式:
|
||||
<el-input v-model="input1" placeholder="请选择日期" suffix-icon="el-icon-date" />
|
||||
<el-input v-model="input2" placeholder="请输入内容" prefix-icon="el-icon-search" />
|
||||
</div>
|
||||
<div class="demo-input-suffix">
|
||||
slot 方式:
|
||||
<el-input v-model="input3" placeholder="请选择日期">
|
||||
<template #suffix>
|
||||
<i class="el-input__icon el-icon-date"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-model="input4" placeholder="请输入内容">
|
||||
<template #prefix>
|
||||
<i class="el-input__icon el-icon-search"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const basic = defineComponent({
|
||||
data() {
|
||||
return {
|
||||
input1: '1111',
|
||||
input2: '2222',
|
||||
input3: '',
|
||||
input4: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default basic
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.demo-input-suffix {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.demo-input .el-input {
|
||||
width: 180px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
</style>
|
47
packages/input/doc/composition.vue
Normal file
47
packages/input/doc/composition.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-input v-model="input1" placeholder="请输入内容">
|
||||
<template #prepend>Http://</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<el-input v-model="input2" placeholder="请输入内容">
|
||||
<template #append>.com</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<el-input v-model="input3" class="input-with-select" placeholder="请输入内容">
|
||||
<!-- <el-select v-model="select" slot="prepend" placeholder="请选择">
|
||||
<el-option label="餐厅名" value="1"></el-option>
|
||||
<el-option label="订单号" value="2"></el-option>
|
||||
<el-option label="用户电话" value="3"></el-option>
|
||||
</el-select> -->
|
||||
<template #append>
|
||||
<el-button icon="el-icon-search" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const basic = defineComponent({
|
||||
data() {
|
||||
return {
|
||||
input1: '1111',
|
||||
input2: '2222',
|
||||
input3: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default basic
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.demo-input .el-input {
|
||||
width: 180px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
</style>
|
9
packages/input/doc/index.stories.ts
Normal file
9
packages/input/doc/index.stories.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as BasicUsage } from './basic.vue'
|
||||
export { default as TextareaUsage } from './textarea.vue'
|
||||
export { default as CompositionUsage } from './composition.vue'
|
||||
export { default as SizeUsage } from './size.vue'
|
||||
export { default as LengthUsage } from './length.vue'
|
||||
|
||||
export default {
|
||||
title: 'Input',
|
||||
}
|
28
packages/input/doc/length.vue
Normal file
28
packages/input/doc/length.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<el-input
|
||||
v-model="text"
|
||||
type="text"
|
||||
placeholder="请输入内容"
|
||||
maxlength="10"
|
||||
show-word-limit
|
||||
/>
|
||||
<div style="margin: 20px 0;"></div>
|
||||
<el-input
|
||||
v-model="textarea"
|
||||
type="textarea"
|
||||
placeholder="请输入内容"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
text: '',
|
||||
textarea: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
47
packages/input/doc/size.vue
Normal file
47
packages/input/doc/size.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="demo-input-size">
|
||||
<el-input v-model="input1" placeholder="请输入内容" suffix-icon="el-icon-date" />
|
||||
<el-input
|
||||
v-model="input2"
|
||||
size="medium"
|
||||
placeholder="请输入内容"
|
||||
suffix-icon="el-icon-date"
|
||||
/>
|
||||
<el-input
|
||||
v-model="input3"
|
||||
size="small"
|
||||
placeholder="请输入内容"
|
||||
suffix-icon="el-icon-date"
|
||||
/>
|
||||
<el-input
|
||||
v-model="input4"
|
||||
size="mini"
|
||||
placeholder="请输入内容"
|
||||
suffix-icon="el-icon-date"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const basic = defineComponent({
|
||||
data() {
|
||||
return {
|
||||
input1: '1111',
|
||||
input2: '2222',
|
||||
input3: '',
|
||||
input4: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default basic
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.demo-input-size .el-input {
|
||||
width: 180px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
</style>
|
48
packages/input/doc/textarea.vue
Normal file
48
packages/input/doc/textarea.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="demo-input">
|
||||
<h3>文本域</h3>
|
||||
<el-input
|
||||
v-model="textarea1"
|
||||
:rows="2"
|
||||
type="textarea"
|
||||
placeholder="请输入内容"
|
||||
/>
|
||||
</div>
|
||||
<div class="demo-input">
|
||||
<h3>可自适应文本高度的文本域</h3>
|
||||
<el-input
|
||||
v-model="textarea2"
|
||||
type="textarea"
|
||||
autosize
|
||||
placeholder="请输入内容"
|
||||
/>
|
||||
<div style="margin: 20px 0;"></div>
|
||||
<el-input
|
||||
v-model="textarea3"
|
||||
type="textarea"
|
||||
:autosize="{minRows: 3, maxRows: 5}"
|
||||
placeholder="请输入内容"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const basic = defineComponent({
|
||||
data() {
|
||||
return {
|
||||
textarea1: 'sda\ndasd\nddasdsda\ndasd\nddasdsda\ndasd\nddasdsda\ndasd\nddasd',
|
||||
textarea2: 'sda\ndasd\nddasdsda\ndasd\nddasdsda\ndasd\nddasdsda\ndasd\nddasd',
|
||||
textarea3: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
export default basic
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.demo-input .el-textarea {
|
||||
width: 414px;
|
||||
}
|
||||
</style>
|
5
packages/input/index.ts
Normal file
5
packages/input/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { App } from 'vue'
|
||||
import Input from './src/index.vue'
|
||||
export default (app: App): void => {
|
||||
app.component(Input.name, Input)
|
||||
}
|
12
packages/input/package.json
Normal file
12
packages/input/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@element-plus/input",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0-rc.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.0.0-beta.0"
|
||||
}
|
||||
}
|
117
packages/input/src/calcTextareaHeight.ts
Normal file
117
packages/input/src/calcTextareaHeight.ts
Normal file
@ -0,0 +1,117 @@
|
||||
let hiddenTextarea
|
||||
|
||||
const HIDDEN_STYLE = `
|
||||
height:0 !important;
|
||||
visibility:hidden !important;
|
||||
overflow:hidden !important;
|
||||
position:absolute !important;
|
||||
z-index:-1000 !important;
|
||||
top:0 !important;
|
||||
right:0 !important;
|
||||
`
|
||||
|
||||
const CONTEXT_STYLE = [
|
||||
'letter-spacing',
|
||||
'line-height',
|
||||
'padding-top',
|
||||
'padding-bottom',
|
||||
'font-family',
|
||||
'font-weight',
|
||||
'font-size',
|
||||
'text-rendering',
|
||||
'text-transform',
|
||||
'width',
|
||||
'text-indent',
|
||||
'padding-left',
|
||||
'padding-right',
|
||||
'border-width',
|
||||
'box-sizing',
|
||||
]
|
||||
|
||||
type NodeStyle = {
|
||||
contextStyle: string
|
||||
boxSizing: string
|
||||
paddingSize: number
|
||||
borderSize: number
|
||||
}
|
||||
|
||||
type TextAreaHeight = {
|
||||
height: string
|
||||
minHeight?: string
|
||||
}
|
||||
|
||||
function calculateNodeStyling(targetElement): NodeStyle {
|
||||
const style = window.getComputedStyle(targetElement)
|
||||
|
||||
const boxSizing = style.getPropertyValue('box-sizing')
|
||||
|
||||
const paddingSize = (
|
||||
parseFloat(style.getPropertyValue('padding-bottom')) +
|
||||
parseFloat(style.getPropertyValue('padding-top'))
|
||||
)
|
||||
|
||||
const borderSize = (
|
||||
parseFloat(style.getPropertyValue('border-bottom-width')) +
|
||||
parseFloat(style.getPropertyValue('border-top-width'))
|
||||
)
|
||||
|
||||
const contextStyle = CONTEXT_STYLE
|
||||
.map(name => `${name}:${style.getPropertyValue(name)}`)
|
||||
.join(';')
|
||||
|
||||
return { contextStyle, paddingSize, borderSize, boxSizing }
|
||||
}
|
||||
|
||||
export default function calcTextareaHeight(
|
||||
targetElement,
|
||||
minRows = 1,
|
||||
maxRows = null,
|
||||
): TextAreaHeight {
|
||||
if (!hiddenTextarea) {
|
||||
hiddenTextarea = document.createElement('textarea')
|
||||
document.body.appendChild(hiddenTextarea)
|
||||
}
|
||||
|
||||
const {
|
||||
paddingSize,
|
||||
borderSize,
|
||||
boxSizing,
|
||||
contextStyle,
|
||||
} = calculateNodeStyling(targetElement)
|
||||
|
||||
hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
|
||||
hiddenTextarea.value = targetElement.value || targetElement.placeholder || ''
|
||||
|
||||
let height = hiddenTextarea.scrollHeight
|
||||
const result = {} as TextAreaHeight
|
||||
|
||||
if (boxSizing === 'border-box') {
|
||||
height = height + borderSize
|
||||
} else if (boxSizing === 'content-box') {
|
||||
height = height - paddingSize
|
||||
}
|
||||
|
||||
hiddenTextarea.value = ''
|
||||
const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize
|
||||
|
||||
if (minRows !== null) {
|
||||
let minHeight = singleRowHeight * minRows
|
||||
if (boxSizing === 'border-box') {
|
||||
minHeight = minHeight + paddingSize + borderSize
|
||||
}
|
||||
height = Math.max(minHeight, height)
|
||||
result.minHeight = `${ minHeight }px`
|
||||
}
|
||||
if (maxRows !== null) {
|
||||
let maxHeight = singleRowHeight * maxRows
|
||||
if (boxSizing === 'border-box') {
|
||||
maxHeight = maxHeight + paddingSize + borderSize
|
||||
}
|
||||
height = Math.min(maxHeight, height)
|
||||
}
|
||||
result.height = `${ height }px`
|
||||
hiddenTextarea.parentNode?.removeChild(hiddenTextarea)
|
||||
hiddenTextarea = null
|
||||
|
||||
return result
|
||||
}
|
472
packages/input/src/index.vue
Normal file
472
packages/input/src/index.vue
Normal file
@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
type === 'textarea' ? 'el-textarea' : 'el-input',
|
||||
inputSize ? 'el-input--' + inputSize : '',
|
||||
{
|
||||
'is-disabled': inputDisabled,
|
||||
'is-exceed': inputExceed,
|
||||
'el-input-group': $slots.prepend || $slots.append,
|
||||
'el-input-group--append': $slots.append,
|
||||
'el-input-group--prepend': $slots.prepend,
|
||||
'el-input--prefix': $slots.prefix || prefixIcon,
|
||||
'el-input--suffix': $slots.suffix || suffixIcon || clearable || showPassword
|
||||
}
|
||||
]"
|
||||
@mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
>
|
||||
<template v-if="type !== 'textarea'">
|
||||
<!-- 前置元素 -->
|
||||
<div v-if="$slots.prepend" class="el-input-group__prepend">
|
||||
<slot name="prepend"></slot>
|
||||
</div>
|
||||
<input
|
||||
v-if="type !== 'textarea'"
|
||||
ref="input"
|
||||
class="el-input__inner"
|
||||
v-bind="$attrs"
|
||||
:type="showPassword ? (passwordVisible ? 'text': 'password') : type"
|
||||
:disabled="inputDisabled"
|
||||
:readonly="readonly"
|
||||
:autocomplete="autocomplete"
|
||||
:tabindex="tabindex"
|
||||
:aria-label="label"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionupdate="handleCompositionUpdate"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@change="handleChange"
|
||||
>
|
||||
<!-- 前置内容 -->
|
||||
<span v-if="$slots.prefix || prefixIcon" class="el-input__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
<i
|
||||
v-if="prefixIcon"
|
||||
:class="['el-input__icon', prefixIcon]"
|
||||
></i>
|
||||
</span>
|
||||
<!-- 后置内容 -->
|
||||
<span v-if="getSuffixVisible()" class="el-input__suffix">
|
||||
<span class="el-input__suffix-inner">
|
||||
<template v-if="!showClear || !showPwdVisible || !isWordLimitVisible">
|
||||
<slot name="suffix"></slot>
|
||||
<i v-if="suffixIcon" :class="['el-input__icon', suffixIcon]"></i>
|
||||
</template>
|
||||
<i
|
||||
v-if="showClear"
|
||||
class="el-input__icon el-icon-circle-close el-input__clear"
|
||||
@mousedown.prevent
|
||||
@click="clear"
|
||||
></i>
|
||||
<i v-if="showPwdVisible" class="el-input__icon el-icon-view el-input__clear" @click="handlePasswordVisible"></i>
|
||||
<span v-if="isWordLimitVisible" class="el-input__count">
|
||||
<span class="el-input__count-inner">
|
||||
{{ textLength }}/{{ upperLimit }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<i v-if="validateState" :class="['el-input__icon', 'el-input__validateIcon', validateIcon]"></i>
|
||||
</span>
|
||||
<!-- 后置元素 -->
|
||||
<div v-if="$slots.append" class="el-input-group__append">
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
</template>
|
||||
<textarea
|
||||
v-else
|
||||
ref="textarea"
|
||||
class="el-textarea__inner"
|
||||
v-bind="$attrs"
|
||||
:tabindex="tabindex"
|
||||
:disabled="inputDisabled"
|
||||
:readonly="readonly"
|
||||
:autocomplete="autocomplete"
|
||||
:style="textareaStyle"
|
||||
:aria-label="label"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionupdate="handleCompositionUpdate"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@change="handleChange"
|
||||
>
|
||||
</textarea>
|
||||
<span v-if="isWordLimitVisible && type === 'textarea'" class="el-input__count">{{ textLength }}/{{ upperLimit }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang='ts'>
|
||||
import {
|
||||
defineComponent,
|
||||
inject,
|
||||
computed,
|
||||
watch,
|
||||
nextTick,
|
||||
getCurrentInstance,
|
||||
ref,
|
||||
shallowRef,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
} from 'vue'
|
||||
import { UPDATE_MODEL_EVENT, VALIDATE_STATE_MAP } from '@element-plus/utils/constants'
|
||||
import { isObject } from '@element-plus/utils/util'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
import { isKorean } from '@element-plus/utils/isDef'
|
||||
import calcTextareaHeight from './calcTextareaHeight'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
// TODOS: replace these interface definition with actual ElForm interface
|
||||
interface ElForm {
|
||||
disabled: boolean
|
||||
statusIcon: string
|
||||
}
|
||||
interface ElFormItem {
|
||||
elFormItemSize: number
|
||||
validateState: string
|
||||
}
|
||||
type AutosizeProp = {
|
||||
minRows?: number
|
||||
maxRows?: number
|
||||
} | boolean
|
||||
|
||||
const ELEMENT: {
|
||||
size?: number
|
||||
} = {}
|
||||
|
||||
const PENDANT_MAP = {
|
||||
suffix: 'append',
|
||||
prefix: 'prepend',
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ElInput',
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<'large' | 'medium' | 'small' | 'mini'>,
|
||||
validator: (val: string) => ['large', 'medium', 'small', 'mini'].includes(val),
|
||||
},
|
||||
resize: {
|
||||
type: String as PropType<'none' | 'both' | 'horizontal' | 'vertical'>,
|
||||
validator: (val: string) => ['none', 'both', 'horizontal', 'vertical'].includes(val),
|
||||
},
|
||||
autosize: {
|
||||
type: [Boolean, Object] as PropType<AutosizeProp>,
|
||||
default: false,
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: 'off',
|
||||
validator: (val: string) => ['on', 'off'].includes(val),
|
||||
},
|
||||
form: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showPassword: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showWordLimit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
suffixIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
prefixIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
tabindex: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
validateEvent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: [UPDATE_MODEL_EVENT, 'change', 'focus', 'blur', 'clear'],
|
||||
|
||||
setup(props, ctx) {
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const elForm = inject<ElForm>('elForm', {} as any)
|
||||
const elFormItem = inject<ElFormItem>('elFormItem', {} as any)
|
||||
|
||||
const input = ref(null)
|
||||
const textarea = ref (null)
|
||||
const focused = ref(false)
|
||||
const hovering = ref(false)
|
||||
const isComposing = ref(false)
|
||||
const passwordVisible = ref(false)
|
||||
const _textareaCalcStyle = shallowRef({})
|
||||
|
||||
const inputOrTextarea = computed(() => input.value || textarea.value)
|
||||
const inputSize = computed(() => props.size || elFormItem.elFormItemSize || ELEMENT?.size)
|
||||
const needStatusIcon = computed(() => elForm ? elForm.statusIcon : false)
|
||||
// TODO: adjust when ElForm done
|
||||
const validateState = computed(() => elFormItem.validateState || '')
|
||||
const validateIcon = computed(() => VALIDATE_STATE_MAP[validateState.value])
|
||||
const textareaStyle = computed(() => ({
|
||||
..._textareaCalcStyle.value,
|
||||
resize: props.resize,
|
||||
}))
|
||||
const inputDisabled = computed(() => props.disabled || elForm?.disabled)
|
||||
const nativeInputValue = computed(() => String(props.modelValue))
|
||||
const upperLimit = computed(() => ctx.attrs.maxlength)
|
||||
const showClear = computed(() => {
|
||||
return props.clearable &&
|
||||
!inputDisabled.value &&
|
||||
!props.readonly &&
|
||||
nativeInputValue.value &&
|
||||
(focused.value || hovering.value)
|
||||
})
|
||||
const showPwdVisible = computed(() => {
|
||||
return props.showPassword &&
|
||||
!inputDisabled.value &&
|
||||
!props.readonly &&
|
||||
(!!nativeInputValue.value || focused.value)
|
||||
})
|
||||
const isWordLimitVisible = computed(() => {
|
||||
return props.showWordLimit &&
|
||||
ctx.attrs.maxlength &&
|
||||
(props.type === 'text' || props.type === 'textarea') &&
|
||||
!inputDisabled.value &&
|
||||
!props.readonly &&
|
||||
!props.showPassword
|
||||
})
|
||||
const textLength = computed(() => {
|
||||
return typeof props.modelValue === 'number' ? String(props.modelValue).length : (props.modelValue || '').length
|
||||
})
|
||||
const inputExceed = computed(() => {
|
||||
// show exceed style if length of initial value greater then maxlength
|
||||
return isWordLimitVisible.value && (textLength.value > upperLimit.value)
|
||||
})
|
||||
|
||||
const resizeTextarea = () => {
|
||||
const { type, autosize } = props
|
||||
|
||||
if (isServer || type !== 'textarea') return
|
||||
|
||||
if (autosize) {
|
||||
const minRows = isObject(autosize) ? autosize.minRows : void 0
|
||||
const maxRows = isObject(autosize) ? autosize.maxRows : void 0
|
||||
_textareaCalcStyle.value = calcTextareaHeight(textarea.value, minRows, maxRows)
|
||||
} else {
|
||||
_textareaCalcStyle.value = {
|
||||
minHeight: calcTextareaHeight(textarea.value).minHeight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setNativeInputValue = () => {
|
||||
const input = inputOrTextarea.value
|
||||
if (!input || input.value === nativeInputValue.value) return
|
||||
input.value = nativeInputValue.value
|
||||
}
|
||||
|
||||
const calcIconOffset = place => {
|
||||
const { el } = instance.vnode
|
||||
const elList: HTMLSpanElement[] = Array.from(el.querySelectorAll(`.el-input__${place}`))
|
||||
const target = elList.find(item => item.parentNode === el)
|
||||
|
||||
if (!target) return
|
||||
|
||||
const pendant = PENDANT_MAP[place]
|
||||
|
||||
if (ctx.slots[pendant]) {
|
||||
target.style.transform = `translateX(${place === 'suffix' ? '-' : ''}${el.querySelector(`.el-input-group__${pendant}`).offsetWidth}px)`
|
||||
} else {
|
||||
target.removeAttribute('style')
|
||||
}
|
||||
}
|
||||
|
||||
const updateIconOffset = () => {
|
||||
calcIconOffset('prefix')
|
||||
calcIconOffset('suffix')
|
||||
}
|
||||
|
||||
const handleInput = event => {
|
||||
// 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 (event.target.value === nativeInputValue.value) return
|
||||
|
||||
ctx.emit(UPDATE_MODEL_EVENT, event.target.value)
|
||||
|
||||
// ensure native input value is controlled
|
||||
// see: https://github.com/ElemeFE/element/issues/12850
|
||||
nextTick(setNativeInputValue)
|
||||
}
|
||||
|
||||
const handleChange = event => {
|
||||
ctx.emit('change', event.target.value)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
inputOrTextarea.value.focus()
|
||||
}
|
||||
|
||||
const blur = () => {
|
||||
inputOrTextarea.value.blur()
|
||||
}
|
||||
|
||||
const handleFocus = event => {
|
||||
focused.value = true
|
||||
ctx.emit('focus', event)
|
||||
}
|
||||
|
||||
const handleBlur = event => {
|
||||
focused.value = false
|
||||
ctx.emit('blur', event)
|
||||
// if (props.validateEvent) {
|
||||
// this.dispatch('ElFormItem', 'el.form.blur', [props.modelValue])
|
||||
// }
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
inputOrTextarea.value.select()
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
const handleCompositionUpdate = event => {
|
||||
const text = event.target.value
|
||||
const lastCharacter = text[text.length - 1] || ''
|
||||
isComposing.value = !isKorean(lastCharacter)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = event => {
|
||||
if (isComposing.value) {
|
||||
isComposing.value = false
|
||||
handleInput(event)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
ctx.emit(UPDATE_MODEL_EVENT, '')
|
||||
ctx.emit('change', '')
|
||||
ctx.emit('clear')
|
||||
}
|
||||
|
||||
const handlePasswordVisible = () => {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
focus()
|
||||
}
|
||||
|
||||
const getSuffixVisible = () => {
|
||||
return ctx.slots.suffix ||
|
||||
props.suffixIcon ||
|
||||
showClear.value ||
|
||||
props.showPassword ||
|
||||
isWordLimitVisible.value ||
|
||||
(validateState.value && needStatusIcon.value)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
nextTick(resizeTextarea)
|
||||
// TODO: should dispatch event to parent component <el-form-item>;
|
||||
// if (props.validateEvent) {
|
||||
// dispatch('ElFormItem', 'el.form.change', [val])
|
||||
// }
|
||||
})
|
||||
|
||||
// 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, () => {
|
||||
nextTick(() => {
|
||||
setNativeInputValue()
|
||||
resizeTextarea()
|
||||
updateIconOffset()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setNativeInputValue()
|
||||
updateIconOffset()
|
||||
nextTick(resizeTextarea)
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
nextTick(updateIconOffset)
|
||||
})
|
||||
|
||||
return {
|
||||
input,
|
||||
textarea,
|
||||
inputSize,
|
||||
validateState,
|
||||
validateIcon,
|
||||
textareaStyle,
|
||||
inputDisabled,
|
||||
showClear,
|
||||
showPwdVisible,
|
||||
isWordLimitVisible,
|
||||
upperLimit,
|
||||
textLength,
|
||||
hovering,
|
||||
inputExceed,
|
||||
passwordVisible,
|
||||
handleInput,
|
||||
handleChange,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleCompositionStart,
|
||||
handleCompositionUpdate,
|
||||
handleCompositionEnd,
|
||||
handlePasswordVisible,
|
||||
clear,
|
||||
select,
|
||||
focus,
|
||||
blur,
|
||||
getSuffixVisible,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
</script>
|
@ -1,2 +1,8 @@
|
||||
export const UPDATE_MODEL_EVENT = 'update:modelValue'
|
||||
|
||||
export const VALIDATE_STATE_MAP = {
|
||||
validating: 'el-icon-loading',
|
||||
success: 'el-icon-circle-check',
|
||||
error: 'el-icon-circle-close',
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user