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; +}