Commit d04fb45a0ebb7342ca3f516a50011e252f1446ff
Committed by
GitHub
Merge pull request #2007 from SergioCrisostomo/scroll-component
New feature: Scroll component
Showing
9 changed files
with
398 additions
and
8 deletions
Show diff stats
.eslintrc.json
package-lock.json
| ... | ... | @@ -12511,6 +12511,11 @@ |
| 12511 | 12511 | "lodash.escape": "3.2.0" |
| 12512 | 12512 | } |
| 12513 | 12513 | }, |
| 12514 | + "lodash.throttle": { | |
| 12515 | + "version": "4.1.1", | |
| 12516 | + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", | |
| 12517 | + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" | |
| 12518 | + }, | |
| 12514 | 12519 | "lodash.uniq": { |
| 12515 | 12520 | "version": "4.5.0", |
| 12516 | 12521 | "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", | ... | ... |
package.json
| 1 | + | |
| 2 | +<template lang="html"> | |
| 3 | + <div :class="wrapperClasses"> | |
| 4 | + <div :class="spinnerClasses"> | |
| 5 | + <Spin fix> | |
| 6 | + <Icon type="load-c" size="18" :class="iconClasses"></Icon> | |
| 7 | + <div v-if="text" :class="textClasses">{{text}}</div> | |
| 8 | + </Spin> | |
| 9 | + | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | +</template> | |
| 13 | + | |
| 14 | +<script> | |
| 15 | +const prefixCls = 'ivu-scroll'; | |
| 16 | + | |
| 17 | +export default { | |
| 18 | + props: ['text', 'active', 'spinnerHeight'], | |
| 19 | + computed: { | |
| 20 | + wrapperClasses() { | |
| 21 | + return [ | |
| 22 | + `${prefixCls}-loader-wrapper`, | |
| 23 | + { | |
| 24 | + [`${prefixCls}-loader-wrapper-active`]: this.active | |
| 25 | + } | |
| 26 | + ]; | |
| 27 | + }, | |
| 28 | + spinnerClasses() { | |
| 29 | + return `${prefixCls}-spinner`; | |
| 30 | + }, | |
| 31 | + iconClasses() { | |
| 32 | + return `${prefixCls}-spinner-icon`; | |
| 33 | + }, | |
| 34 | + textClasses() { | |
| 35 | + return `${prefixCls}-loader-text`; | |
| 36 | + } | |
| 37 | + } | |
| 38 | +}; | |
| 39 | +</script> | ... | ... |
| 1 | + | |
| 2 | +<template> | |
| 3 | + <div :class="wrapClasses" style="touch-action: none;"> | |
| 4 | + <div | |
| 5 | + :class="scrollContainerClasses" | |
| 6 | + @scroll="handleScroll" | |
| 7 | + @wheel="onWheel" | |
| 8 | + @touchstart="onPointerDown" | |
| 9 | + ref="scrollContainer" | |
| 10 | + > | |
| 11 | + <div :class="loaderClasses" :style="{paddingTop: wrapperPadding.paddingTop}" ref="toploader"> | |
| 12 | + <loader :text="loadingText" :active="showTopLoader"></loader> | |
| 13 | + </div> | |
| 14 | + <div :class="slotContainerClasses" ref="scrollContent"> | |
| 15 | + <slot></slot> | |
| 16 | + </div> | |
| 17 | + <div :class="loaderClasses" :style="{paddingBottom: wrapperPadding.paddingBottom}" ref="bottomLoader"> | |
| 18 | + <loader :text="loadingText" :active="showBottomLoader"></loader> | |
| 19 | + </div> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | +</template> | |
| 23 | +<script> | |
| 24 | + import throttle from 'lodash.throttle'; | |
| 25 | + import loader from './loading-component.vue'; | |
| 26 | + | |
| 27 | + const prefixCls = 'ivu-scroll'; | |
| 28 | + const dragConfig = { | |
| 29 | + sensitivity: 10, | |
| 30 | + minimumStartDragOffset: 5, // minimum start drag offset | |
| 31 | + }; | |
| 32 | + | |
| 33 | + export default { | |
| 34 | + name: 'Scroll', | |
| 35 | + mixins: [], | |
| 36 | + components: {loader}, | |
| 37 | + props: { | |
| 38 | + onReachTop: { | |
| 39 | + type: Function, | |
| 40 | + default: () => Promise.resolve() | |
| 41 | + }, | |
| 42 | + onReachBottom: { | |
| 43 | + type: Function, | |
| 44 | + default: () => Promise.resolve() | |
| 45 | + }, | |
| 46 | + onReachEdge: { | |
| 47 | + type: Function, | |
| 48 | + default: () => Promise.resolve() | |
| 49 | + }, | |
| 50 | + loadingText: { | |
| 51 | + type: String, | |
| 52 | + default: '' | |
| 53 | + } | |
| 54 | + }, | |
| 55 | + data() { | |
| 56 | + return { | |
| 57 | + showTopLoader: false, | |
| 58 | + showBottomLoader: false, | |
| 59 | + showBodyLoader: false, | |
| 60 | + lastScroll: 0, | |
| 61 | + reachedTopScrollLimit: true, | |
| 62 | + reachedBottomScrollLimit: false, | |
| 63 | + topRubberPadding: 0, | |
| 64 | + bottomRubberPadding: 0, | |
| 65 | + rubberRollBackTimeout: false, | |
| 66 | + isLoading: false, | |
| 67 | + pointerTouchDown: null, | |
| 68 | + touchScroll: false, | |
| 69 | + handleScroll: () => {}, | |
| 70 | + pointerUpHandler: () => {}, | |
| 71 | + pointerMoveHandler: () => {}, | |
| 72 | + }; | |
| 73 | + }, | |
| 74 | + computed: { | |
| 75 | + wrapClasses() { | |
| 76 | + return `${prefixCls}-wrapper`; | |
| 77 | + }, | |
| 78 | + scrollContainerClasses() { | |
| 79 | + return `${prefixCls}-container`; | |
| 80 | + }, | |
| 81 | + slotContainerClasses() { | |
| 82 | + return [ | |
| 83 | + `${prefixCls}-content`, | |
| 84 | + { | |
| 85 | + [`${prefixCls}-content-loading`]: this.showBodyLoader | |
| 86 | + } | |
| 87 | + ]; | |
| 88 | + }, | |
| 89 | + loaderClasses() { | |
| 90 | + return `${prefixCls}-loader`; | |
| 91 | + }, | |
| 92 | + wrapperPadding() { | |
| 93 | + return { | |
| 94 | + paddingTop: this.topRubberPadding + 'px', | |
| 95 | + paddingBottom: this.bottomRubberPadding + 'px' | |
| 96 | + }; | |
| 97 | + } | |
| 98 | + }, | |
| 99 | + methods: { | |
| 100 | + // just to improve feeling of loading and avoid scroll trailing events fired by the browser | |
| 101 | + waitOneSecond() { | |
| 102 | + return new Promise(resolve => { | |
| 103 | + setTimeout(resolve, 2000); | |
| 104 | + }); | |
| 105 | + }, | |
| 106 | + | |
| 107 | + onCallback(dir) { | |
| 108 | + this.isLoading = true; | |
| 109 | + this.showBodyLoader = true; | |
| 110 | + if (dir > 0) { | |
| 111 | + this.showTopLoader = true; | |
| 112 | + this.topRubberPadding = 20; | |
| 113 | + } else { | |
| 114 | + this.showBottomLoader = true; | |
| 115 | + this.bottomRubberPadding = 20; | |
| 116 | + | |
| 117 | + // to force the scroll to the bottom while height is animating | |
| 118 | + let bottomLoaderHeight = 0; | |
| 119 | + const container = this.$refs.scrollContainer; | |
| 120 | + const initialScrollTop = container.scrollTop; | |
| 121 | + for (var i = 0; i < 20; i++) { | |
| 122 | + setTimeout(() => { | |
| 123 | + bottomLoaderHeight = Math.max( | |
| 124 | + bottomLoaderHeight, | |
| 125 | + this.$refs.bottomLoader.getBoundingClientRect().height | |
| 126 | + ); | |
| 127 | + container.scrollTop = initialScrollTop + bottomLoaderHeight; | |
| 128 | + }, i * 50); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + const callbacks = [this.waitOneSecond(), this.onReachEdge(dir)]; | |
| 133 | + callbacks.push(dir > 0 ? this.onReachTop() : this.onReachBottom()); | |
| 134 | + | |
| 135 | + let tooSlow = setTimeout(() => { | |
| 136 | + this.reset(); | |
| 137 | + }, 5000); | |
| 138 | + | |
| 139 | + Promise.all(callbacks).then(() => { | |
| 140 | + clearTimeout(tooSlow); | |
| 141 | + this.reset(); | |
| 142 | + }); | |
| 143 | + }, | |
| 144 | + | |
| 145 | + reset() { | |
| 146 | + [ | |
| 147 | + 'showTopLoader', | |
| 148 | + 'showBottomLoader', | |
| 149 | + 'showBodyLoader', | |
| 150 | + 'isLoading', | |
| 151 | + 'reachedTopScrollLimit', | |
| 152 | + 'reachedBottomScrollLimit' | |
| 153 | + ].forEach(prop => (this[prop] = false)); | |
| 154 | + | |
| 155 | + this.lastScroll = 0; | |
| 156 | + this.topRubberPadding = 0; | |
| 157 | + this.bottomRubberPadding = 0; | |
| 158 | + clearInterval(this.rubberRollBackTimeout); | |
| 159 | + | |
| 160 | + // if we remove the handler too soon the screen will bump | |
| 161 | + if (this.touchScroll) { | |
| 162 | + setTimeout(() => { | |
| 163 | + window.removeEventListener('touchend', this.pointerUpHandler); | |
| 164 | + this.$refs.scrollContainer.removeEventListener('touchmove', this.pointerMoveHandler); | |
| 165 | + this.touchScroll = false; | |
| 166 | + }, 500); | |
| 167 | + } | |
| 168 | + }, | |
| 169 | + | |
| 170 | + onWheel() { | |
| 171 | + if (this.isLoading) return; | |
| 172 | + | |
| 173 | + // get the wheel direction | |
| 174 | + const wheelDelta = event.wheelDelta ? event.wheelDelta : -(event.detail || event.deltaY); | |
| 175 | + this.stretchEdge(wheelDelta); | |
| 176 | + }, | |
| 177 | + | |
| 178 | + stretchEdge(direction) { | |
| 179 | + clearTimeout(this.rubberRollBackTimeout); | |
| 180 | + | |
| 181 | + // if the scroll is not strong enough, lets reset it | |
| 182 | + this.rubberRollBackTimeout = setTimeout(() => { | |
| 183 | + if (!this.isLoading) this.reset(); | |
| 184 | + }, 250); | |
| 185 | + | |
| 186 | + // to give the feeling its ruberish and can be puled more to start loading | |
| 187 | + if (direction > 0 && this.reachedTopScrollLimit) { | |
| 188 | + this.topRubberPadding += 5 - this.topRubberPadding / 5; | |
| 189 | + if (this.topRubberPadding > 20) this.onCallback(1); | |
| 190 | + } else if (direction < 0 && this.reachedBottomScrollLimit) { | |
| 191 | + this.bottomRubberPadding += 6 - this.bottomRubberPadding / 4; | |
| 192 | + if (this.bottomRubberPadding > 20) this.onCallback(-1); | |
| 193 | + } else { | |
| 194 | + this.onScroll(); | |
| 195 | + } | |
| 196 | + }, | |
| 197 | + | |
| 198 | + onScroll() { | |
| 199 | + if (this.isLoading) return; | |
| 200 | + const el = this.$refs.scrollContainer; | |
| 201 | + const scrollDirection = Math.sign(this.lastScroll - el.scrollTop); // IE has no Math.sign, check that webpack polyfills this | |
| 202 | + const displacement = el.scrollHeight - el.clientHeight - el.scrollTop; | |
| 203 | + | |
| 204 | + if (scrollDirection == -1 && displacement <= dragConfig.sensitivity) { | |
| 205 | + this.reachedBottomScrollLimit = true; | |
| 206 | + } else if (scrollDirection >= 0 && el.scrollTop == 0) { | |
| 207 | + this.reachedTopScrollLimit = true; | |
| 208 | + } else { | |
| 209 | + this.reachedTopScrollLimit = false; | |
| 210 | + this.reachedBottomScrollLimit = false; | |
| 211 | + this.lastScroll = el.scrollTop; | |
| 212 | + } | |
| 213 | + }, | |
| 214 | + | |
| 215 | + getTouchCoordinates(e) { | |
| 216 | + return { | |
| 217 | + x: e.touches[0].pageX, | |
| 218 | + y: e.touches[0].pageY | |
| 219 | + }; | |
| 220 | + }, | |
| 221 | + | |
| 222 | + onPointerDown(e) { | |
| 223 | + // we just use scroll and wheel in desktop, no mousedown | |
| 224 | + if (this.isLoading) return; | |
| 225 | + if (e.type == 'touchstart') { | |
| 226 | + // if we start do touchmove on the scroll edger the browser will scroll the body | |
| 227 | + // by adding 5px margin on pointer down we avoid this behaviour and the scroll/touchmove | |
| 228 | + // in the component will not be exported outside of the component | |
| 229 | + const container = this.$refs.scrollContainer; | |
| 230 | + if (this.reachedTopScrollLimit) container.scrollTop = 5; | |
| 231 | + else if (this.reachedBottomScrollLimit) container.scrollTop -= 5; | |
| 232 | + } | |
| 233 | + if (e.type == 'touchstart' && this.$refs.scrollContainer.scrollTop == 0) | |
| 234 | + this.$refs.scrollContainer.scrollTop = 5; | |
| 235 | + | |
| 236 | + this.pointerTouchDown = this.getTouchCoordinates(e); | |
| 237 | + window.addEventListener('touchend', this.pointerUpHandler); | |
| 238 | + this.$refs.scrollContainer.parentElement.addEventListener('touchmove', e => { | |
| 239 | + e.stopPropagation(); | |
| 240 | + this.pointerMoveHandler(e); | |
| 241 | + }, {passive: false, useCapture: true}); | |
| 242 | + }, | |
| 243 | + | |
| 244 | + onPointerMove(e) { | |
| 245 | + if (!this.pointerTouchDown) return; | |
| 246 | + if (this.isLoading) return; | |
| 247 | + | |
| 248 | + const pointerPosition = this.getTouchCoordinates(e); | |
| 249 | + const yDiff = pointerPosition.y - this.pointerTouchDown.y; | |
| 250 | + | |
| 251 | + this.stretchEdge(yDiff); | |
| 252 | + | |
| 253 | + if (!this.touchScroll) { | |
| 254 | + const wasDragged = Math.abs(yDiff) > dragConfig.minimumStartDragOffset; | |
| 255 | + if (wasDragged) this.touchScroll = true; | |
| 256 | + else return; | |
| 257 | + } | |
| 258 | + }, | |
| 259 | + | |
| 260 | + onPointerUp() { | |
| 261 | + this.pointerTouchDown = null; | |
| 262 | + } | |
| 263 | + }, | |
| 264 | + created(){ | |
| 265 | + this.handleScroll = throttle(this.onScroll, 150, {leading: false}); | |
| 266 | + this.pointerUpHandler = this.onPointerUp.bind(this), // because we need the same function to add and remove event handlers | |
| 267 | + this.pointerMoveHandler = throttle(this.onPointerMove, 50, {leading: false}); | |
| 268 | + } | |
| 269 | + }; | |
| 270 | + | |
| 271 | +</script> | ... | ... |
src/index.js
| ... | ... | @@ -23,6 +23,7 @@ import Form from './components/form'; |
| 23 | 23 | import Icon from './components/icon'; |
| 24 | 24 | import Input from './components/input'; |
| 25 | 25 | import InputNumber from './components/input-number'; |
| 26 | +import Scroll from './components/scroll'; | |
| 26 | 27 | import LoadingBar from './components/loading-bar'; |
| 27 | 28 | import Menu from './components/menu'; |
| 28 | 29 | import Message from './components/message'; |
| ... | ... | @@ -46,8 +47,8 @@ import Tooltip from './components/tooltip'; |
| 46 | 47 | import Transfer from './components/transfer'; |
| 47 | 48 | import Tree from './components/tree'; |
| 48 | 49 | import Upload from './components/upload'; |
| 49 | -import { Row, Col } from './components/grid'; | |
| 50 | -import { Select, Option, OptionGroup } from './components/select'; | |
| 50 | +import {Row, Col} from './components/grid'; | |
| 51 | +import {Select, Option, OptionGroup} from './components/select'; | |
| 51 | 52 | import locale from './locale'; |
| 52 | 53 | |
| 53 | 54 | const iview = { |
| ... | ... | @@ -84,6 +85,7 @@ const iview = { |
| 84 | 85 | Input, |
| 85 | 86 | iInput: Input, |
| 86 | 87 | InputNumber, |
| 88 | + Scroll, | |
| 87 | 89 | LoadingBar, |
| 88 | 90 | Menu, |
| 89 | 91 | iMenu: Menu, |
| ... | ... | @@ -111,7 +113,7 @@ const iview = { |
| 111 | 113 | Spin, |
| 112 | 114 | Step: Steps.Step, |
| 113 | 115 | Steps, |
| 114 | - // Switch, | |
| 116 | + // Switch, | |
| 115 | 117 | iSwitch: Switch, |
| 116 | 118 | iTable: Table, |
| 117 | 119 | Table, |
| ... | ... | @@ -127,11 +129,11 @@ const iview = { |
| 127 | 129 | Upload |
| 128 | 130 | }; |
| 129 | 131 | |
| 130 | -const install = function (Vue, opts = {}) { | |
| 132 | +const install = function(Vue, opts = {}) { | |
| 131 | 133 | locale.use(opts.locale); |
| 132 | 134 | locale.i18n(opts.i18n); |
| 133 | 135 | |
| 134 | - Object.keys(iview).forEach((key) => { | |
| 136 | + Object.keys(iview).forEach(key => { | |
| 135 | 137 | Vue.component(key, iview[key]); |
| 136 | 138 | }); |
| 137 | 139 | |
| ... | ... | @@ -147,4 +149,4 @@ if (typeof window !== 'undefined' && window.Vue) { |
| 147 | 149 | install(window.Vue); |
| 148 | 150 | } |
| 149 | 151 | |
| 150 | -module.exports = Object.assign(iview, {install}); // eslint-disable-line no-undef | |
| 152 | +module.exports = Object.assign(iview, {install}); // eslint-disable-line no-undef | ... | ... |
src/styles/components/index.less
| ... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 | @import "checkbox"; |
| 14 | 14 | @import "switch"; |
| 15 | 15 | @import "input-number"; |
| 16 | +@import "scroll"; | |
| 16 | 17 | @import "tag"; |
| 17 | 18 | @import "loading-bar"; |
| 18 | 19 | @import "progress"; |
| ... | ... | @@ -41,4 +42,4 @@ |
| 41 | 42 | @import "tree"; |
| 42 | 43 | @import "avatar"; |
| 43 | 44 | @import "color-picker"; |
| 44 | -@import "auto-complete"; | |
| 45 | 45 | \ No newline at end of file |
| 46 | +@import "auto-complete"; | ... | ... |
| 1 | +@scroll-prefix-cls: ~"@{css-prefix}scroll"; | |
| 2 | + | |
| 3 | +.@{scroll-prefix-cls} { | |
| 4 | + &-wrapper { | |
| 5 | + width: auto; | |
| 6 | + margin: 0 auto; | |
| 7 | + position: relative; | |
| 8 | + outline: none; | |
| 9 | + } | |
| 10 | + | |
| 11 | + &-container { | |
| 12 | + overflow-y: scroll; | |
| 13 | + } | |
| 14 | + | |
| 15 | + &-content { | |
| 16 | + opacity: 1; | |
| 17 | + transition: opacity 0.5s; | |
| 18 | + } | |
| 19 | + | |
| 20 | + &-content-loading { | |
| 21 | + opacity: 0.5; | |
| 22 | + } | |
| 23 | + | |
| 24 | + &-loader { | |
| 25 | + text-align: center; | |
| 26 | + padding: 0px; | |
| 27 | + transition: padding 0.5s; | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +.@{scroll-prefix-cls}-loader-wrapper { | |
| 32 | + padding: 5px 0; | |
| 33 | + height: 0; | |
| 34 | + background-color: inherit; | |
| 35 | + transform: scale(0); | |
| 36 | + transition: opacity .3s, transform .5s, height .5s; | |
| 37 | + | |
| 38 | + &-active { | |
| 39 | + height: 40px; | |
| 40 | + transform: scale(1); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @keyframes ani-demo-spin { | |
| 44 | + from { | |
| 45 | + transform: rotate(0deg); | |
| 46 | + } | |
| 47 | + 50% { | |
| 48 | + transform: rotate(180deg); | |
| 49 | + } | |
| 50 | + to { | |
| 51 | + transform: rotate(360deg); | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + .@{scroll-prefix-cls}-spinner { | |
| 56 | + position: relative; | |
| 57 | + } | |
| 58 | + | |
| 59 | + .@{scroll-prefix-cls}-spinner-icon { | |
| 60 | + animation: ani-demo-spin 1s linear infinite; | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +@media (max-width: 768px) { | |
| 65 | + .@{scroll-prefix-cls} { | |
| 66 | + } | |
| 67 | +} | ... | ... |