Custom range component packaging practice based on El-Slider

preface

Range selection components are often used in daily work, such as progress bar, date range selection and other components. The common component library often uses the circular slider shape. In the project, it is necessary to implement a range component according to the customization.

Problem directory

  • El-slider source code interpretation
  • Custom encapsulation
  • Common ways to encapsulate range components

To explore the case

El-slider source code interpretation

[Component Directory]

  • src
    • button.vue
    • main.vue
    • marker.js
  • index.js

[directory description] Button is the main body of the slider size, style, drag and drop behavior, etc. Main displays the progress bar

[source code analysis] The main body of el-Slider is button and bar, the main need to consider on the button is the event related processing, Including mouseenter, mouseleave, mousemove, mouseup, mousedown, touchstart, touchuend, touchmove, keydown. Left/right/down/up; The main concern on BAR is the acquisition of values and display of positions

Button in the source code:

<template>
  <div
    class="el-slider__button-wrapper"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
    @mousedown="onButtonDown"
    @touchstart="onButtonDown"
    :class="{ 'hover': hovering, 'dragging': dragging }"
    :style="wrapperStyle"
    ref="button"
    tabindex="0"
    @focus="handleMouseEnter"
    @blur="handleMouseLeave"
    @keydown.left="onLeftKeyDown"
    @keydown.right="onRightKeyDown"
    @keydown.down.prevent="onLeftKeyDown"
    @keydown.up.prevent="onRightKeyDown"
  >
    <el-tooltip
      placement="top"
      ref="tooltip"
      :popper-class="tooltipClass"
      :disabled=! "" showTooltip">
      <span slot="content">{{ formatValue }}</span>
      <div class="el-slider__button" :class="{ 'hover': hovering, 'dragging': dragging }"></div>
    </el-tooltip>
  </div>
</template>

<script>
  import ElTooltip from 'element-ui/packages/tooltip';

  export default {
    name: 'ElSliderButton',

    components: {
      ElTooltip
    },

    props: {
      value: {
        type: Number,
        default: 0
      },
      vertical: {
        type: Boolean,
        default: false
      },
      tooltipClass: String
    },

    data() {
      return {
        hovering: false,
        dragging: false,
        isClick: false,
        startX: 0,
        currentX: 0,
        startY: 0,
        currentY: 0,
        startPosition: 0,
        newPosition: null,
        oldValue: this.value
      };
    },

    computed: {
      disabled() {
        return this.$parent.sliderDisabled;
      },

      max() {
        return this.$parent.max;
      },

      min() {
        return this.$parent.min;
      },

      step() {
        return this.$parent.step;
      },

      showTooltip() {
        return this.$parent.showTooltip;
      },

      precision() {
        return this.$parent.precision;
      },

      currentPosition() {
        return `${ (this.value - this.min) / (this.max - this.min) * 100 }% `; },enableFormat() {
        return this.$parent.formatTooltip instanceof Function;
      },

      formatValue() {
        return this.enableFormat && this.$parent.formatTooltip(this.value) || this.value;
      },

      wrapperStyle() {
        return this.vertical ? { bottom: this.currentPosition } : { left: this.currentPosition };
      }
    },

    watch: {
      dragging(val) {
        this.$parent.dragging = val;
      }
    },

    methods: {
      displayTooltip() {
        this.$refs.tooltip && (this.$refs.tooltip.showPopper = true);
      },

      hideTooltip() {
        this.$refs.tooltip && (this.$refs.tooltip.showPopper = false);
      },

      handleMouseEnter() {
        this.hovering = true;
        this.displayTooltip();
      },

      handleMouseLeave() {
        this.hovering = false;
        this.hideTooltip();
      },

      onButtonDown(event) {
        if (this.disabled) return;
        event.preventDefault();
        this.onDragStart(event);
        window.addEventListener('mousemove', this.onDragging);
        window.addEventListener('touchmove', this.onDragging);
        window.addEventListener('mouseup', this.onDragEnd);
        window.addEventListener('touchend', this.onDragEnd);
        window.addEventListener('contextmenu', this.onDragEnd);
      },
      onLeftKeyDown() {
        if (this.disabled) return;
        this.newPosition = parseFloat(this.currentPosition) - this.step / (this.max - this.min) * 100;
        this.setPosition(this.newPosition);
        this.$parent.emitChange();
      },
      onRightKeyDown() {
        if (this.disabled) return;
        this.newPosition = parseFloat(this.currentPosition) + this.step / (this.max - this.min) * 100;
        this.setPosition(this.newPosition);
        this.$parent.emitChange();
      },
      onDragStart(event) {
        this.dragging = true;
        this.isClick = true;
        if (event.type === 'touchstart') {
          event.clientY = event.touches[0].clientY;
          event.clientX = event.touches[0].clientX;
        }
        if (this.vertical) {
          this.startY = event.clientY;
        } else {
          this.startX = event.clientX;
        }
        this.startPosition = parseFloat(this.currentPosition);
        this.newPosition = this.startPosition;
      },

      onDragging(event) {
        if (this.dragging) {
          this.isClick = false;
          this.displayTooltip();
          this.$parent.resetSize();
          let diff = 0;
          if (event.type === 'touchmove') {
            event.clientY = event.touches[0].clientY;
            event.clientX = event.touches[0].clientX;
          }
          if (this.vertical) {
            this.currentY = event.clientY;
            diff = (this.startY - this.currentY) / this.$parent.sliderSize * 100;
          } else {
            this.currentX = event.clientX;
            diff = (this.currentX - this.startX) / this.$parent.sliderSize * 100; } this.newPosition = this.startPosition + diff; this.setPosition(this.newPosition); }},onDragEnd() {
        if(this.dragging) {/* * Prevent click from triggering immediately after mouseup causing the slider to have a small displacement * Not using preventDefault because mouseup and Click are not registered on the same DOM */setTimeout(() => {
            this.dragging = false;
            this.hideTooltip();
            if(! this.isClick) { this.setPosition(this.newPosition); this.$parent.emitChange(); }}, 0); window.removeEventListener('mousemove', this.onDragging);
          window.removeEventListener('touchmove', this.onDragging);
          window.removeEventListener('mouseup', this.onDragEnd);
          window.removeEventListener('touchend', this.onDragEnd);
          window.removeEventListener('contextmenu', this.onDragEnd); }},setPosition(newPosition) {
        if (newPosition === null || isNaN(newPosition)) return;
        if (newPosition < 0) {
          newPosition = 0;
        } else if (newPosition > 100) {
          newPosition = 100;
        }
        const lengthPerStep = 100 / ((this.max - this.min) / this.step);
        const steps = Math.round(newPosition / lengthPerStep);
        letValue = steps * lengthPerStep * (this.max-this.min) * 0.01 + this.min; value = parseFloat(value.toFixed(this.precision)); this.$emit('input', value);
        this.$nextTick(() => {
          this.displayTooltip();
          this.$refs.tooltip && this.$refs.tooltip.updatePopper();
        });
        if(! this.dragging && this.value ! == this.oldValue) { this.oldValue = this.value; }}}}; </script>Copy the code

The main source:

<template>
  <div
    class="el-slider"
    :class="{ 'is-vertical': vertical, 'el-slider--with-input': showInput }"
    role="slider"
    :aria-valuemin="min"
    :aria-valuemax="max"
    :aria-orientation="vertical ? 'vertical': 'horizontal'"
    :aria-disabled="sliderDisabled"
  >
    <el-input-number
      v-model="firstValue"
      v-if="showInput && ! range"
      class="el-slider__input"
      ref="input"
      @change="emitChange"
      :step="step"
      :disabled="sliderDisabled"
      :controls="showInputControls"
      :min="min"
      :max="max"
      :debounce="debounce"
      :size="inputSize">
    </el-input-number>
    <div
      class="el-slider__runway"
      :class="{ 'show-input': showInput, 'disabled': sliderDisabled }"
      :style="runwayStyle"
      @click="onSliderClick"
      ref="slider">
      <div
        class="el-slider__bar"
        :style="barStyle">
      </div>
      <slider-button
        :vertical="vertical"
        v-model="firstValue"
        :tooltip-class="tooltipClass"
        ref="button1">
      </slider-button>
      <slider-button
        :vertical="vertical"
        v-model="secondValue"
        :tooltip-class="tooltipClass"
        ref="button2"
        v-if="range">
      </slider-button>
      <div
        class="el-slider__stop"
        v-for="(item, key) in stops"
        :key="key"
        :style="getStopStyle(item)"
        v-if="showStops">
      </div>
      <template v-if="markList.length > 0">
        <div>
          <div
            v-for="(item, key) in markList"
            :style="getStopStyle(item.position)"
            class="el-slider__stop el-slider__marks-stop"
            :key="key">
          </div>
        </div>
        <div class="el-slider__marks">
          <slider-marker
            :mark="item.mark" v-for="(item, key) in markList"
            :key="key"
            :style="getStopStyle(item.position)">
          </slider-marker>
        </div>
      </template>
    </div>
  </div>
</template>

<script type="text/babel">
  import ElInputNumber from 'element-ui/packages/input-number';
  import SliderButton from './button.vue';
  import SliderMarker from './marker';
  import Emitter from 'element-ui/src/mixins/emitter';

  export default {
    name: 'ElSlider',

    mixins: [Emitter],

    inject: {
      elForm: {
        default: ' '
      }
    },

    props: {
      min: {
        type: Number,
        default: 0
      },
      max: {
        type: Number,
        default: 100
      },
      step: {
        type: Number,
        default: 1
      },
      value: {
        type: [Number, Array],
        default: 0
      },
      showInput: {
        type: Boolean,
        default: false
      },
      showInputControls: {
        type: Boolean,
        default: true
      },
      inputSize: {
        type: String,
        default: 'small'
      },
      showStops: {
        type: Boolean,
        default: false
      },
      showTooltip: {
        type: Boolean,
        default: true
      },
      formatTooltip: Function,
      disabled: {
        type: Boolean,
        default: false
      },
      range: {
        type: Boolean,
        default: false
      },
      vertical: {
        type: Boolean,
        default: false
      },
      height: {
        type: String
      },
      debounce: {
        type: Number,
        default: 300
      },
      label: {
        type: String
      },
      tooltipClass: String,
      marks: Object
    },

    components: {
      ElInputNumber,
      SliderButton,
      SliderMarker
    },

    data() {
      return {
        firstValue: null,
        secondValue: null,
        oldValue: null,
        dragging: false,
        sliderSize: 1
      };
    },

    watch: {
      value(val, oldVal) {
        if (this.dragging ||
          Array.isArray(val) &&
          Array.isArray(oldVal) &&
          val.every((item, index) => item === oldVal[index])) {
          return;
        }
        this.setValues();
      },

      dragging(val) {
        if(! val) { this.setValues(); } }, firstValue(val) {if (this.range) {
          this.$emit('input', [this.minValue, this.maxValue]);
        } else {
          this.$emit('input', val); }},secondValue() {
        if (this.range) {
          this.$emit('input', [this.minValue, this.maxValue]); }},min() {
        this.setValues();
      },

      max() {
        this.setValues();
      }
    },

    methods: {
      valueChanged() {
        if (this.range) {
          return! [this.minValue, this.maxValue] .every((item, index) => item === this.oldValue[index]); }else {
          return this.value !== this.oldValue;
        }
      },
      setValues() {
        if (this.min > this.max) {
          console.error('[Element Error][Slider]min should not be greater than max.');
          return;
        }
        const val = this.value;
        if (this.range && Array.isArray(val)) {
          if (val[1] < this.min) {
            this.$emit('input', [this.min, this.min]);
          } else if (val[0] > this.max) {
            this.$emit('input', [this.max, this.max]);
          } else if (val[0] < this.min) {
            this.$emit('input', [this.min, val[1]]);
          } else if (val[1] > this.max) {
            this.$emit('input', [val[0], this.max]);
          } else {
            this.firstValue = val[0];
            this.secondValue = val[1];
            if (this.valueChanged()) {
              this.dispatch('ElFormItem'.'el.form.change', [this.minValue, this.maxValue]); this.oldValue = val.slice(); }}}else if(! this.range && typeof val ==='number' && !isNaN(val)) {
          if (val < this.min) {
            this.$emit('input', this.min);
          } else if (val > this.max) {
            this.$emit('input', this.max);
          } else {
            this.firstValue = val;
            if (this.valueChanged()) {
              this.dispatch('ElFormItem'.'el.form.change', val); this.oldValue = val; }}}},setPosition(percent) {
        const targetValue = this.min + percent * (this.max - this.min) / 100;
        if(! this.range) { this.$refs.button1.setPosition(percent);
          return;
        }
        let button;
        if (Math.abs(this.minValue - targetValue) < Math.abs(this.maxValue - targetValue)) {
          button = this.firstValue < this.secondValue ? 'button1' : 'button2';
        } else {
          button = this.firstValue > this.secondValue ? 'button1' : 'button2';
        }
        this.$refs[button].setPosition(percent);
      },

      onSliderClick(event) {
        if (this.sliderDisabled || this.dragging) return;
        this.resetSize();
        if (this.vertical) {
          const sliderOffsetBottom = this.$refs.slider.getBoundingClientRect().bottom;
          this.setPosition((sliderOffsetBottom - event.clientY) / this.sliderSize * 100);
        } else {
          const sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().left;
          this.setPosition((event.clientX - sliderOffsetLeft) / this.sliderSize * 100);
        }
        this.emitChange();
      },

      resetSize() {
        if (this.$refs.slider) {
          this.sliderSize = this.$refs.slider[`client${ this.vertical ? 'Height' : 'Width' }`]; }},emitChange() {
        this.$nextTick(() => {
          this.$emit('change', this.range ? [this.minValue, this.maxValue] : this.value);
        });
      },

      getStopStyle(position) {
        return this.vertical ? { 'bottom': position + The '%'}, {'left': position + The '%' };
      }
    },

    computed: {
      stops() {
        if(! this.showStops || this.min > this.max)return [];
        if(this.step === 0) { process.env.NODE_ENV ! = ='production' &&
          console.warn('[Element Warn][Slider]step should not be 0.');
          return [];
        }
        const stopCount = (this.max - this.min) / this.step;
        const stepWidth = 100 * this.step / (this.max - this.min);
        const result = [];
        for (let i = 1; i < stopCount; i++) {
          result.push(i * stepWidth);
        }
        if (this.range) {
          return result.filter(step => {
            returnstep < 100 * (this.minValue - this.min) / (this.max - this.min) || step > 100 * (this.maxValue - this.min) / (this.max -  this.min); }); }else {
          returnresult.filter(step => step > 100 * (this.firstValue - this.min) / (this.max - this.min)); }},markList() {
        if(! this.marks) {return [];
        }

        const marksKeys = Object.keys(this.marks);
        return marksKeys.map(parseFloat)
          .sort((a, b) => a - b)
          .filter(point => point <= this.max && point >= this.min)
          .map(point => ({
            point,
            position: (point - this.min) * 100 / (this.max - this.min),
            mark: this.marks[point]
          }));
      },

      minValue() {
        return Math.min(this.firstValue, this.secondValue);
      },

      maxValue() {
        return Math.max(this.firstValue, this.secondValue);
      },

      barSize() {
        return this.range
          ? `${ 100 * (this.maxValue - this.minValue) / (this.max - this.min) }% ` : `${ 100 * (this.firstValue - this.min) / (this.max - this.min) }% `; },barStart() {
        return this.range
          ? `${ 100 * (this.minValue - this.min) / (this.max - this.min) }% ` :'0%';
      },

      precision() {
        let precisions = [this.min, this.max, this.step].map(item => {
          let decimal = (' ' + item).split('. ') [1];return decimal ? decimal.length : 0;
        });
        return Math.max.apply(null, precisions);
      },

      runwayStyle() {
        return this.vertical ? { height: this.height } : {};
      },

      barStyle() {
        return this.vertical
          ? {
            height: this.barSize,
            bottom: this.barStart
          } : {
            width: this.barSize,
            left: this.barStart
          };
      },

      sliderDisabled() {
        returnthis.disabled || (this.elForm || {}).disabled; }},mounted() {
      let valuetext;
      if (this.range) {
        if (Array.isArray(this.value)) {
          this.firstValue = Math.max(this.min, this.value[0]);
          this.secondValue = Math.min(this.max, this.value[1]);
        } else {
          this.firstValue = this.min;
          this.secondValue = this.max;
        }
        this.oldValue = [this.firstValue, this.secondValue];
        valuetext = `${this.firstValue}-${this.secondValue}`;
      } else {
        if(typeof this.value ! = ='number' || isNaN(this.value)) {
          this.firstValue = this.min;
        } else {
          this.firstValue = Math.min(this.max, Math.max(this.min, this.value));
        }
        this.oldValue = this.firstValue;
        valuetext = this.firstValue;
      }
      this.$el.setAttribute('aria-valuetext', valuetext);

      // label screen reader
      this.$el.setAttribute('aria-label', this.label ? this.label : `slider between ${this.min} and ${this.max}`);

      this.resetSize();
      window.addEventListener('resize', this.resetSize);
    },

    beforeDestroy() {
      window.removeEventListener('resize', this.resetSize); }}; </script>Copy the code

Custom encapsulation

[Requirement analysis] Because the project is used in the date template display, so the main drag events can be directly used el-slider, style can be customized Settings; The second is the date display. The field of El-Slider is set as a number, so the date needs to be converted. At this time, the moment.js library is used to facilitate unified processing

The home directory is still a component of vue, with date-class methods removed from the format.js file under utils

[Solution] Mainly template, script, style three parts

The template code:

<template>
  <div class="range-container">
      <el-slider 
        v-model="s" 
        :format-tooltip="formatTooltip"
        :max="24"
        :step='1'
        range
        @change='handleChange'
      >
      </el-slider>
  </div>
</template>
Copy the code

The script code:

<script> // Utility function import {formatHoursMinutes} from'@/utils/format';

export default {
  data() {
    return {
      s: [this.start,this.isFullDay(this.end)],
      formatHoursMinutes: formatHoursMinutes
    }
  },
  props: {
    start: {
      default: 0,
      type: Number
    },
    end: {
      default: 24,
      type: Number
    },
    week: {
      default: 1,
      type: String
    }
  },
  methods: {

    isFullDay(val) {
      if(val === 0) {
        return24}else {
        returnNumber(val)}}, formatTooltip(val) {// Format conversionlet n = parseInt(val / 1);
        return this.formatHoursMinutes(n)
    },

    handleChange(val) {
      const [start,end] = val;
      const week = this.week;
      this.$emit('changeTemplate',{start,end,week})
    }
  }
}
</script>
Copy the code

Style code:

<style lang='scss'>
@import '@/styles/element-variables.scss'; .range-container { .el-slider { .el-slider__runway { height: 32px; margin-top: 0; margin-bottom: 0 ! important; background-color:#FFFFFF;
      border: 1px solid #DCDFE6;
      .el-slider__bar {
        height: 32px;
      }
      .el-slider__button-wrapper {
        top: 0;
        height: 32px;
        .el-slider__button {
          width: 4px;
          height: 32px;
          border-radius: 0;
          background: # 121212;
          border: none;
        }
      }
      .el-slider__stop {
        width: 1px;
        height: 31px;
        border-radius: 0;
        background-color: #DCDFE6;
      }
      .el-slider__marks-text {
        color: # 717171;
        margin-top: 0;
        transform: translateX(-115%);
      }
    }
  }
}
</style>
Copy the code

Utility functions:

import moment from 'moment'; // Change the value of the weekexport function formatWeek(val) {
    switch (val) {
    case '1':
        return 'Monday'
        break;
    case '2':
        return 'Tuesday'
        break;
    case '3':
        return 'Wednesday'
        break;
    case '4':
        return 'Thursday'
        break;
    case '5':
        return 'Friday'
        break;
    case '6':
        return 'Saturday'
        break;
    case '7':
        return 'Sunday'
        break;
    default:
        break; }}; // Change the value to the hour formatexport function formatHours(val) {
    return moment(val,'H').hours(); }; // Change the value to the hour-minute formatexport function formatHoursMinutes(val) {
    return moment().hour(val).minute(0).second(0).format("HH:mm"); }; // Change the value to the hour-minute formatexport function formatHoursMinutesSeconds(val) {
    return moment().hour(val).minute(0).second(0).format("HH:mm:ss");
};
Copy the code

Common ways to encapsulate range components

The package range component is mainly composed of three parts: 1. Display: mainly progress bar, slider and display. Access and display of behavior layer need to be considered; 2, behavior: mainly is the encapsulation of mouse, touch, drag behavior, generally will be sealed into a function library, whether it is native packaging, jQuery packaging or direct reference to others packaged libraries, finally exposed need to consider the scope of display layer access, type, etc. 3. Extensibility: Packaged components provide a good extensibility for introduction and modification

conclusion

In projects often need according to the need for secondary packaging components, in the process of encapsulation, the understanding of component library and grasp can better avoid the component on the propagation of the scene changes and other factors, improve efficacy, draw lessons from other thought also is very good method, at the same time for aspiring from 0 to encapsulate himself a set of component library of classmate, Also need to master the library packaging, environment configuration, expansion and community considerations, you can refer to this article from 0 to 1 to teach you to build front-end team component system (advanced advanced necessary), for only the second business packaging, based on the analysis of the source code can also be very good to enhance their packaging ability.

reference

  • Vue-range Slider Assembly (1)
  • Vue-range slider Assembly (2) – Gradient
  • Front-end plugin native JS to write range components
  • Embellishment of the range type in H5 input
  • Homemade simple Range (Vue)
  • vue-range-calendar
  • vue-range-slider
  • vue-range