Browse Source

Merge pull request #1947 from QingWei-Li/feat/scrollbar

Add scrollbar
baiyaaaaa 8 years ago
parent
commit
427d6498e3

+ 1 - 0
.travis.yml

@@ -5,6 +5,7 @@ cache:
   directories:
   - $HOME/.npm
   - $HOME/.yarn-cache
+  - node_modules
 before_install:
 - curl -o- -L https://yarnpkg.com/install.sh | bash
 - export PATH=$HOME/.yarn/bin:$PATH

+ 1 - 0
components.json

@@ -55,6 +55,7 @@
   "steps": "./packages/steps/index.js",
   "step": "./packages/step/index.js",
   "carousel": "./packages/carousel/index.js",
+  "scrollbar": "./packages/scrollbar/index.js",
   "carousel-item": "./packages/carousel-item/index.js",
   "collapse": "./packages/collapse/index.js",
   "collapse-item": "./packages/collapse-item/index.js"

+ 1 - 0
examples/docs/en-US/table.md

@@ -1362,6 +1362,7 @@ Customize table column so it can be integrated with other components.
 | row-key | key of row data, used for optimizing rendering. Required if `reserve-selection` is on | Function(row)/String | — | — |
 | context | context of Table, e.g. `_self` refers to the current context, `$parent` parent context, `$root` root context, can be overridden by `context` in `el-table-column` | Object | - | current context where Table lies |
 | empty-text | Displayed text when data is empty. You can customize this area with `slot="empty"` | String | | - | No Data |
+| virtual-scrollbar | Enable virtual scrollbar | Boolean | - | false |
 
 ### Table Events
 | Event Name | Description | Parameters |

+ 1 - 0
examples/docs/zh-CN/table.md

@@ -1369,6 +1369,7 @@
 | row-key | 行数据的 Key,用来优化 Table 的渲染;在使用 reserve-selection 功能的情况下,该属性是必填的 | Function(row)/String | — | — |
 | context | 设置上下文环境,例如设置当前上下文就是 `_self`,父级就是 `$parent`,根组件 `$root`。优先读取 column 的 context 属性。 | Object | - | Table 所处上下文 |
 | empty-text | 空数据时显示的文本内容,也可以通过 `slot="empty"` 设置 | String | | - | 暂无数据 |
+| virtual-scrollbar | 启用虚拟滚动条 | Boolean | - | false |
 
 ### Table Events
 | 事件名 | 说明 | 参数 |

+ 65 - 44
packages/date-picker/src/basic/time-spinner.vue

@@ -1,56 +1,65 @@
 <template>
   <div class="el-time-spinner" :class="{ 'has-seconds': showSeconds }">
-    <div
-      @mouseenter="emitSelectRange('hours')"
-      @mousewheel="handleScroll('hour')"
+    <el-scrollbar
+      @mouseenter.native="emitSelectRange('hours')"
+      @mousewheel.native="handleScroll('hour')"
       class="el-time-spinner__wrapper"
+      wrap-style="max-height: inherit;"
+      view-class="el-time-spinner__list"
+      noresize
+      tag="ul"
       ref="hour">
-      <ul class="el-time-spinner__list">
-        <li
-          @click="handleClick('hours', { value: hour, disabled: disabled }, true)"
-          v-for="(disabled, hour) in hoursList"
-          track-by="hour"
-          class="el-time-spinner__item"
-          :class="{ 'active': hour === hours, 'disabled': disabled }"
-          v-text="hour"></li>
-      </ul>
-    </div>
-    <div
-      @mouseenter="emitSelectRange('minutes')"
-      @mousewheel="handleScroll('minute')"
+      <li
+        @click="handleClick('hours', { value: hour, disabled: disabled }, true)"
+        v-for="(disabled, hour) in hoursList"
+        track-by="hour"
+        class="el-time-spinner__item"
+        :class="{ 'active': hour === hours, 'disabled': disabled }"
+        v-text="hour"></li>
+    </el-scrollbar>
+    <el-scrollbar
+      @mouseenter.native="emitSelectRange('minutes')"
+      @mousewheel.native="handleScroll('minute')"
       class="el-time-spinner__wrapper"
+      wrap-style="max-height: inherit;"
+      view-class="el-time-spinner__list"
+      noresize
+      tag="ul"
       ref="minute">
-      <ul class="el-time-spinner__list">
-        <li
-          @click="handleClick('minutes', key, true)"
-          v-for="(minute, key) in 60"
-          class="el-time-spinner__item"
-          :class="{ 'active': key === minutes }"
-          v-text="key"></li>
-      </ul>
-    </div>
-    <div
+      <li
+        @click="handleClick('minutes', key, true)"
+        v-for="(minute, key) in 60"
+        class="el-time-spinner__item"
+        :class="{ 'active': key === minutes }"
+        v-text="key"></li>
+    </el-scrollbar>
+    <el-scrollbar
       v-show="showSeconds"
-      @mouseenter="emitSelectRange('seconds')"
-      @mousewheel="handleScroll('second')"
+      @mouseenter.native="emitSelectRange('seconds')"
+      @mousewheel.native="handleScroll('second')"
       class="el-time-spinner__wrapper"
+      wrap-style="max-height: inherit;"
+      view-class="el-time-spinner__list"
+      noresize
+      tag="ul"
       ref="second">
-      <ul class="el-time-spinner__list">
-        <li
-          @click="handleClick('seconds', key, true)"
-          v-for="(second, key) in 60"
-          class="el-time-spinner__item"
-          :class="{ 'active': key === seconds }"
-          v-text="key"></li>
-      </ul>
-    </div>
+      <li
+        @click="handleClick('seconds', key, true)"
+        v-for="(second, key) in 60"
+        class="el-time-spinner__item"
+        :class="{ 'active': key === seconds }"
+        v-text="key"></li>
+    </el-scrollbar>
   </div>
 </template>
 
 <script type="text/babel">
   import { getRangeHours } from '../util';
+  import ElScrollbar from 'element-ui/packages/scrollbar';
 
   export default {
+    components: { ElScrollbar },
+
     props: {
       hours: {
         type: Number,
@@ -78,7 +87,7 @@
         if (!(newVal >= 0 && newVal <= 23)) {
           this.hoursPrivate = oldVal;
         }
-        this.$refs.hour.scrollTop = Math.max(0, (this.hoursPrivate - 2.5) * 32 + 80);
+        this.hourEl.scrollTop = Math.max(0, (this.hoursPrivate - 2.5) * 32 + 80);
         this.$emit('change', { hours: newVal });
       },
 
@@ -86,7 +95,7 @@
         if (!(newVal >= 0 && newVal <= 59)) {
           this.minutesPrivate = oldVal;
         }
-        this.$refs.minute.scrollTop = Math.max(0, (this.minutesPrivate - 2.5) * 32 + 80);
+        this.minuteEl.scrollTop = Math.max(0, (this.minutesPrivate - 2.5) * 32 + 80);
         this.$emit('change', { minutes: newVal });
       },
 
@@ -94,7 +103,7 @@
         if (!(newVal >= 0 && newVal <= 59)) {
           this.secondsPrivate = oldVal;
         }
-        this.$refs.second.scrollTop = Math.max(0, (this.secondsPrivate - 2.5) * 32 + 80);
+        this.secondEl.scrollTop = Math.max(0, (this.secondsPrivate - 2.5) * 32 + 80);
         this.$emit('change', { seconds: newVal });
       }
     },
@@ -102,6 +111,18 @@
     computed: {
       hoursList() {
         return getRangeHours(this.selectableRange);
+      },
+
+      hourEl() {
+        return this.$refs.hour.wrap;
+      },
+
+      minuteEl() {
+        return this.$refs.minute.wrap;
+      },
+
+      secondEl() {
+        return this.$refs.second.wrap;
       }
     },
 
@@ -138,14 +159,14 @@
       handleScroll(type) {
         const ajust = {};
 
-        ajust[`${type}s`] = Math.min(Math.floor((this.$refs[type].scrollTop - 80) / 32 + 3), 59);
+        ajust[`${type}s`] = Math.min(Math.floor((this[`${type}Elm`].scrollTop - 80) / 32 + 3), 59);
         this.$emit('change', ajust);
       },
 
       ajustScrollTop() {
-        this.$refs.hour.scrollTop = Math.max(0, (this.hours - 2.5) * 32 + 80);
-        this.$refs.minute.scrollTop = Math.max(0, (this.minutes - 2.5) * 32 + 80);
-        this.$refs.second.scrollTop = Math.max(0, (this.seconds - 2.5) * 32 + 80);
+        this.hourEl.scrollTop = Math.max(0, (this.hours - 2.5) * 32 + 80);
+        this.minuteEl.scrollTop = Math.max(0, (this.minutes - 2.5) * 32 + 80);
+        this.secondEl.scrollTop = Math.max(0, (this.seconds - 2.5) * 32 + 80);
       }
     }
   };

+ 8 - 4
packages/date-picker/src/panel/time-select.vue

@@ -3,20 +3,22 @@
     <div
       v-show="visible"
       :style="{ width: width + 'px' }"
-      class="el-picker-panel time-select"
-      :class="popperClass">
-      <div class="el-picker-panel__content">
+      :class="popperClass"
+      class="el-picker-panel time-select">
+      <el-scrollbar noresize wrap-class="el-picker-panel__content">
         <div class="time-select-item"
           v-for="item in items"
           :class="{ selected: value === item.value, disabled: item.disabled }"
           :disabled="item.disabled"
           @click="handleClick(item)">{{ item.value }}</div>
-      </div>
+      </el-scrollbar>
     </div>
   </transition>
 </template>
 
 <script type="text/babel">
+  import ElScrollbar from 'element-ui/packages/scrollbar';
+
   const parseTime = function(time) {
     const values = ('' || time).split(':');
     if (values.length >= 2) {
@@ -69,6 +71,8 @@
   };
 
   export default {
+    components: { ElScrollbar },
+
     watch: {
       value(val) {
         if (!val) return;

+ 8 - 0
packages/scrollbar/index.js

@@ -0,0 +1,8 @@
+import Scrollbar from './src/main';
+
+/* istanbul ignore next */
+Scrollbar.install = function(Vue) {
+  Vue.component(Scrollbar.name, Scrollbar);
+};
+
+export default Scrollbar;

+ 87 - 0
packages/scrollbar/src/bar.js

@@ -0,0 +1,87 @@
+import { on, off } from 'wind-dom/src/event';
+import { renderThumbStyle, BAR_MAP } from './util';
+
+export default {
+  name: 'Bar',
+
+  props: {
+    vertical: Boolean,
+    size: String,
+    move: Number
+  },
+
+  computed: {
+    bar() {
+      return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
+    },
+
+    wrap() {
+      return this.$parent.wrap;
+    }
+  },
+
+  render(h) {
+    const { size, move, bar } = this;
+
+    return (
+      <div
+        class={ ['el-scrollbar__bar', 'is-' + bar.key] }
+        onMousedown={ this.clickTrackHandler } >
+        <div
+          ref="thumb"
+          class="el-scrollbar__thumb"
+          onMousedown={ this.clickThumbHandler }
+          style={ renderThumbStyle({ size, move, bar }) }>
+        </div>
+      </div>
+    );
+  },
+
+  methods: {
+    clickThumbHandler(e) {
+      this.startDrag(e);
+      this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
+    },
+
+    clickTrackHandler(e) {
+      const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]);
+      const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
+      const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
+
+      this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
+    },
+
+    startDrag(e) {
+      e.stopImmediatePropagation();
+      this.cursorDown = true;
+
+      on(document, 'mousemove', this.mouseMoveDocumentHandler);
+      on(document, 'mouseup', this.mouseUpDocumentHandler);
+      document.onselectstart = () => false;
+    },
+
+    mouseMoveDocumentHandler(e) {
+      if (this.cursorDown === false) return;
+      const prevPage = this[this.bar.axis];
+
+      if (!prevPage) return;
+
+      const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * -1);
+      const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
+      const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / this.$el[this.bar.offset]);
+
+      this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
+    },
+
+    mouseUpDocumentHandler(e) {
+      this.cursorDown = false;
+      this[this.bar.axis] = 0;
+      off(document, 'mousemove', this.mouseMoveDocumentHandler);
+      document.onselectstart = null;
+    }
+  },
+
+  destroyed() {
+    off(document, 'mouseup', this.mouseUpDocumentHandler);
+  }
+};

+ 125 - 0
packages/scrollbar/src/main.js

@@ -0,0 +1,125 @@
+// reference https://github.com/noeldelgado/gemini-scrollbar/blob/master/index.js
+
+import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
+import scrollbarWidth from 'element-ui/src/utils/scrollbar-width';
+import * as util from './util';
+import Bar from './bar';
+
+export default {
+  name: 'ElScrollbar',
+
+  components: { Bar },
+
+  props: {
+    native: Boolean,
+    wrapStyle: {},
+    wrapClass: {},
+    viewClass: {},
+    viewStyle: {},
+    noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
+    tag: {
+      type: String,
+      default: 'div'
+    }
+  },
+
+  data() {
+    return {
+      sizeWidth: '0',
+      sizeHeight: '0',
+      moveX: 0,
+      moveY: 0
+    };
+  },
+
+  computed: {
+    wrap() {
+      return this.$refs.wrap;
+    }
+  },
+
+  render(h) {
+    let gutter = scrollbarWidth();
+    let style = this.wrapStyle;
+
+    if (gutter) {
+      const gutterWith = `-${gutter}px`;
+
+      if (Array.isArray(this.wrapStyle)) {
+        style = util.toObject(this.wrapStyle);
+        style.marginRight = style.marginBottom = gutterWith;
+      } else if (typeof this.wrapStyle === 'string') {
+        style += `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
+      }
+    }
+    const view = h(this.tag, {
+      class: ['el-scrollbar__view', this.viewClass],
+      style: this.viewStyle,
+      ref: 'resize'
+    }, this.$slots.default);
+    const wrap = (
+      <div
+        ref="wrap"
+        style={ style }
+        onScroll={ this.handleScroll }
+        class={ [this.wrapClass, 'el-scrollbar__wrap el-scrollbar__wrap--hidden-default'] }>
+        { [view] }
+      </div>
+    );
+    let nodes;
+
+    if (!this.native) {
+      nodes = ([
+        wrap,
+        <Bar
+          move={ this.moveX }
+          size={ this.sizeWidth }></Bar>,
+        <Bar
+          vertical
+          move={ this.moveY }
+          size={ this.sizeHeight }></Bar>
+      ]);
+    } else {
+      nodes = ([
+        <div
+          ref="wrap"
+          class={ [this.wrapClass, 'el-scrollbar__wrap'] }
+          style={ style }>
+          { [view] }
+        </div>
+      ]);
+    }
+    return h('div', { class: 'el-scrollbar' }, nodes);
+  },
+
+  methods: {
+    handleScroll() {
+      const wrap = this.wrap;
+
+      this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
+      this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
+    },
+
+    update() {
+      let heightPercentage, widthPercentage;
+      const wrap = this.wrap;
+
+      heightPercentage = (wrap.clientHeight * 100 / wrap.scrollHeight);
+      widthPercentage = (wrap.clientWidth * 100 / wrap.scrollWidth);
+
+      this.sizeHeight = (heightPercentage < 100) ? (heightPercentage + '%') : '';
+      this.sizeWidth = (widthPercentage < 100) ? (widthPercentage + '%') : '';
+    }
+  },
+
+  mounted() {
+    if (this.native) return;
+    this.$nextTick(this.update);
+    !this.noresize && addResizeListener(this.$refs.resize, this.update);
+  },
+
+  destroyed() {
+    if (this.native) return;
+    !this.noresize && removeResizeListener(this.$refs.resize, this.update);
+  }
+};

+ 38 - 0
packages/scrollbar/src/util.js

@@ -0,0 +1,38 @@
+import Vue from 'vue';
+
+export const BAR_MAP = {
+  vertical: {
+    offset: 'offsetHeight',
+    scroll: 'scrollTop',
+    scrollSize: 'scrollHeight',
+    size: 'height',
+    key: 'vertical',
+    axis: 'Y',
+    client: 'clientY',
+    direction: 'top'
+  },
+  horizontal: {
+    offset: 'offsetWidth',
+    scroll: 'scrollLeft',
+    scrollSize: 'scrollWidth',
+    size: 'width',
+    key: 'horizontal',
+    axis: 'X',
+    client: 'clientX',
+    direction: 'left'
+  }
+};
+
+export function renderThumbStyle({ move, size, bar }) {
+  const style = {};
+  const translate = `translate${bar.axis}(${ move }%)`;
+
+  style[bar.size] = size;
+  style.transform = translate;
+  style.msTransform = translate;
+  style.webkitTransform = translate;
+
+  return style;
+};
+
+export const toObject = Vue.util.toObject;

+ 8 - 4
packages/select/src/select.vue

@@ -65,8 +65,10 @@
       <el-select-menu
         ref="popper"
         v-show="visible && emptyText !== false">
-        <ul
-          class="el-select-dropdown__list"
+        <el-scrollbar
+          tag="ul"
+          wrap-class="el-select-dropdown__wrap"
+          view-class="el-select-dropdown__list"
           :class="{ 'is-empty': !allowCreate && filteredOptionsCount === 0 }"
           v-show="options.length > 0 && !loading">
           <el-option
@@ -75,7 +77,7 @@
             v-if="showNewOption">
           </el-option>
           <slot></slot>
-        </ul>
+        </el-scrollbar>
         <p class="el-select-dropdown__empty" v-if="emptyText && !allowCreate">{{ emptyText }}</p>
       </el-select-menu>
     </transition>
@@ -89,6 +91,7 @@
   import ElSelectMenu from './select-dropdown.vue';
   import ElOption from './option.vue';
   import ElTag from 'element-ui/packages/tag';
+  import ElScrollbar from 'element-ui/packages/scrollbar';
   import debounce from 'throttle-debounce/debounce';
   import Clickoutside from 'element-ui/src/utils/clickoutside';
   import { addClass, removeClass, hasClass } from 'wind-dom/src/class';
@@ -148,7 +151,8 @@
       ElInput,
       ElSelectMenu,
       ElOption,
-      ElTag
+      ElTag,
+      ElScrollbar
     },
 
     directives: { Clickoutside },

+ 1 - 1
packages/table/src/filter-panel.vue

@@ -163,7 +163,7 @@
     mounted() {
       this.popperElm = this.$el;
       this.referenceElm = this.cell;
-      this.table.$refs.bodyWrapper.addEventListener('scroll', () => {
+      this.table.bodyWrapper.addEventListener('scroll', () => {
         this.updatePopper();
       });
 

+ 13 - 9
packages/table/src/table-body.js

@@ -40,7 +40,7 @@ export default {
             this._l(this.data, (row, $index) =>
               <tr
                 style={ this.rowStyle ? this.getRowStyle(row, $index) : null }
-                key={ this.$parent.rowKey ? this.getKeyOfRow(row, $index) : $index }
+                key={ this.table.rowKey ? this.getKeyOfRow(row, $index) : $index }
                 on-dblclick={ ($event) => this.handleDoubleClick($event, row) }
                 on-click={ ($event) => this.handleClick($event, row) }
                 on-contextmenu={ ($event) => this.handleContextMenu($event, row) }
@@ -54,7 +54,7 @@ export default {
                       on-mouseenter={ ($event) => this.handleCellMouseEnter($event, row) }
                       on-mouseleave={ this.handleCellMouseLeave }>
                       {
-                        column.renderCell.call(this._renderProxy, h, { row, column, $index, store: this.store, _self: this.context || this.$parent.$vnode.context })
+                        column.renderCell.call(this._renderProxy, h, { row, column, $index, store: this.store, _self: this.context || this.table.$vnode.context })
                       }
                     </td>
                   )
@@ -103,6 +103,10 @@ export default {
   },
 
   computed: {
+    table() {
+      return this.$parent.$parent.columns ? this.$parent.$parent : this.$parent;
+    },
+
     data() {
       return this.store.states.data;
     },
@@ -132,7 +136,7 @@ export default {
 
   methods: {
     getKeyOfRow(row, index) {
-      const rowKey = this.$parent.rowKey;
+      const rowKey = this.table.rowKey;
       if (rowKey) {
         return getRowIdentity(row, rowKey);
       }
@@ -171,7 +175,7 @@ export default {
     },
 
     handleCellMouseEnter(event, row) {
-      const table = this.$parent;
+      const table = this.table;
       const cell = getCell(event);
 
       if (cell) {
@@ -190,8 +194,8 @@ export default {
       const cell = getCell(event);
       if (!cell) return;
 
-      const oldHoverState = this.$parent.hoverState;
-      this.$parent.$emit('cell-mouse-leave', oldHoverState.row, oldHoverState.column, oldHoverState.cell, event);
+      const oldHoverState = this.table.hoverState;
+      this.table.$emit('cell-mouse-leave', oldHoverState.row, oldHoverState.column, oldHoverState.cell, event);
     },
 
     handleMouseEnter(index) {
@@ -203,17 +207,17 @@ export default {
     },
 
     handleContextMenu(event, row) {
-      const table = this.$parent;
+      const table = this.table;
       table.$emit('row-contextmenu', row, event);
     },
 
     handleDoubleClick(event, row) {
-      const table = this.$parent;
+      const table = this.table;
       table.$emit('row-dblclick', row, event);
     },
 
     handleClick(event, row) {
-      const table = this.$parent;
+      const table = this.table;
       const cell = getCell(event);
       let column;
       if (cell) {

+ 3 - 9
packages/table/src/table-layout.js

@@ -1,6 +1,4 @@
-import { getScrollBarWidth } from './util';
-
-let GUTTER_WIDTH;
+import scrollbarWidth from 'element-ui/src/utils/scrollbar-width';
 
 class TableLayout {
   constructor(options) {
@@ -21,11 +19,7 @@ class TableLayout {
     this.viewportHeight = null; // Table Height - Scroll Bar Height
     this.bodyHeight = null; // Table Height - Table Header Height
     this.fixedBodyHeight = null; // Table Height - Table Header Height - Scroll Bar Height
-
-    if (GUTTER_WIDTH === undefined) {
-      GUTTER_WIDTH = getScrollBarWidth();
-    }
-    this.gutterWidth = GUTTER_WIDTH;
+    this.gutterWidth = scrollbarWidth();
 
     for (let name in options) {
       if (options.hasOwnProperty(name)) {
@@ -44,7 +38,7 @@ class TableLayout {
   updateScrollY() {
     const height = this.height;
     if (typeof height !== 'string' && typeof height !== 'number') return;
-    const bodyWrapper = this.table.$refs.bodyWrapper;
+    const bodyWrapper = this.table.bodyWrapper;
     if (this.table.$el && bodyWrapper) {
       const body = bodyWrapper.querySelector('.el-table__body');
       this.scrollY = body.offsetHeight > bodyWrapper.offsetHeight;

+ 18 - 8
packages/table/src/table.vue

@@ -18,7 +18,11 @@
         :style="{ width: layout.bodyWidth ? layout.bodyWidth + 'px' : '' }">
       </table-header>
     </div>
-    <div class="el-table__body-wrapper" ref="bodyWrapper" :style="[bodyHeight]">
+    <el-scrollbar
+      class="el-table__body-wrapper"
+      ref="bodyWrapper"
+      :native="!virtualScrollbar"
+      :wrap-style="[bodyHeight]">
       <table-body
         :context="context"
         :store="store"
@@ -29,9 +33,9 @@
         :style="{ width: layout.bodyWidth ? layout.bodyWidth - (layout.scrollY ? layout.gutterWidth : 0 ) + 'px' : '' }">
       </table-body>
       <div class="el-table__empty-block" v-if="!data || data.length === 0">
-      <span class="el-table__empty-text"><slot name="empty">{{ emptyText || t('el.table.emptyText') }}</slot></span>
+        <span class="el-table__empty-text"><slot name="empty">{{ emptyText || t('el.table.emptyText') }}</slot></span>
       </div>
-    </div>
+    </el-scrollbar>
     <div class="el-table__fixed" ref="fixedWrapper"
       v-if="fixedColumns.length > 0"
       :style="[
@@ -130,6 +134,8 @@
 
       width: [String, Number],
 
+      virtualScrollbar: Boolean,
+
       height: [String, Number],
 
       maxHeight: [String, Number],
@@ -205,22 +211,22 @@
       },
 
       bindEvents() {
-        const { bodyWrapper, headerWrapper } = this.$refs;
+        const { headerWrapper } = this.$refs;
         const refs = this.$refs;
-        bodyWrapper.addEventListener('scroll', function() {
+        this.bodyWrapper.addEventListener('scroll', function() {
           if (headerWrapper) headerWrapper.scrollLeft = this.scrollLeft;
           if (refs.fixedBodyWrapper) refs.fixedBodyWrapper.scrollTop = this.scrollTop;
           if (refs.rightFixedBodyWrapper) refs.rightFixedBodyWrapper.scrollTop = this.scrollTop;
         });
 
         if (headerWrapper) {
-          mousewheel(headerWrapper, throttle(16, function(event) {
+          mousewheel(headerWrapper, throttle(16, event => {
             const deltaX = event.deltaX;
 
             if (deltaX > 0) {
-              bodyWrapper.scrollLeft = bodyWrapper.scrollLeft + 10;
+              this.bodyWrapper.scrollLeft += 10;
             } else {
-              bodyWrapper.scrollLeft = bodyWrapper.scrollLeft - 10;
+              this.bodyWrapper.scrollLeft -= 10;
             }
           }));
         }
@@ -255,6 +261,10 @@
     },
 
     computed: {
+      bodyWrapper() {
+        return this.$refs.bodyWrapper.wrap;
+      },
+
       shouldUpdateHeight() {
         return typeof this.height === 'number' ||
           this.fixedColumns.length > 0 ||

+ 0 - 25
packages/table/src/util.js

@@ -1,28 +1,3 @@
-let scrollBarWidth;
-
-export const getScrollBarWidth = () => {
-  if (scrollBarWidth !== undefined) return scrollBarWidth;
-
-  const outer = document.createElement('div');
-  outer.style.visibility = 'hidden';
-  outer.style.width = '100px';
-  outer.style.position = 'absolute';
-  outer.style.top = '-9999px';
-  document.body.appendChild(outer);
-
-  const widthNoScroll = outer.offsetWidth;
-  outer.style.overflow = 'scroll';
-
-  const inner = document.createElement('div');
-  inner.style.width = '100%';
-  outer.appendChild(inner);
-
-  const widthWithScroll = inner.offsetWidth;
-  outer.parentNode.removeChild(outer);
-
-  return widthNoScroll - widthWithScroll;
-};
-
 export const getCell = function(event) {
   let cell = event.target;
 

+ 5 - 0
packages/theme-default/src/common/var.css

@@ -545,6 +545,11 @@
   --loading-spinner-size: 42px;
   --loading-fullscreen-spinner-size: 50px;
 
+  /* Scrollbar
+  --------------------------*/
+  --scrollbar-background-color: rgba(#99a9bf, .3);
+  --scrollbar-hover-background-color: rgba(#99a9bf, .5);
+
   /* Carousel
   --------------------------*/
   --carousel-arrow-font-size: 12px;

+ 7 - 8
packages/theme-default/src/date-picker/time-spinner.css

@@ -4,22 +4,21 @@
   @b time-spinner {
     &.has-seconds {
       .el-time-spinner__wrapper {
-        width: calc(100% / 3);
+        width: 33%;
+      }
+
+      .el-time-spinner__wrapper:nth-child(2) {
+        padding-left: 1%;
       }
     }
 
     @e wrapper {
-      height: 190px;
-      overflow: hidden;
+      max-height: 190px;
+      overflow: auto;
       display: inline-block;
       width: 50%;
       vertical-align: top;
       position: relative;
-      -ms-overflow-style: none;
-
-      &:hover {
-        overflow-y: auto;
-      }
     }
 
     @e list {

+ 1 - 0
packages/theme-default/src/index.css

@@ -40,6 +40,7 @@
 @import "./rate.css";
 @import "./steps.css";
 @import "./step.css";
+@import "./scrollbar.css";
 @import "./carousel.css";
 @import "./carousel-item.css";
 @import "./collapse.css";

+ 68 - 0
packages/theme-default/src/scrollbar.css

@@ -0,0 +1,68 @@
+@component-namespace el {
+  @b scrollbar {
+    overflow: hidden;
+    position: relative;
+
+    &:hover,
+    &:active,
+    &:focus {
+      .el-scrollbar__bar {
+        opacity: 1;
+        transition: opacity 340ms ease-out;
+      }
+    }
+
+    @e wrap {
+      overflow: scroll;
+
+      @m hidden-default {
+        &::-webkit-scrollbar {
+          width: 0;
+          height: 0;
+        }
+      }
+    }
+
+    @e thumb {
+      position: relative;
+      display: block;
+      size: 0;
+      cursor: pointer;
+      border-radius: inherit;
+      background-color: var(--scrollbar-background-color);
+      transition: .3s background-color;
+
+      &:hover {
+        background-color: var(--scrollbar-hover-background-color);
+      }
+    }
+
+    @e bar {
+      position: absolute;
+      right: 2px;
+      bottom: 2px;
+      z-index: 1;
+      border-radius: 4px;
+      opacity: 0;
+      transition: opacity 120ms ease-out;
+
+      @when vertical {
+        width: 6px;
+        top: 2px;
+
+        > div {
+          width: 100%;
+        }
+      }
+
+      @when horizontal {
+        height: 6px;
+        left: 2px;
+
+        > div {
+          height: 100%;
+        }
+      }
+    }
+  }
+}

+ 5 - 3
packages/theme-default/src/select-dropdown.css

@@ -43,14 +43,16 @@
     font-size: var(--select-font-size);
   }
 
+  @b select-dropdown__wrap {
+    max-height: var(--select-dropdown-max-height);
+    width: 100%;
+  }
+
   @b select-dropdown__list {
     list-style: none;
     padding: var(--select-dropdown-padding);
     margin: 0;
-    width: 100%;
-    max-height: var(--select-dropdown-max-height);
     box-sizing: border-box;
-    overflow-y: auto;
 
     @when empty {
       padding: 0;

+ 0 - 5
packages/theme-default/src/time-select.css

@@ -9,12 +9,7 @@
 
 .time-select .el-picker-panel__content {
   max-height: 200px;
-  overflow: hidden;
   margin: 0;
-
-  &:hover {
-    overflow-y: auto;
-  }
 }
 
 .time-select-item {

+ 3 - 0
src/index.js

@@ -56,6 +56,7 @@ import Rate from '../packages/rate';
 import Steps from '../packages/steps';
 import Step from '../packages/step';
 import Carousel from '../packages/carousel';
+import Scrollbar from '../packages/scrollbar';
 import CarouselItem from '../packages/carousel-item';
 import Collapse from '../packages/collapse';
 import CollapseItem from '../packages/collapse-item';
@@ -118,6 +119,7 @@ const install = function(Vue, opts = {}) {
   Vue.component(Steps.name, Steps);
   Vue.component(Step.name, Step);
   Vue.component(Carousel.name, Carousel);
+  Vue.component(Scrollbar.name, Scrollbar);
   Vue.component(CarouselItem.name, CarouselItem);
   Vue.component(Collapse.name, Collapse);
   Vue.component(CollapseItem.name, CollapseItem);
@@ -198,6 +200,7 @@ module.exports = {
   Steps,
   Step,
   Carousel,
+  Scrollbar,
   CarouselItem,
   Collapse,
   CollapseItem

+ 28 - 0
src/utils/scrollbar-width.js

@@ -0,0 +1,28 @@
+import Vue from 'vue';
+
+let scrollBarWidth;
+
+export default function() {
+  if (Vue.prototype.$isServer) return 0;
+  if (scrollBarWidth !== undefined) return scrollBarWidth;
+
+  const outer = document.createElement('div');
+  outer.className = 'el-scrollbar__wrap';
+  outer.style.visibility = 'hidden';
+  outer.style.width = '100px';
+  outer.style.position = 'absolute';
+  outer.style.top = '-9999px';
+  document.body.appendChild(outer);
+
+  const widthNoScroll = outer.offsetWidth;
+  outer.style.overflow = 'scroll';
+
+  const inner = document.createElement('div');
+  inner.style.width = '100%';
+  outer.appendChild(inner);
+
+  const widthWithScroll = inner.offsetWidth;
+  outer.parentNode.removeChild(outer);
+
+  return widthNoScroll - widthWithScroll;
+};

+ 15 - 15
test/unit/specs/time-picker.spec.js

@@ -58,23 +58,23 @@ describe('TimePicker', () => {
 
     Vue.nextTick(_ => {
       const list = vm.picker.$el.querySelectorAll('.el-time-spinner__list');
-      const hoursElm = list[0];
-      const minutesElm = list[1];
-      const secondsElm = list[2];
-      const hourElm = hoursElm.querySelectorAll('.el-time-spinner__item')[4];
-      const minuteElm = minutesElm.querySelectorAll('.el-time-spinner__item')[36];
-      const secondElm = secondsElm.querySelectorAll('.el-time-spinner__item')[20];
+      const hoursEl = list[0];
+      const minutesEl = list[1];
+      const secondsEl = list[2];
+      const hourEl = hoursEl.querySelectorAll('.el-time-spinner__item')[4];
+      const minuteEl = minutesEl.querySelectorAll('.el-time-spinner__item')[36];
+      const secondEl = secondsEl.querySelectorAll('.el-time-spinner__item')[20];
 
-      hourElm.click();
-      minuteElm.click();
-      secondElm.click();
+      hourEl.click();
+      minuteEl.click();
+      secondEl.click();
 
       Vue.nextTick(_ => {
         const date = vm.picker.currentDate;
 
-        expect(hourElm.classList.contains('active')).to.true;
-        expect(minuteElm.classList.contains('active')).to.true;
-        expect(secondElm.classList.contains('active')).to.true;
+        expect(hourEl.classList.contains('active')).to.true;
+        expect(minuteEl.classList.contains('active')).to.true;
+        expect(secondEl.classList.contains('active')).to.true;
         expect(date.getHours()).to.equal(4);
         expect(date.getMinutes()).to.equal(36);
         expect(date.getSeconds()).to.equal(20);
@@ -165,12 +165,12 @@ describe('TimePicker', () => {
 
     setTimeout(_ => {
       const list = vm.picker.$el.querySelectorAll('.el-time-spinner__list');
-      const hoursElm = list[0];
+      const hoursEl = list[0];
       const disabledHours = [].slice
-        .call(hoursElm.querySelectorAll('.disabled'))
+        .call(hoursEl.querySelectorAll('.disabled'))
         .map(node => Number(node.textContent));
 
-      hoursElm.querySelectorAll('.disabled')[0].click();
+      hoursEl.querySelectorAll('.disabled')[0].click();
       expect(disabledHours).to.not.include.members([18, 19, 20]);
       destroyVM(vm);
       done();