diff --git a/docs/examples-docs/number-keyboard.md b/docs/examples-docs/number-keyboard.md
new file mode 100644
index 000000000..362d1d1f8
--- /dev/null
+++ b/docs/examples-docs/number-keyboard.md
@@ -0,0 +1,101 @@
+
+
+
+
+## NumberKeyboard 数字键盘
+
+### 使用指南
+``` javascript
+import { NumberKeyboard } from 'vant';
+
+Vue.component(NumberKeyboard.name, NumberKeyboard);
+```
+
+### 代码演示
+
+#### 基础用法
+
+:::demo 基础用法
+```html
+
+ 弹出键盘
+
+
+
+ 收起键盘
+
+
+
+```
+
+```javascript
+export default {
+ data() {
+ return {
+ showKeyboard: true
+ }
+ },
+
+ methods: {
+ onInput(value) {
+ Toast(value);
+ },
+ onDelete() {
+ Toast('delete');
+ }
+ }
+}
+```
+:::
+
+
+### API
+
+| 参数 | 说明 | 类型 | 默认值 | 可选值 |
+|-----------|-----------|-----------|-------------|-------------|
+| show | 是否显示键盘 | `Boolean` | - | - |
+| title | 键盘标题 | `String` | `安全输入键盘` | - |
+| extraKey | 左下角按键内容 | `String` | `''` | - |
+| zIndex | 键盘 z-index | `Number` | `100` | - |
+| transition | 是否开启过场动画 | `Boolean` | `true` | - |
+| showDeleteKey | 是否展示删除按钮 | `Boolean` | `true` | - |
+
+### Event
+
+| 事件名 | 说明 | 参数 |
+|-----------|-----------|-----------|
+| input | 点击按键时触发 | key: 按键内容 |
+| delete | 点击删除键时触发 | - |
+| blur | 点击非键盘区域时触发 | - |
+| show | 键盘完全弹出时触发 | - |
+| hide | 键盘完全收起时触发 | - |
diff --git a/docs/src/doc.config.js b/docs/src/doc.config.js
index 492844c19..a840d170d 100644
--- a/docs/src/doc.config.js
+++ b/docs/src/doc.config.js
@@ -143,6 +143,10 @@ module.exports = {
"path": "/field",
"title": "Field 输入框"
},
+ {
+ "path": "/number-keyboard",
+ "title": "NumberKeyboard 数字键盘"
+ },
{
"path": "/radio",
"title": "Radio 单选框"
diff --git a/packages/index.js b/packages/index.js
index 2129658e0..08d141822 100644
--- a/packages/index.js
+++ b/packages/index.js
@@ -24,6 +24,7 @@ import Lazyload from './lazyload';
import Loading from './loading';
import NavBar from './nav-bar';
import NoticeBar from './notice-bar';
+import NumberKeyboard from './number-keyboard';
import Panel from './panel';
import Picker from './picker';
import Popup from './popup';
@@ -74,6 +75,7 @@ const components = [
Loading,
NavBar,
NoticeBar,
+ NumberKeyboard,
Panel,
Picker,
Popup,
@@ -140,6 +142,7 @@ export {
Loading,
NavBar,
NoticeBar,
+ NumberKeyboard,
Panel,
Picker,
Popup,
diff --git a/packages/number-keyboard/index.vue b/packages/number-keyboard/index.vue
new file mode 100644
index 000000000..41ac7d1f1
--- /dev/null
+++ b/packages/number-keyboard/index.vue
@@ -0,0 +1,137 @@
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
diff --git a/packages/vant-css/src/base.css b/packages/vant-css/src/base.css
index 5cbf9bc24..9a148e573 100644
--- a/packages/vant-css/src/base.css
+++ b/packages/vant-css/src/base.css
@@ -5,4 +5,5 @@
@import "./common/var.css";
@import "./common/normalize.css";
@import "./common/hairline.css";
+@import "./common/animation.css";
diff --git a/packages/vant-css/src/common/animation.css b/packages/vant-css/src/common/animation.css
new file mode 100644
index 000000000..070c8e1fc
--- /dev/null
+++ b/packages/vant-css/src/common/animation.css
@@ -0,0 +1,21 @@
+@keyframes van-slide-bottom-enter {
+ from {
+ transform: translate3d(0, 100%, 0);
+ }
+}
+
+@keyframes van-slide-bottom-leave {
+ to {
+ transform: translate3d(0, 100%, 0);
+ }
+}
+
+.van-slide-bottom {
+ &-enter-active {
+ animation: van-slide-bottom-enter .3s both ease;
+ }
+
+ &-leave-active {
+ animation: van-slide-bottom-leave .3s both ease;
+ }
+}
diff --git a/packages/vant-css/src/index.css b/packages/vant-css/src/index.css
index 140c245e2..545716d92 100644
--- a/packages/vant-css/src/index.css
+++ b/packages/vant-css/src/index.css
@@ -34,6 +34,7 @@
@import './radio.css';
@import './switch.css';
@import './uploader.css';
+@import './number-keyboard.css';
/* action components */
@import './actionsheet.css';
diff --git a/packages/vant-css/src/number-keyboard.css b/packages/vant-css/src/number-keyboard.css
new file mode 100644
index 000000000..fc2c4b39a
--- /dev/null
+++ b/packages/vant-css/src/number-keyboard.css
@@ -0,0 +1,52 @@
+@import "./common/var.css";
+
+.van-number-keyboard {
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ position: fixed;
+ user-select: none;
+ background-color: $white;
+ animation-timing-function: ease-out;
+
+ &__title {
+ font-weight: 400;
+ text-align: center;
+ color: $gray-dark;
+ font-size: 12px;
+ line-height: 25px;
+ }
+
+ i {
+ width: calc(100%/3);
+ height: 54px;
+ font-size: 24px;
+ line-height: 54px;
+ font-style: normal;
+ text-align: center;
+ display: inline-block;
+ vertical-align: middle;
+
+ &::after {
+ border-top-width: 1px;
+ }
+
+ &:not(:nth-of-type(3n))::after {
+ border-right-width: 1px;
+ }
+
+ &:nth-of-type(10),
+ &:nth-of-type(12) {
+ background-color: #F3F3F6;
+ }
+ }
+
+ &__delete {
+ background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAeCAMAAABg6AyVAAAAbFBMVEUAAAAfHiIdHB4eHR8dHR4eHB4dHB4dHR8gICIdHB4dHB4dHB4dHB8eHh8hISEeHR8fHB8fHR8fHR8fHx8eHiArKyszMzMeHB8eHB8fHR8eHiAeHh4dHB4vLjDY2Nn////b29zKysq9vb28vLzkfBRpAAAAHHRSTlMAK/PW+I/llBv77N1kSCPwWlFAOTMGBb28hHlu08g5sgAAAMlJREFUOMuV1MsWgiAQgGHQyOx+s+sgYO//jnnMGIdDDfwbN99CYEDQFiVEKkolPUG7gl9VTWC31NKuDbVz+Fc1tRJtPDmxS2BS3p5ZC+XXnnbAVoz2WEBCH7uZAalzGoa06whGiznT6sG2xgX4QO2Aej1+KN7XBKL2FvGaMtTWBhbQhtoaYzVQrHKwuGf8hhAPSF5g3xPSt45sCHcouNWx436FGA+RHyQcD35EcUj54U8ff4WYvVi1zLjelUh/OG6XjOeLWv5hfAOI+HLwwOAqhAAAAABJRU5ErkJggg==") no-repeat center center;
+ background-size: auto 15px;
+ }
+
+ i&--active {
+ background-color: $active-color!important;
+ }
+}
diff --git a/test/unit/specs/number-keyboard.spec.js b/test/unit/specs/number-keyboard.spec.js
new file mode 100644
index 000000000..526a65885
--- /dev/null
+++ b/test/unit/specs/number-keyboard.spec.js
@@ -0,0 +1,146 @@
+import NumberKeyboard from 'packages/number-keyboard';
+import { mount } from 'avoriaz';
+import { triggerTouch } from '../utils';
+
+function mockKeyDown(wrapper, keyIndex) {
+ const customEvent = document.createEvent('CustomEvent');
+ customEvent.initCustomEvent('touchstart', true, true, {});
+ Object.defineProperty(customEvent, 'target', {
+ value: {
+ dataset: {
+ key: keyIndex
+ }
+ }
+ });
+ wrapper.element.dispatchEvent(customEvent);
+}
+
+describe('NumberKeyboard', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper && wrapper.destroy();
+ });
+
+ it('create a NumberKeyboard', () => {
+ wrapper = mount(NumberKeyboard, {});
+ expect(wrapper.hasClass('van-number-keyboard')).to.be.true;
+ });
+
+ it('click a keyboard key', (done) => {
+ wrapper = mount(NumberKeyboard, {});
+
+ // just for coverage
+ wrapper.vm.handler(true);
+
+ wrapper.vm.$on('input', value => {
+ expect(value).to.equal(1);
+ expect(wrapper.vm.active).to.equal(0);
+
+ triggerTouch(wrapper, 'touchend');
+ expect(wrapper.vm.active).to.equal(-1);
+ done();
+ });
+
+ mockKeyDown(wrapper, 9);
+ mockKeyDown(wrapper, NaN);
+ mockKeyDown(wrapper, 0);
+ });
+
+ it('click delete key', (done) => {
+ wrapper = mount(NumberKeyboard, {});
+
+ const deleteSpy = sinon.spy();
+ wrapper.vm.$on('delete', deleteSpy);
+
+ mockKeyDown(wrapper, 11);
+ wrapper.vm.$nextTick(() => {
+ expect(deleteSpy.calledOnce).to.be.true;
+ done();
+ });
+ });
+
+ it('blur keyboard', (done) => {
+ wrapper = mount(NumberKeyboard, {
+ attachToDocument: true
+ });
+
+ const blur = sinon.spy();
+ wrapper.vm.$on('blur', blur);
+
+ triggerTouch(document.body, 'touchstart');
+ wrapper.vm.$nextTick(() => {
+ expect(blur.calledOnce).to.be.true;
+ done();
+ });
+ });
+
+ it('listen to show event when has transtion', (done) => {
+ wrapper = mount(NumberKeyboard, {
+ attachToDocument: true
+ });
+
+ const show = sinon.spy();
+ wrapper.vm.$on('show', show);
+ wrapper.vm.show = true;
+
+ setTimeout(() => {
+ expect(show.calledOnce).to.be.true;
+ done();
+ }, 800);
+ });
+
+ it('listen to show event when no transtion', (done) => {
+ wrapper = mount(NumberKeyboard, {
+ attachToDocument: true,
+ propsData: {
+ transition: false
+ }
+ });
+
+ const show = sinon.spy();
+ wrapper.vm.$on('show', show);
+ wrapper.vm.show = true;
+
+ wrapper.vm.$nextTick(() => {
+ expect(show.calledOnce).to.be.true;
+ done();
+ });
+ });
+
+ it('listen to hide event when has transtion', (done) => {
+ wrapper = mount(NumberKeyboard, {
+ attachToDocument: true,
+ propsData: {
+ show: true
+ }
+ });
+
+ const hide = sinon.spy();
+ wrapper.vm.$on('hide', hide);
+ wrapper.vm.show = false;
+
+ setTimeout(() => {
+ expect(hide.calledOnce).to.be.true;
+ done();
+ }, 800);
+ });
+
+ it('listen to hide event when no transtion', (done) => {
+ wrapper = mount(NumberKeyboard, {
+ attachToDocument: true,
+ propsData: {
+ show: true,
+ transition: false
+ }
+ });
+
+ const hide = sinon.spy();
+ wrapper.vm.$on('hide', hide);
+ wrapper.vm.show = false;
+
+ wrapper.vm.$nextTick(() => {
+ expect(hide.calledOnce).to.be.true;
+ done();
+ });
+ });
+});