diff --git a/src/quickapp/components/button/__test__/button.test.js b/src/quickapp/components/button/__test__/button.test.js
new file mode 100644
index 000000000..ddf64052b
--- /dev/null
+++ b/src/quickapp/components/button/__test__/button.test.js
@@ -0,0 +1,72 @@
+import Nerv from 'nervjs'
+import { renderIntoDocument, Simulate } from 'nerv-test-utils'
+import Button from '../index'
+
+const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+const hoverStartTime = 20
+const hoverStayTime = 70
+
+describe('Button', () => {
+ it('render Button', () => {
+ const component = renderIntoDocument()
+ expect(component.props.disabled).toBeFalsy()
+ })
+
+ it('render Button disabled', () => {
+ const component = renderIntoDocument()
+ expect(component.props.disabled).toBeTruthy()
+ })
+
+ it('show loading Botton', () => {
+ const component = renderIntoDocument()
+ expect(component.props.loading).toBeTruthy()
+ })
+
+ it('should trigger touchStart and touchEnd', async () => {
+ const hoverClass = 'hoverclass'
+ const onTouchStart = jest.fn()
+ const onTouchEnd = jest.fn()
+ let btnIns
+ const view =
+ const component = renderIntoDocument(view)
+ const dom = Nerv.findDOMNode(component)
+
+ Simulate.touchStart(dom)
+ expect(onTouchStart).toHaveBeenCalled()
+
+ Simulate.touchEnd(dom)
+ await delay(hoverStartTime)
+ expect(dom.getAttribute('class')).not.toContain(hoverClass)
+
+ Simulate.touchStart(dom)
+ await delay(hoverStartTime)
+ expect(btnIns.state.touch).toBeTruthy()
+ expect(dom.getAttribute('class')).toContain(hoverClass)
+
+ Simulate.touchEnd(dom)
+ expect(onTouchEnd).toHaveBeenCalled()
+
+ Simulate.touchStart(dom)
+ await delay(hoverStayTime)
+ expect(dom.getAttribute('class')).toContain(hoverClass)
+
+ Simulate.touchEnd(dom)
+ await delay(hoverStayTime)
+ expect(dom.getAttribute('class')).not.toContain(hoverClass)
+ })
+
+ it('should not execute set hoverClass when hoverClass is undefined', async () => {
+ let btnIns
+ const view =
+ const component = renderIntoDocument(view)
+ const dom = Nerv.findDOMNode(component)
+ Simulate.touchStart(dom)
+ expect(btnIns.state.touch).toBeFalsy()
+ await delay(hoverStartTime)
+ expect(btnIns.state.hover).toBeTruthy()
+
+ Simulate.touchEnd(dom)
+ await delay(hoverStayTime)
+ expect(btnIns.state.hover).toBeFalsy()
+ })
+})
diff --git a/src/quickapp/components/button/index.js b/src/quickapp/components/button/index.js
new file mode 100644
index 000000000..43d5c76df
--- /dev/null
+++ b/src/quickapp/components/button/index.js
@@ -0,0 +1,94 @@
+import 'weui'
+import Nerv from 'nervjs'
+import omit from 'omit.js'
+import classNames from 'classnames'
+
+import '../../../style/components-qa/button.scss'
+
+class Button extends Nerv.Component {
+ constructor () {
+ super(...arguments)
+ this.state = {
+ hover: false,
+ touch: false
+ }
+ }
+
+ render () {
+ const {
+ children,
+ disabled,
+ className,
+ style,
+ onClick,
+ onTouchStart,
+ onTouchEnd,
+ hoverClass = 'button-hover',
+ hoverStartTime = 20,
+ hoverStayTime = 70,
+ size,
+ plain,
+ loading = false,
+ type = 'default'
+ } = this.props
+ const cls = className || classNames(
+ 'weui-btn',
+ {
+ [`${hoverClass}`]: this.state.hover && !disabled,
+ [`weui-btn_plain-${type}`]: plain,
+ [`weui-btn_${type}`]: !plain && type,
+ 'weui-btn_mini': size === 'mini',
+ 'weui-btn_loading': loading,
+ 'weui-btn_disabled': disabled
+ }
+ )
+
+ const _onTouchStart = e => {
+ this.setState(() => ({
+ touch: true
+ }))
+ if (hoverClass && !disabled) {
+ setTimeout(() => {
+ if (this.state.touch) {
+ this.setState(() => ({
+ hover: true
+ }))
+ }
+ }, hoverStartTime)
+ }
+ onTouchStart && onTouchStart(e)
+ }
+ const _onTouchEnd = e => {
+ this.setState(() => ({
+ touch: false
+ }))
+ if (hoverClass && !disabled) {
+ setTimeout(() => {
+ if (!this.state.touch) {
+ this.setState(() => ({
+ hover: false
+ }))
+ }
+ }, hoverStayTime)
+ }
+ onTouchEnd && onTouchEnd(e)
+ }
+
+ return (
+
+ )
+ }
+}
+
+export default Button
diff --git a/src/quickapp/components/button/index.md b/src/quickapp/components/button/index.md
new file mode 100644
index 000000000..606b0a2a7
--- /dev/null
+++ b/src/quickapp/components/button/index.md
@@ -0,0 +1,20 @@
+button
+
+## API
+
+| | 属性 | 类型 | 默认值 | 说明 |
+| --- | ---------------------- | ------- | ------------ | ------------------------------------------------------------------------------------------- |
+| √ | type | String | default | 按钮的样式类型 |
+| √ | size | String | default | 按钮的大小 px |
+| √ | plain | Boolean | false | 按钮是否镂空,背景色透明 |
+| √ | disabled | Boolean | false | 是否禁用 |
+| √ | loading | Boolean | false | 名称前是否带 loading 图标 |
+| | form-type | String | | 用于 form 组件,点击分别会触发 form 组件的 submit/reset 事件 |
+| | open-type | String | | 微信开放能力 |
+| | app-parameter | String | | 打开 APP 时,向 APP 传递的参数 |
+| √ | hover-class | String | button-hover | 指定按钮按下去的样式类。当 hover-class="none" 时,没有点击态效果 |
+| | hover-stop-propagation | Boolean | false | 指定是否阻止本节点的祖先节点出现点击态 |
+| √ | hover-start-time | Number | 20 | 按住后多久出现点击态,单位毫秒 |
+| √ | hover-stay-time | Number | 70 | 手指松开后点击态保留时间,单位毫秒 |
+| | bindgetuserinfo | Handler | | 用户点击该按钮时,会返回获取到的用户信息,从返回参数的 detail 中获取到的值同 wx.getUserInfo |
+| | lang | String | en | 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。 |
diff --git a/src/quickapp/index.js b/src/quickapp/index.js
new file mode 100644
index 000000000..14bddb05a
--- /dev/null
+++ b/src/quickapp/index.js
@@ -0,0 +1 @@
+export { default as Button } from './components/button'
diff --git a/src/quickapp/utils/hoverable.js b/src/quickapp/utils/hoverable.js
new file mode 100644
index 000000000..f07aa2f4b
--- /dev/null
+++ b/src/quickapp/utils/hoverable.js
@@ -0,0 +1,114 @@
+import Taro from '@tarojs/taro-h5'
+import Nerv from 'nervjs'
+import omit from 'omit.js'
+
+/**
+ * 添加touch能力
+ * @param {Object} Options hoverable的默认配置
+ * @param {String} [Options.hoverClass] 指定点击时的样式类,当hover-class="none"时,没有点击态效果
+ * @param {Boolean} [Options.hoverStopPropergation] 指定是否阻止本节点的祖先节点出现点击态
+ * @param {Number} [Options.hoverStartTime] 按住后多久出现点击态,单位毫秒
+ * @param {Number} [Options.hoverStayTime] 手指松开后点击态保留时间,单位毫秒
+ */
+const hoverable = ({
+ hoverClass,
+ hoverStopPropergation,
+ hoverStartTime,
+ hoverStayTime
+}) => {
+ return ComponentClass => {
+ return class HoverableComponent extends Taro.Component {
+ static defaultProps = {
+ hoverClass,
+ hoverStopPropergation,
+ hoverStartTime,
+ hoverStayTime
+ }
+ constructor (props, ctx) {
+ super(props, ctx)
+ this.state = this.getInitState(this.props)
+ }
+
+ touchStartTimer = null
+ touchEndTimer = null
+
+ state = {
+ isHover: false,
+ onTouchStart: null,
+ onTouchEnd: null
+ }
+
+ getInitState = ({ hoverClass, hoverStartTime, hoverStayTime, hoverStopPropergation, onTouchStart, onTouchEnd }) => {
+ if (hoverClass === 'none') return {}
+ return {
+ onTouchStart: this.getOnTouchStart({ hoverStartTime, hoverStopPropergation, onTouchStart }),
+ onTouchEnd: this.getOnTouchEnd({ hoverStayTime, hoverStopPropergation, onTouchEnd })
+ }
+ }
+ getOnTouchStart = ({ hoverStartTime, hoverStopPropergation, onTouchStart }) => {
+ return e => {
+ onTouchStart && onTouchStart(e)
+ hoverStopPropergation && e.stopPropergation()
+ this.touchStartTimer && clearTimeout(this.touchStartTimer)
+ this.touchEndTimer && clearTimeout(this.touchEndTimer)
+ this.touchStartTimer = setTimeout(() => {
+ this.setState({
+ isHover: true
+ })
+ }, hoverStartTime)
+ }
+ }
+ getOnTouchEnd = ({ hoverStayTime, hoverStopPropergation, onTouchEnd }) => {
+ return e => {
+ onTouchEnd && onTouchEnd(e)
+ hoverStopPropergation && e.stopPropergation()
+ this.touchStartTimer && clearTimeout(this.touchStartTimer)
+ this.touchEndTimer && clearTimeout(this.touchEndTimer)
+ this.touchEndTimer = setTimeout(() => {
+ this.setState({
+ isHover: false
+ })
+ }, hoverStayTime)
+ }
+ }
+ reset = () => {
+ this.setState({
+ isHover: false
+ })
+ }
+ componentWillMount () {
+ document.body.addEventListener('touchstart', this.reset)
+ }
+ componentWillReceiveProps (nProps, nCtx) {
+ if (
+ nProps.hoverClass !== this.props.hoverClass ||
+ nProps.hoverStopPropergation !== this.props.hoverStopPropergation ||
+ nProps.hoverStartTime !== this.props.hoverStartTime ||
+ nProps.hoverStayTime !== this.props.hoverStayTime
+ ) {
+ const stateObj = this.getInitState(nProps)
+ this.setState(stateObj)
+ }
+ }
+ componentWillUnmount () {
+ document.body.removeEventListener('touchstart', this.reset)
+ }
+ render () {
+ const { isHover, onTouchStart, onTouchEnd } = this.state
+ const props = {
+ ...omit(this.props, [
+ 'hoverStopPropergation',
+ 'hoverStartTime',
+ 'hoverStayTime'
+ ]),
+ isHover,
+ onTouchStart,
+ onTouchEnd
+ }
+ return
+ }
+ }
+ }
+}
+
+export default hoverable
diff --git a/src/quickapp/utils/index.js b/src/quickapp/utils/index.js
new file mode 100644
index 000000000..e3d2416f7
--- /dev/null
+++ b/src/quickapp/utils/index.js
@@ -0,0 +1,104 @@
+export function throttle (fn, threshhold, scope) {
+ threshhold || (threshhold = 250)
+ let last, deferTimer
+ return function () {
+ let context = scope || this
+
+ let now = +new Date()
+ let args = arguments
+ if (last && now < last + threshhold) {
+ clearTimeout(deferTimer)
+ deferTimer = setTimeout(() => {
+ last = now
+ fn.apply(context, args)
+ }, threshhold)
+ } else {
+ last = now
+ fn.apply(context, args)
+ }
+ }
+}
+
+export const normalizePath = url => {
+ let _isRelative
+ let _leadingParents = ''
+ let _parent, _pos
+
+ // handle relative paths
+ if (url.charAt(0) !== '/') {
+ _isRelative = true
+ url = '/' + url
+ }
+
+ // handle relative files (as opposed to directories)
+ if (url.substring(-3) === '/..' || url.slice(-2) === '/.') {
+ url += '/'
+ }
+
+ // resolve simples
+ url = url.replace(/(\/(\.\/)+)|(\/\.$)/g, '/').replace(/\/{2,}/g, '/')
+
+ // remember leading parents
+ if (_isRelative) {
+ _leadingParents = url.substring(1).match(/^(\.\.\/)+/) || ''
+ if (_leadingParents) {
+ _leadingParents = _leadingParents[0]
+ }
+ }
+
+ // resolve parents
+ while (true) {
+ _parent = url.search(/\/\.\.(\/|$)/)
+ if (_parent === -1) {
+ // no more ../ to resolve
+ break
+ } else if (_parent === 0) {
+ // top level cannot be relative, skip it
+ url = url.substring(3)
+ continue
+ }
+
+ _pos = url.substring(0, _parent).lastIndexOf('/')
+ if (_pos === -1) {
+ _pos = _parent
+ }
+ url = url.substring(0, _pos) + url.substring(_parent + 3)
+ }
+
+ // revert to relative
+ if (_isRelative) {
+ url = _leadingParents + url.substring(1)
+ }
+
+ return url
+}
+
+export const splitUrl = _url => {
+ let url = _url || ''
+ let pos
+ let res = {
+ path: null,
+ query: null,
+ fragment: null
+ }
+
+ pos = url.indexOf('#')
+ if (pos > -1) {
+ res.fragment = url.substring(pos + 1)
+ url = url.substring(0, pos)
+ }
+
+ pos = url.indexOf('?')
+ if (pos > -1) {
+ res.query = url.substring(pos + 1)
+ url = url.substring(0, pos)
+ }
+
+ res.path = url
+
+ return res
+}
+
+export const isNumber = obj => {
+ return typeof obj === 'number'
+}
diff --git a/src/quickapp/utils/parse-type.js b/src/quickapp/utils/parse-type.js
new file mode 100644
index 000000000..3074662ed
--- /dev/null
+++ b/src/quickapp/utils/parse-type.js
@@ -0,0 +1,137 @@
+/** lodash BOF */
+const objectProto = Object.prototype
+const hasOwnProperty = objectProto.hasOwnProperty
+const toString = objectProto.toString
+const symToStringTag =
+ typeof Symbol !== 'undefined' ? Symbol.toStringTag : undefined
+/** `Object#toString` result references. */
+const dataViewTag = '[object DataView]'
+const mapTag = '[object Map]'
+const objectTag = '[object Object]'
+const promiseTag = '[object Promise]'
+const setTag = '[object Set]'
+const weakMapTag = '[object WeakMap]'
+
+/** Used to detect maps, sets, and weakmaps. */
+const dataViewCtorString = `${DataView}`
+const mapCtorString = `${Map}`
+const promiseCtorString = `${Promise}`
+const setCtorString = `${Set}`
+const weakMapCtorString = `${WeakMap}`
+
+let getTag = baseGetTag
+
+if (
+ (DataView && getTag(new DataView(new ArrayBuffer(1))) !== dataViewTag) ||
+ getTag(new Map()) !== mapTag ||
+ getTag(Promise.resolve()) !== promiseTag ||
+ getTag(new Set()) !== setTag ||
+ getTag(new WeakMap()) !== weakMapTag
+) {
+ getTag = value => {
+ const result = baseGetTag(value)
+ const Ctor = result === objectTag ? value.constructor : undefined
+ const ctorString = Ctor ? `${Ctor}` : ''
+
+ if (ctorString) {
+ switch (ctorString) {
+ case dataViewCtorString:
+ return dataViewTag
+ case mapCtorString:
+ return mapTag
+ case promiseCtorString:
+ return promiseTag
+ case setCtorString:
+ return setTag
+ case weakMapCtorString:
+ return weakMapTag
+ }
+ }
+ return result
+ }
+}
+
+function isObjectLike (value) {
+ return typeof value === 'object' && value !== null
+}
+
+function baseGetTag (value) {
+ if (value == null) {
+ return value === undefined ? '[object Undefined]' : '[object Null]'
+ }
+ if (!(symToStringTag && symToStringTag in Object(value))) {
+ return toString.call(value)
+ }
+ const isOwn = hasOwnProperty.call(value, symToStringTag)
+ const tag = value[symToStringTag]
+ let unmasked = false
+ try {
+ value[symToStringTag] = undefined
+ unmasked = true
+ } catch (e) {}
+
+ const result = toString.call(value)
+ if (unmasked) {
+ if (isOwn) {
+ value[symToStringTag] = tag
+ } else {
+ delete value[symToStringTag]
+ }
+ }
+ return result
+}
+
+function isBoolean (value) {
+ return (
+ value === true ||
+ value === false ||
+ (isObjectLike(value) && baseGetTag(value) === '[object Boolean]')
+ )
+}
+
+function isNumber (value) {
+ return (
+ typeof value === 'number' ||
+ (isObjectLike(value) && baseGetTag(value) === '[object Number]')
+ )
+}
+
+function isString (value) {
+ const type = typeof value
+ return (
+ type === 'string' ||
+ (type === 'object' &&
+ value != null &&
+ !Array.isArray(value) &&
+ getTag(value) === '[object String]')
+ )
+}
+
+function isObject (value) {
+ const type = typeof value
+ return value != null && (type === 'object' || type === 'function')
+}
+
+function isFunction (value) {
+ if (!isObject(value)) {
+ return false
+ }
+ // The use of `Object#toString` avoids issues with the `typeof` operator
+ // in Safari 9 which returns 'object' for typed arrays and other constructors.
+ const tag = baseGetTag(value)
+ return (
+ tag === '[object Function]' ||
+ tag === '[object AsyncFunction]' ||
+ tag === '[object GeneratorFunction]' ||
+ tag === '[object Proxy]'
+ )
+}
+
+/** lodash EOF */
+
+export {
+ isBoolean,
+ isNumber,
+ isString,
+ isFunction
+}
diff --git a/src/quickapp/utils/touchable.js b/src/quickapp/utils/touchable.js
new file mode 100644
index 000000000..9c7c8a1ae
--- /dev/null
+++ b/src/quickapp/utils/touchable.js
@@ -0,0 +1,106 @@
+import Taro from '@tarojs/taro-h5'
+import omit from 'omit.js'
+import Nerv from 'nervjs'
+
+function getOffset (el) {
+ const rect = el.getBoundingClientRect()
+ const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop
+ return { offsetY: rect.top + scrollTop, offsetX: rect.left + scrollLeft }
+}
+
+/**
+ * 将DOM标准的touches转换为wx的标准
+ * @param {TouchList} touches
+ */
+
+const transformTouches = (touches, { offsetX, offsetY }) => {
+ const wxTouches = []
+ const touchCnt = touches.length
+ for (let idx = 0; idx < touchCnt; idx++) {
+ const touch = touches.item(idx)
+ wxTouches.push({
+ x: touch.pageX - offsetX,
+ y: touch.pageY - offsetY,
+ identifier: touch.identifier
+ })
+ }
+ return wxTouches
+}
+
+const touchable = (opt = {
+ longTapTime: 500
+}) => {
+ return ComponentClass => {
+ return class TouchableComponent extends Taro.Component {
+ static defaultProps = {
+ onTouchStart: null,
+ onTouchMove: null,
+ onTouchEnd: null,
+ onTouchCancel: null,
+ onLongTap: null
+ }
+ timer = null
+ offset = {
+ offsetX: 0,
+ offsetY: 0
+ }
+
+ onTouchStart = e => {
+ const { onTouchStart, onLongTap } = this.props
+ Object.defineProperty(e, 'touches', { value: transformTouches(e.touches, this.offset) })
+ onTouchStart && onTouchStart(e)
+ this.timer = setTimeout(() => {
+ onLongTap && onLongTap(e)
+ }, opt.longTapTime)
+ }
+ onTouchMove = e => {
+ this.timer && clearTimeout(this.timer)
+ const { onTouchMove } = this.props
+ Object.defineProperty(e, 'touches', { value: transformTouches(e.touches, this.offset) })
+ onTouchMove && onTouchMove(e)
+ }
+ onTouchEnd = e => {
+ this.timer && clearTimeout(this.timer)
+ const { onTouchEnd } = this.props
+ Object.defineProperty(e, 'touches', { value: transformTouches(e.touches, this.offset) })
+ onTouchEnd && onTouchEnd(e)
+ }
+ onTouchCancel = e => {
+ this.timer && clearTimeout(this.timer)
+ const { onTouchCancel } = this.props
+ Object.defineProperty(e, 'touches', { value: transformTouches(e.touches, this.offset) })
+ onTouchCancel && onTouchCancel(e)
+ }
+ updatePos = () => {
+ const { offsetX, offsetY } = getOffset(this.vnode.dom)
+ this.offset.offsetX = offsetX
+ this.offset.offsetY = offsetY
+ }
+ componentDidMount () {
+ this.updatePos()
+ }
+ componentDidUpdate () {
+ this.updatePos()
+ }
+ render () {
+ const props = {
+ onTouchStart: this.onTouchStart,
+ onTouchMove: this.onTouchMove,
+ onTouchEnd: this.onTouchEnd,
+ onTouchCancel: this.onTouchCancel,
+ ...omit(this.props, [
+ 'onTouchStart',
+ 'onTouchMove',
+ 'onTouchEnd',
+ 'onTouchCancel',
+ 'onLongTap'
+ ])
+ }
+ return
+ }
+ }
+ }
+}
+
+export default touchable
diff --git a/src/style/components-qa/button.scss b/src/style/components-qa/button.scss
new file mode 100644
index 000000000..3185ef3cd
--- /dev/null
+++ b/src/style/components-qa/button.scss
@@ -0,0 +1,48 @@
+button {
+ position: relative;
+ display: block;
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ padding-left: 14px;
+ padding-right: 14px;
+ box-sizing: border-box;
+ font-size: 18px;
+ text-align: center;
+ text-decoration: none;
+ line-height: 2.55555556;
+ border-radius: 5px;
+ -webkit-tap-highlight-color: transparent;
+ overflow: hidden;
+ color: #000000;
+ background-color: #F8F8F8;
+}
+
+button[plain] {
+ color: #353535;
+ border: 1px solid #353535;
+ background-color: transparent;
+}
+
+button[plain][disabled] {
+ color: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ background-color: #F7F7F7;
+}
+
+button[type=primary] {
+ color: #FFFFFF;
+ background-color: #1AAD19;
+}
+
+button[type=primary][plain] {
+ color: #1aad19;
+ border: 1px solid #1aad19;
+ background-color: transparent;
+}
+
+button[type=primary][plain][disabled] {
+ color: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ background-color: #F7F7F7;
+}