Создание простого слайдера сравнения «до и после» с помощью Vue.js v2

Сравнение состояния любого объекта до и после изменений всегда было лучшим инструментом для наглядной демонстрации разницы. Поэтому здесь мы сделаем слайдер сравнения до и после с помощью Vue.js v2. И, конечно, куда же без тестов? Для тестов мы будем использовать фреймворк тестирования Jest и библиотеку утилит Vue-test-utils.

Эта страница предполагает, что вы уже прочитали документацию по Vue.js и знакомы с Vue CLI. Если вы новичок в Vue.js, сначала прочитайте эту статью.

Первым шагом будет установка vue-cli. Следующий шаг — создание скелета приложения с помощью команды vue create в терминале. Мы назовем его images-comparison-slider. Итак, перейдем в нужную директорию, где будет находиться ваш проект, и напишем команду:

vue create images-comparison-slider
Войти в полноэкранный режим Выйти из полноэкранного режима

В настройках укажите параметры по умолчанию для проекта Vue 2, babel, eslint:

После того, как наше приложение будет создано, нам нужно будет открыть его в редакторе и удалить ненужные стили и компоненты из папки src. Также переименуйте компонент HelloWorld.vue в BeforeAfter.vue и используйте его в основном компоненте App.vue:

Построение простого слайдера сравнения «до и после» с помощью Vue
В компоненте App.vue нам нужно добавить div с классом container и добавить некоторые стили.

В результате наш компонент App.vue должен выглядеть примерно так:

<template>
  <div class="container">
    <BeforeAfter
      :value="50"
      :step="0.01"
      beforeImage="https://d6xkme6dcvajw.cloudfront.net/images/Cases/godee/png/application-before.png"
      afterImage="https://d6xkme6dcvajw.cloudfront.net/images/Cases/godee/png/application-after.png"
    />
  </div>
</template>

<script>
import BeforeAfter from '@/components/BeforeAfter.vue'
export default {
  name: 'App',
  components: {
    BeforeAfter
  }
}
</script>

<style scoped>
.container {
  max-width: 980px;
  margin: 0 auto;
  height: 100vh;
}
</style>
Вход в полноэкранный режим Выход из полноэкранного режима

Компонент BeforeAfter.vue должен принимать 4 реквизита: v_alue, step, beforeImage_, и afterImage.

Для примера мы введем две ссылки на изображения одинакового размера и добавим некоторые значения для реквизитов value и step.

Давайте посмотрим на компонент BeforeAfter.vue. Вместе со стилями и логикой он выглядит следующим образом:

<template>
  <div ref="imageWrapper" class="images-wrapper">
    <img class="before-image" :src="beforeImage" alt="Before image">
    <div class="compare-overlay" :style="{ width: `${compareWidth}%`}">
      <img class="after-image" :src="afterImage" alt="After image" :style="{ width: `${width}px` }">
    </div>
    <input
      class="compare__range"
      type="range"
      min="0"
      max="100"
      :step="step"
      :value="compareWidth"
      @input="handleInput"
      tabindex="-1"
    />
    <div
      class="handle-wrap"
      :style="{ left: `${compareWidth}%` }"
    >
      <div class="handle">
        <svg
          class="handle__arrow handle__arrow--l"
          xmlns="http://www.w3.org/2000/svg"
          width="24" height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <polyline points="15 18 9 12 15 6"/>
        </svg>
        <svg
          class="handle__arrow handle__arrow--r"
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <polyline points="9 18 15 12 9 6"/>
        </svg>
      </div>
      <span class="handle-line"></span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'BeforeAfter',
  props: {
    value: {
      type: Number,
      default: 50
    },
    beforeImage: {
      type: String,
      default: ''
    },
    afterImage: {
      type: String,
      default: ''
    },
    step: {
      type: Number,
      default: 0.1
    }
  },
  data() {
    return {
      width: null,
      compareWidth: this.value,
    }
  },
  mounted() {
    this.width = this.$refs.imageWrapper.getBoundingClientRect().width
    window.addEventListener('resize', this.resizeHandler)
  },
  destroyed() {
    window.removeEventListener('resize', this.resizeHandler)
  },
  methods: {
    handleInput(e) {
      this.compareWidth = e.target.value
    },
    resizeHandler() {
      this.width = this.$refs.imageWrapper.getBoundingClientRect().width
    }
  }
}
</script>

<style scoped>
.images-wrapper {
  width: 100%;
  position: relative;
}
.compare-overlay {
  position: absolute;
  overflow:hidden;
  height: auto;
  top:0;
}
.before-image,
.after-image {
  width: 100%;
  height: auto;
}
.after-image {
  z-index: 2;
  width: 50%;
  position: relative;
}
.compare__range {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 999;
  right: 0;
  left: 0;
  height: 50px;
  cursor: ew-resize;
  background: rgba(0,0,0,.4);
  opacity: 0;
}
.handle__arrow {
  position: absolute;
  width: 20px;
}
.handle__arrow--l {
  left:0;
}
.handle__arrow--r {
  right:0;
}
.handle-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 50%;
  height: 100%;
  transform: translate(-50%, -50%);
  z-index: 5;
}
.handle {
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  background: #FF0000;
  border-radius: 50%;
  width: 30px;
  height: 30px;
}
.handle-line {
  content: '';
  position: absolute;
  top:0;
  width: 2px;
  height: 100%;
  background: #FF0000;
  z-index: 4;
  pointer-events:none;
  user-select:none;
}
@media screen and (max-width: 568px) {
  .handle {
    width: 25px;
    height: 25px;
  }
  .handle__arrow {
    width: 20px;
  }
}
@media screen and (max-width: 480px) {
  .handle {
    width: 15px;
    height: 15px;
  }
  .handle__arrow {
    width: 10px;
  }
}
</style>
Вход в полноэкранный режим Выход из полноэкранного режима

Мы не будем углубляться в стили компонента, любой разработчик стилизует его в соответствии со своими требованиями. Рассмотрим основную логику работы компонента. Мы принимаем 4 реквизита, которые были описаны выше: value, step, beforeImage и afterImage. Значение необходимо для указания начальной позиции ползунка, шаг — для шага перемещения ползунка, а beforeImage, afterImage — это пути к двум изображениям.

Чтобы получить значение позиции курсора, мы используем элемент input, располагаем его в центре по вертикали и скрываем со стилем opacity: 0.

Повесим на этот элемент слушатель событий, и будем получать значение для дальнейшего присвоения его ширине контейнера изображения в методе handleInput.

Чтобы регулировать ширину изображений при изменении ширины экрана, мы используем обработчик события для окна и обрабатываем событие resize (строка 90 в коде выше). Конечно, нам нужно удалить все обработчики событий, когда наш компонент будет уничтожен (строка 94 в приведенном выше коде). Мы также должны определить две опции в объекте данных: width и compareWidth. Опции width присвоим значение: null, а для compareWidth возьмем значение из props: this.value. Когда наш компонент будет смонтирован, мы установим значение для ширины, взятое из ref контейнера (посмотрите на строки 89 и 2 в коде выше).

Для визуального отображения границы движения курсора мы используем handle-wrap и стилизуем его так, как нам нравится.

Основная логика заключается в том, что мы берем значение из ввода и используем метод handleInput для перезаписи значения compareWidth.

Мы можем упростить наш код, избавившись от метода handleInput и просто используя v-model для двусторонней привязки входного значения к compareWidth. Поэтому мы можем удалить метод handleInput и добавить v-model к элементу input. Вот и все. Все должно работать отлично! Давайте посмотрим на онлайн-редактор кода; ссылка ниже.

Готовый слайдер и исходный код можно посмотреть в онлайн-редакторе. Также вы можете увидеть, как этот слайдер работает на нашем сайте. Следующим шагом будет покрытие нашего компонента тестами с использованием движка Jest и библиотеки утилит Vue-test-utils.

Покрытие компонента тестами

Мы будем использовать тип юнит-тестов. Скорее всего, не нужно объяснять, что и как использовать. Но на всякий случай мы сделаем это вместе.

Vue Test Utils (VTU) — это набор утилит, упрощающих тестирование компонентов Vue.js. Он предоставляет некоторые методы для изолированного монтажа и взаимодействия с компонентами Vue.

Мы установим необходимые инструменты для тестирования нашего компонента, для этого прочитайте документацию по этой ссылке.

Затем нам нужно указать Jest преобразовывать файлы .vue с помощью vue-jest. Это можно сделать, добавив следующую конфигурацию в package.json

Наш файл package.json выглядит следующим образом:

{
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "vue"
    ],
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1"
    },
    "transform": {
      ".*\.(vue)$": "vue-jest",
      ".*\.(js)$": "babel-jest"
    }
  },
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы запустить тесты, нам нужно ввести в терминале в корне проекта следующую команду: npm run test:unit. Она прописана в файле package.json в поле scripts

"scripts": {
  "serve": "vue-cli-service serve",
  "build": "vue-cli-service build",
  "test:unit": "vue-cli-service test:unit",
  "lint": "vue-cli-service lint"
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Если мы попытаемся выполнить команду npm run test-unit, в терминале появится сообщение об отсутствии тестов. Поэтому давайте создадим каталог и файл test. Иерархия папки и файла с тестами будет выглядеть следующим образом:

Чтобы движок Jest распознал наш тестовый файл, расширение файла должно быть testComponent.spec.js. Структура тестовых файлов и каталога внутри папки unit должна в точности повторять структуру корневого каталога src нашего проекта, чтобы избежать различного рода путаницы.

Давайте напишем тесты для компонента BeforeAfter.vue. Прежде всего, мы должны импортировать наш компонент в тестовый файл и импортировать функцию shallowMount. Функция ShallowMount будет загружать сам компонент, игнорируя дочерние компоненты.

Полный код выглядит следующим образом:

import BeforeAfter from '@/components/BeforeAfter'
import { shallowMount } from '@vue/test-utils'
import 'regenerator-runtime'

const props = {
  afterImage: 'img.jpg',
  beforeImage: 'img.jpg',
  value: 50,
  step: 0.1,
}

const testWidth = 420
const inputValue = 89

jest.spyOn(window, 'removeEventListener').mockImplementation()
jest.spyOn(window, 'addEventListener').mockImplementation()

describe('BeforeAfter component', () => {
  let wrapper

  beforeAll(() => {
    window.addEventListener('resize')
  })

  beforeEach(() => {
    wrapper = shallowMount(BeforeAfter, {
      propsData: props,
    })
  })

  afterEach(() => {
    wrapper = null
  })

  it('should render correctly with images', () => {
    expect(wrapper.props()).toEqual(props)
    expect(wrapper.is(BeforeAfter)).toBe(true)
    expect(wrapper).toMatchSnapshot()
  })

  it('the compareWidth should correctly react to change value of the input', () => {
    const rangeInput = wrapper.find('.compare__range')
    rangeInput.setValue(inputValue)
    rangeInput.trigger('input change')

    setTimeout(() => {
      expect(wrapper.find('.img-wrapper__compare-overlay').attributes().style).toBe(`width: ${inputValue}%;`)
    }, 0)
    expect(wrapper.vm.compareWidth).toBe(`${inputValue}`)
  })

  it('does not fire resize event by default', () => {
    expect(window.innerWidth).not.toBe(testWidth)
  })

  it('updates the window width', () => {
    window.innerWidth = testWidth
    window.dispatchEvent(new Event('resize'))
    expect(window.innerWidth).toBe(testWidth)
  })

  it('should correctly remove resize event listener from window when component is destroyed', () => {
    wrapper = shallowMount(BeforeAfter, {
      propsData: props,
      attachTo: document.body,
    })

    wrapper.destroy()

    expect(window.removeEventListener).toHaveBeenCalledTimes(1)
  })
})
Вход в полноэкранный режим Выход из полноэкранного режима

Далее нам нужно передать реквизиты. Поэтому давайте создадим тестовые реквизиты для передачи компоненту и заранее подготовим тестовую ширину и значение для ввода (строки 5-13 приведенного выше кода). Следующим шагом будет блокировка путем добавления и удаления слушателей событий для окна (строки 15, 16 приведенного выше кода).

Затем нужно создать блок describe, чтобы сгруппировать несколько связанных тестов (см. документацию Jest ). После объявления переменной-обертки перед всеми тестами мы вешаем обработчик события изменения размера окна (строка 21). Блок кода beforeEach выполняется перед каждым модульным тестом и повторно инициализирует реквизиты, которые будут переданы компоненту BeforeAfter, мы присваиваем переменную wrapper компоненту с помощью функции shallowMount с поддельными реквизитами. После каждого теста мы переопределяем переменную-обертку на null.

Далее мы пишем отдельные блоки «it», которые являются обертками для наших тестов. Описание каждого теста находится в первом аргументе функции «it».

Резюме

Здесь мы узнали, как разрабатывать и тестировать слайдер до и после, используя Vue.js v2 в своем проекте. Обладая соответствующими навыками, вы сможете модифицировать этот код, использовать его в различных случаях и значительно улучшить пользовательский интерфейс и пользовательский опыт.

Ранее опубликовано на maddevs.io/blog.

Оцените статью
Procodings.ru
Добавить комментарий