Ох уж эти модальные окна или почему я полюбил render-функции в VueJs

  • Tutorial
Привет всем!
Моя первая публикация прошла с неприятным осадком. Я обещал исправить это недоразумение и на ваш суд представляю свою первую статью-урок по VueJs. Надеюсь, она окажется полезной. Мыслей много, опыта тоже немало. Всю жизнь учусь по чужим статьям, урокам. Пришло время тоже делиться знаниями.
А будем мы творить модальные окна. Да опять они. Но не такие простые, как описаны в первой моей (не моей) публикации.

Много уже их создано для Vue. Пользовался всякими. И видимо, когда достигаешь какого-то определенного уровня владения инструментом (в данном случае Vue), сразу хочется сделать велосипед, но конечно со своими прибамбасами, типа, чтобы круче всех и т.д. И я не стал исключением из правил.

Из всех доступных модальных компонентов, использовал в основном этот — Vuedals.
Но решил я его проапгрейдить. В принципе от основы остался только EventBus и взаимодействие событий связанных с открытием-закрытием окон. Основной компонент переписан и стал оберткой-контейнером и добавлен новый компонент — само модальное окно.
Но обо всем по порядку. И статья получится очень немаленькая, кто осилит, тот красавчик :)

В основном модальные окна во всех примерах вызываются в таком стиле:

<template>
  <button @click="visible = true">Show modal</button>
  <modal :show="visible">
    <div>какой то контент</div>
  </modal>
</template>

<script>
export default {
  data() {
    return {
      visible: false
    }
}
</script>

Вроде все красиво. Но!

Какие вижу недостатки такого подхода.

Во-первых, темплейт модального окна находится внутри родительского компонента, где мы его вызываем. И контекст окна не изолирован от родителя. Мне так не всегда удобно и нужно.
Во-вторых, если одно и то-же окно используется в нескольких местах, приходится дублировать код. Что не есть гуд!

В-третьих, и что наверно является самым главным недостатком — мы можем использовать модальное окно только внутри страниц или других компонентов Vue, а вот в местах типа Vuex, Router, да и вообще в любых скриптах не можем. Мне например, надо вызвать модальное окно входа/регистрации из роутера или из стора при каком-то событии. Примеров можно привести мильён.

Поэтому подход используемый в Vuedals, когда мы открываем/закрываем окна путем вызова функции с параметрами и передавая «сырой» компонент, вида —

{
  name: 'simple-modal',
  props: ['test'],
  template: "<div>{{test}}</div>"
}

или полноценный, который мы импортнули извне, мне оказался больше по душе.
Больше контроля, возможностей переиспользования и вызвать такое окно можно практически отовсюду.

Выглядит в общем это так, у нас есть компонент ModalWrapper, который мы импортируем в приложение и вставляем например в корневой App-компонент. Где нибудь внизу.

Потом в любом месте вызываем метод this.$modals.open({ component: SimpleModal, title: 'Simple modal'}), куда передаем настройки нашего окна и компонент, который будем показывать и видим наше модальное окошко, которое рендерится в ModalWrapper.

Есть куча событий, возникающие при всех манипуляциях с окнами, события эти управляются при помощи EventBus-а, и их можно прослушивать и как-то реагировать.

Это вводные данные, чтобы информация легче осваивалась. В принципе, статья больше для новичков в Vue. Но надеюсь, будет пара моментов интересных и для искушенных.

Буду кидать куски кода и попутно комментировать, что к чему. Ссылка на примеры и исходники в конце есть.

Ну и пожалуй начнем с главного файла —

index.js
import Bus from './utils/bus';
import ModalWrapper from './modal-wrapper';
import Modal from './modal'

const VuModal = {}
VuModal.install = (Vue) => {
  Vue.prototype.$modals = new Vue({
    name: '$modals',

    created() {
      Bus.$on('opened', data => {
        this.$emit('modals:opened', data);
      });

      Bus.$on('closed', data => {
        this.$emit('modals:closed', data);
      });

      Bus.$on('destroyed', data => {
        this.$emit('modals:destroyed', data);
      });

      this.$on('new', options => {
        this.open(options);
      });

      this.$on('close', data => {
        this.close(data);
      });

      this.$on('dismiss', index => {
        this.dismiss(index || null);
      });
    },

    methods: {
      open(options = null) {
        Bus.$emit('new', options);
      },

      close(data = null) {
        Bus.$emit('close', data);
      },

      dismiss(index = null) {
        Bus.$emit('dismiss', index);
      }
    }
  });

  Vue.mixin({
    created() {
      this.$on('modals:new', options => {
        Bus.$emit('new', options);
      });

      this.$on('modals:close', data => {
        Bus.$emit('close', data);
      });

      this.$on('modals:dismiss', index => {
        Bus.$emit('dismiss', index);
      });
    }
  });
}

if (typeof window !== 'undefined' && window.Vue) {
  window.Vue.use(VuModal);
}

export default VuModal;

export {
  ModalWrapper,
  Modal,
  Bus
}


В нем мы импортируем компоненты, используемые для наших модальных-премодальных окон:

  1. ModalWrapper.js — общая обертка для вывода наших окон
  2. Modal.js — собственно сам компонент модального окна. Его нет в оригинальном Vuedals. Использовать напрямую его не обязательно. Он в любом случае работает под капотом. Дальше по ходу пьесы увидите этот финт ушами и станет понятно для чего я его добавил.
  3. Bus.js — EventBus для коммуникации между компонентом-оберткой (ModalWrapper), модальными окнами (Modal) и нашим приложением VueJs.

Сразу приведу код Bus.js и опишу что там происходит. Как говорил ранее, EventBus оставил так, как есть в оригинале.

Bus.js
let instance = null;

class EventBus {
  constructor() {
    if (!instance) {
      this.events = {};
      instance = this;
    }
    return instance;
  }
  $emit(event, message) {
    if (!this.events[event])
      return;
    const callbacks = this.events[event];
    for (let i = 0, l = callbacks.length; i < l; i++) {
      const callback = callbacks[i];
      callback.call(this, message);
    }
  }
  $on(event, callback) {
    if (!this.events[event])
      this.events[event] = [];
    this.events[event].push(callback);
  }
}
export default new EventBus();


Здесь мы создаем singleton-экземпляр EventBus-а, который может подписываться на события ($on) и вызывать ($emit) события. Думаю здесь объяснять особо нечего. EventBus собирает коллбеки и когда надо их вызывает. Дальше по ходу дела будет видно и понятно, как он связывает все наши компоненты.

А теперь по index.js

Здесь мы экспортируем по умолчанию дефолтную функцию install — для подключения наших окон к приложению (используя Vue.use()) и компоненты ModalWrapper, Modal и Bus. Ну и при подключении VueUniversalModal через script-тег в браузере, активируем наш компонент, если подключен глобальный VueJs на странице.

И по порядку:

$modals
Vue.prototype.$modals = new Vue({
    name: '$modals',
    created() {
      Bus.$on('opened', data => {
        this.$emit('modals:opened', data);
      });
      Bus.$on('closed', data => {
        this.$emit('modals:closed', data);
      });
      Bus.$on('destroyed', data => {
        this.$emit('modals:destroyed', data);
      });
      this.$on('new', options => {
        this.open(options);
      });
      this.$on('close', data => {
        this.close(data);
      });
      this.$on('dismiss', index => {
        this.dismiss(index || null);
      });
    },
    methods: {
      open(options = null) {
        Bus.$emit('new', options);
      },
      close(data = null) {
        Bus.$emit('close', data);
      },
      dismiss(index = null) {
        Bus.$emit('dismiss', index);
      }
    }
  });


Здесь мы цепляем к глобальному VueJs (через prototype) экземпляр Vue под именем $modals.

В его методе created (который запустится сразу, после запуска приложения) мы подписываем наш EventBus к событиям opened (открытие окна), closed (закрытие окна) и destroyed (окон нет, убираем ModalWrapper). При возникновении этих событий EventBus будет эмитить события modals:opened, modals:closed и modals:destroyed в компонент $modals. Эти события мы можем слушать везде, где доступен самолично VueJs.

Вообще, я сначала хотел повыкидывать половину этих коммуникаций, так как некоторые совсем не обязательны, но подумав оставил. Может пригодиться для сбора какой-то статистики по модальным окнам, например. Да и начинающие, возможно что-то для себя поймут в этой, казалось бы, каше из $on, $emit — вызовов.

Дальше this.$on…

Здесь мы включаем прослушивание событий new, close, dismiss самим компонентом $modals. При возникновении этих событий, вызываются соответствующие методы компонента $modals. Которые в свою очередь открывают (open), закрывают (close) и отменяют (dismiss) окно.

Как вы видите, у нас есть два способа закрыть окно — dismiss (отменить или по буржуйски — cancel — из той же оперы) и close (закрыть). Разница в том, что при закрытии модального окна через close, мы можем передавать данные в функцию обратного вызова onClose (рассмотрим далее), которую мы цепляем к опциям нашего нового модального окна.

И собственно методы open, close и dismiss компонента $modals. В них мы и запускаем через EventBus, события new, close и dismiss в нашем ModalWrapper. Там уже и будет происходить вся магия.

И последнее в install-функции файла index.js.

Vue.mixin({
    created() {
      this.$on('modals:new', options => {
        Bus.$emit('new', options);
      });
      this.$on('modals:close', data => {
        Bus.$emit('close', data);
      });
      this.$on('modals:dismiss', index => {
        Bus.$emit('dismiss', index);
      });
    }
  });

Здесь мы расширяем через Vue-миксин всем компонентам Vue метод created, в котором при запуске включаем прослушку компонентами событий modals:new, modals:close и modals:dismiss и при их вызове, через EventBus опять же запускаем соответствующие события в ModalWrapper.

Все эти адовы вызовы здесь нужны для управления нашими модальными окнами. И дают нам 4 варианта запуска событий open, close и dismiss.

Первый способ вызова нашего модального окна в приложении:

this.$modals.open(options)

Второй способ:
this.$modals.$emit('new', options)

Третий:

this.$emit('modals:new', options)

И четвертый (для этого способа нам нужно импортировать Bus.js, но это дает нам возможность вызвать окно не из компонента Vue, а из любого скрипта):

Bus.$emit('new', options)

Ну и close, dismiss по аналогии.

Тут как говорится — «на вкус и цвет».

Следующий больной — Modal.js или мы не ищем легких путей


Дальше пойдет код повеселее. С недавнего времени в своих компонентах использую render-функции (как стандартные так и в jsx-формате). Использовать начал повсеместно тогда, когда понял, что они дают больше возможностей для рендеринга. С render-функциями вся мощь Javascript-а плюс крутая внутренняя VueJs кухня с vNode дают ощутимые бонусы. В момент их появления я на них как-то косо глянул, подумал, нафиг надо и продолжил рисовать компоненты в template-шаблонах. Но теперь я знаю, где собака зарыта.

Modal — это полноценный компонент, который рендерит само модальное окно. У него куча входящих параметров:

modal.js - props
  props: {
    title: { // заголовок окна
      type: String,
      default: ''
    },
    className: { // добавляемый css-класс к компоненту окна
      type: String,
      default: ''
    },
    isScroll: { // скроллинг контента, если он - контент не умещается в размеры окна
      type: Boolean,
      default: false
    },
    escapable: { // dismiss(сброс) окна по нажатию Esc-клавиши
      type: Boolean,
      default: false
    },
    dismissable: { // dismiss(сброс) окна по клику на его маску и показ кнопки закрытия в правом верхнем углу (крестика)
      type: Boolean,
      default: true
    },
    fullscreen: {  // полноэкранный режим
      type: Boolean,
      default: false
    },
    isTop: { // прижать окно к верху страницы
      type: Boolean,
      default: false
    },
    isBottom: { // прижать окно к низу страницы
      type: Boolean,
      default: false
    },
    isLeft: { // прижать окно к левой стороне страницы
      type: Boolean,
      default: false
    },
    isRight: {  // прижать окно к правой стороне страницы
      type: Boolean,
      default: false
    },
    center: {  // окно посередине страницы
      type: Boolean,
      default: false
    },
    size: { // размер окна (высота)
      type: String,
      default: 'md'
    },
    bodyPadding: {  // padding-отступы у body - элемента, в котором рендерится контент
      type: Boolean,
      default: true
    }
  },


В коде прокомментировал все параметры, чтобы было нагляднее. А код в спойлер кидаю, чтобы не получилась портянка. Итак текста немало.

Дальше:

import CloseIcon from './close-icon'
export default {
  name: 'vu-modal',
  componentName: 'vu-modal',
  ...
}

Вначале импортируем иконку-крестик в виде функционального компонента, который рендерит ее в SVG-формате.

close-icon.js
export default {
  name: 'close-icon',
  functional: true,
  render(h) {
    return h('svg', {
      attrs: {
        width: '12px',
        height: '12px',
        viewBox: '0 0 12 12',
        xmlSpace: 'preserve'
      }
    }, [
        h('line', {
          attrs: {
            x1: 1,
            y1: 11,
            x2: 11,
            y2: 1
          },
          style: {
            strokeLinecap: 'round',
            strokeLinejoin: 'round',
          }
        }),
        h('line', {
          attrs: {
            x1: 1,
            y1: 1,
            x2: 11,
            y2: 11
          },
          style: {
            strokeLinecap: 'round',
            strokeLinejoin: 'round',
          }
        })
    ])
  }
}


Зачем я ее так сообразил, даже не знаю.

Дальше параметр name — ну это стандартный ход.

А вот componentName здесь неспроста. Он нам нужен будет дальше, в ModalWrapper-е при рендеринге.

И вычисляемый параметр propsData:

export default {
  ...
  computed: {
    propsData() {
      return (this.$parent.$vnode.data.props 
           && this.$parent.$vnode.data.props.vModal) ? 
        this.$parent.$vnode.data.props.vModal : this.$props
    }
  }  
}

Здесь уже сложнее.

А дело вот в чем. В оригинальном Vuedals все окна тоже вызываются с помощью тех 4-х способов, описанных выше. В каждом из них мы должны передавать компонент, который хотим показать в окне и параметры окна (все они сейчас есть во входящих параметрах Modal и плюс добавлены несколько новых). И если мы хотим запустить одно и то же окно в разных частях приложения, мы каждый раз передаем параметры окна (размеры, другие настройки). Что опять является дублированием. Да и запутаться недолго. А мы, программисты, существа крайне ленивые в основе своей. Поэтому и был создан этот компонент Modal.

Теперь мы можем создать компонент модального окна, например, вот так:

simple-modal.js
<template lang="html">
  <vu-modal title="Test modal" :isScroll="true" size="p50" :center="true" :escapable="true">
    <div slot="header" style="display: flex; justify-content: left; align-items: center;" v-if="header">
      <div style="padding-left: 10px">Simple modal</div>
    </div>
  <div>
     <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quia consequuntur minus sint quidem ut tenetur dicta sunt voluptates numquam. Eum totam ex maxime aut recusandae quae laborum fugit ab autem.</p>
  </div>
    <div slot="footer">
      <button class="uk-button uk-button-smaller uk-button-primary" @click="close">Cancel</button>
    </div>
  </vu-modal>
</template>

<script>
export default {
  name: 'simple-modal',
  props: {
    lorem: {
      type: Boolean,
      default: true
    }
  }
};
</script>


То есть, стандартный компонент. Обернутый нашим Modal (vu-modal). Этому vu-modal мы передаем нужные нам параметры. Они и будут значениями по умолчанию для этого окна.

И теперь мы вызываем это окно так:

import SimpleModal from './modals/simple'
 ...
 this.$modals.open({
  component: SimpleModal
})

Все нужные нам значения дефолтных настроек окна берутся автоматом из того самого компонента SimpleModal, снятых с обертки vu-modal. Мы один раз создали компонент окна с нужными нам настройками и потом используем его в любом месте не парясь о настройках. Более того, если нам надо переназначить те значения по умолчанию, мы указываем нужные нам значения при вызове этого окна:

import SimpleModal from './modals/simple'
 ...
 this.$modals.open({
  component: SimpleModal,
  center: false
})

Теперь параметр center заменит дефолтный параметр указанный в шаблоне окна — SimpleModal.

То есть приоритет такой при мерджинге (слиянии) параметров:

  • props (дефолтные значения в modal.js)
  • props (в шаблоне компонента, обернутого vu-modal)
  • options (при вызове окна)

Чем ниже, тем главнее.

Так вот вычисляемое свойство propsData в компоненте vu-modal возвращает нам правильные входящие параметры (props), учитывая момент, является ли данный экземпляр vu-modal оберткой в каком-то компоненте (SimpleModal) или же нет.
Для этого при рендеринге окна в ModalWrapper, если компонент этого окна обернут в vu-modal мы будем передавать смердженные props-ы под именем vModal, в другом случае будем передавать обычные props-ы.

Но так как в случае, когда компонент обернут в vu-modal, при рендере props-ы будут попадать в этот компонент-родитель (SimpleModal), мы и проверяем, есть ли у компонента-родителя входящий параметр с именем vModal. Если есть, то берем эти значения, иначе стандартные props-ы.

А проверяем мы не у this.$parent.$options.propsData, а именно у this.$parent.$vnode.data.props, потому что если у компонента-родителя не прописан в props-ах параметр vModal, то при рендеринге этот vModal мы cможем увидеть только у this.$parent.$vnode.data.props. Сюда попадают все без исключения параметры, которые мы передали. А потом уже фильтруются и лишние отбрасываются.

Приведу еще раз этот кусок кода, он маленький, чтобы с мысли не сбивать.

export default {
  ...
  computed: {
    propsData() {
      return (this.$parent.$vnode.data.props 
          && this.$parent.$vnode.data.props.vModal) ? 
        this.$parent.$vnode.data.props.vModal : this.$props
    }
  }  
}

Сейчас возможно не совсем все понятно. Информации много, и не совсем все стандартное, как во многих уроках. Пишу такого рода статью в первый раз, не совсем пока ясно, как лучше преподать. Во внутренностях Vue копаются наверняка многие, но мало кто пишет об этом. Сам долгое время искал инфу по таким моментам. Что-то находил, остальное ковырял сам. И хочется рассказывать о таких вещах.

Но более понятно станет, когда будем разбирать ModalWrapper. Там мы будем формировать и отправлять смердженные props-ы нашим окнам.

Ну и осталась render-функция нашего компонента Modal (vu-modal):

render(h)"
render(h) {
    const { dismissable, title, isScroll, fullscreen, isTop, isBottom, isLeft, isRight, center, size, className, bodyPadding } = this.propsData

    const closeBtn = dismissable 
      ? h('div', {
            class: 'vu-modal__close-btn',
            on: {
              click: () => {this.$modals.dismiss()}
            }
        }, [h(CloseIcon)])
      : null

    const headerContent = this.$slots.header 
      ? this.$slots.header
      : title 
        ? h('span', {class: ['vu-modal__cmp-header-title']}, title)
        : null

    const header = headerContent 
      ? h('div', { 
          class: ['vu-modal__cmp-header']
        }, [ headerContent ]) 
      : null

    const body = h('div', { 
      class: ['vu-modal__cmp-body'], 
      style: {
        overflowY: isScroll ? 'auto' : null,
        padding: bodyPadding ? '1em' : 0
      }
      }, [ this.$slots.default ])

    const footer = this.$slots.footer 
      ? h('div', { 
          class: ['vu-modal__cmp-footer']
        }, [ this.$slots.footer ]) 
      : null

    let style = {}
    let translateX = '-50%'
    let translateY = '0'
    if(center) {
        translateX = '-50%'
        translateY = '-50%'
    }
    if(isRight || isLeft) {
        translateX = '0%'
    }
    if((isTop || isBottom) && !isScroll && !center) {
        translateY = '0%'
    }
    style.transform = `translate(${translateX}, ${translateY})`

    return h('div', { 
      style,
      class: ['vu-modal__cmp', {
          'vu-modal__cmp--is-fullscreen': fullscreen,
          'vu-modal__cmp--is-center': center,
          'vu-modal__cmp--is-top': isTop && !isScroll && !center,
          'vu-modal__cmp--is-bottom': isBottom && !isScroll && !center,
          'vu-modal__cmp--is-left': isLeft,
          'vu-modal__cmp--is-right': isRight
        }, 
        isScroll && fullscreen && 'vu-modal__cmp--is-scroll-fullscreen',
        isScroll && !fullscreen && 'vu-modal__cmp--is-scroll',
        !fullscreen && `vu-modal__cmp--${size}`,
        className 
      ],
      on: {click: (event) => {event.stopPropagation()}}
    }, [
      closeBtn,
      header,
      body,
      footer
    ])
  }


Здесь вроде ничего необычного.

Сначала вытаскиваем все наши параметры из ранее описанного вычисляемого значения propsData.

Выводим кнопку-крестик, которое вызывает событие dismiss (отмену окна), если свойство dismissable равно true.

Формируем header — если нашему vu-modal передан слот с именем header (this.$slots.header) рисуем этот слот, если передано свойство title — выводим его, иначе header вообще не показываем.

Формируем блок body с содержимым дефолтного слота (this.$slots.default).
И следом footer — если передан слот footer (this.$slots.footer).
Дальше мы определяем правильные значения для css-свойства transform: translate(x, y) нашего окна. А именно параметры X и Y в зависимости от переданных свойств нашему окну. И потом мы передаем при рендере этот transform главному div-у окна для правильного позиционирования.

Ну и рендерим все это дело, попутно вычисляя нужные класс.

И плюс вешаем на главный div.vu-modal__cmp onClick-обработчик, с event.stopPropagation(), чтобы клик по окну не всплывал выше, дабы не активировать клик по div-у (маске), которым обернуто каждое окно и которое реагирует на клик и вызывает dismiss. Иначе сработает это событие dismiss на маске и наше окно закроется.
Уффф!

Завершающий компонент — ModalWrapper


Начало modal-wrapper.js

import './style.scss'
import Bus from './utils/bus'
import ModalCmp from './modal'

export default {
name: 'vu-modal-wrapper',
data () {
return {
modals: []
}
},
mounted() {
if (typeof document !== 'undefined') {
document.body.addEventListener('keyup', this.handleEscapeKey)
}
},
destroyed() {
if (typeof document !== 'undefined') {
document.body.removeEventListener('keyup', this.handleEscapeKey)
}
},

Подключаем наши стили:
style.scss
body.modals-open {
  overflow: hidden;
}

.vu-modal {
  &__wrapper {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 5000;
    overflow-x: hidden;
    overflow-y: auto;
    transition: opacity .4s ease;
  }

  &__mask {
    background-color: rgba(0, 0, 0, .5);
    position: absolute;
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    &--disabled {
      background-color: rgba(0, 0, 0, 0);
    }
  }

  &__cmp {
    display: flex;
    flex-direction: column;
    border-radius: 0px;
    background: #FFF;
    box-shadow: 3px 5px 20px #333;
    margin: 30px auto;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    width: 650px;

    &--is-center {
      margin: auto;
      top: 50%;
    }
    &--is-scroll {
      max-height: 90%;
    }

    &--is-scroll-fullscreen {
      max-height: 100%;
    }

    &--is-fullscreen {
      width: 100%;
      min-height: 100%;
      margin: 0 0;
    }

    &--is-bottom {
      bottom: 0;
    }

    &--is-top {
      top: 0;
    }

    &--is-right {
      right: 0;
      margin-right: 30px;
    }

    &--is-left {
      left: 0;
      margin-left: 30px;
    }

    &--xl {
      width: 1024px;
    }

    &--lg {
      width: 850px;
    }

    &--md {
      width: 650px;
    }

    &--sm {
      width: 550px;
    }

    &--xs {
      width: 350px;
    }

    &--p50 {
      width: 50%;
    }

    &--p70 {
      width: 70%;
    }

    &--p90 {
      width: 90%;
    }

    &-body {
      padding: 1em;
      &::-webkit-scrollbar {
        width: 6px;
      }

      &::-webkit-scrollbar-track {
        -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
      }

      &::-webkit-scrollbar-thumb {
        background-color: darkgrey;
        outline: 1px solid slategrey;
      }
    }

    &-header {
      user-select: none;
      border-bottom: 1px solid #EEE;
      padding: 1em;
      text-align: left;
      &-title {
        font-size: 16px;
        font-weight: 800;
      }
    }

    &-footer {
      border-top: solid 1px #EEE;
      user-select: none;
      padding: 1em;
      text-align: right;
    }
  }

  &__close-btn {
    user-select: none;
    position: absolute;
    right: 12px;
    top: 5px;
    line {
      stroke: grey;
      stroke-width: 2;
    }
    &:hover {
      cursor: pointer;
      line {
        stroke: black;
      }
    }
  }
}


В массиве modals мы будем хранить наши окна, которые активны в данный момент.

Ну и при монтировании и удалении нашего ModalWrapper-компонента вешаем обработчик keyup на window (если window есть), который запускает метод handleEscapeKey:

handleEscapeKey
handleEscapeKey(e) {
      if (e.keyCode === 27 && this.modals.length) {
        if (!this.modals.length)
          return;
        if (this.current.options.escapable)
          this.dismiss();
      }
    }


Который в свою очередь, если нажата Esc-клавиша и есть окно(а) и у текущего (запущенного последним) окна свойство escapable равно true, запускает метод dismiss, который закрывает это самое текущее окно.

Ну и пожалуй самое интересное началось. Попробую описывать происходящее прямо в коде, может так лучше будет.

При создании нашего ModalWrapper включаем прослушку событий от EventBus-а. Тех самых, которые запускаются в методах $modals, описанных раннее:

created()
created() { 
    Bus.$on('new', options => { // главное событие, открытие окна
      const defaults = { // значения параметров по умолчанию, эти же параметры у компонента Modal
        title: '',
        dismissable: true,
        center: false,
        fullscreen: false,
        isTop: false,
        isBottom: false,
        isLeft: false,
        isRight: false,
        isScroll: false,
        className: '',
        size: 'md',
        escapable: false,
        bodyPadding: true
      };

// а вот здесь немного магии!
// формируем правильные props-ы для нового окна. О чем говорил раннее. 
// rendered нужен нам для определения того, является ли переданный компонент в options, обернутым в Modal (vu-modal) или нет
      let instance = {} // объект, который мы будем добавлять в массив modals
      let rendered
      if(options.component.template) {      
// если мы передаем "сырой" компонент с template, то оберток Modal мы там не ожидаем, хотя возможен такой вариант. Но не будем его рассматривать. Это дело можно поправить. Сейчас это не актуально. 
        rendered = false
      } else {
// иначе вызываем функцию render переданного компонента, дабы получить его componentOptions
        rendered = options.component.render.call(this, this.$createElement)
      }
  // из которых мы и вытаскиваем таким длинным путем упомянутое раннее корневое свойство componentName компонента Modal. Если оно есть и равно 'vu-modal', то наш компонент обернут Modal (vu-modal)
      if(rendered && rendered.componentOptions && rendered.componentOptions.Ctor.extendOptions.componentName === 'vu-modal') {
// в таком случае берем его props-ы, те самые которые мы указали в template-компонента у vu-modal 
        const propsData = rendered.componentOptions.propsData
        instance = {
          isVmodal: true, // это значение тоже передаем в массив, чтобы использовать позднее при рендеринге
          options: Object.assign(defaults, propsData, options) // опции мерджим по приоритету описанному раннее
        }
      } else {
        instance = {
          isVmodal: false, // иначе у нас компонент не обернут vu-modal
          options: Object.assign(defaults, options) // опции мерджим только с дефолтными
        }
      }
      rendered = null

      this.modals.push(instance); // добавляем в modals

      Bus.$emit('opened', { // посылаем событие об открытом окне через EventBus c данными нового окна
        index: this.$last, // его индекс в массиве, последний
        instance // и настройки
      });

      this.body && this.body.classList.add('modals-open'); // добавляем к элементу body страницы нужный класс 
    });


Далее события:

close и dismiss
Bus.$on('close', data => { // помните, раннее я писал о возможности передачи данных при закрытии окна через close
      let index = null;

      if (data && data.$index) 
        index = data.$index; // можем передать индекс определенного окна

      if (index === null)
        index = this.$last; // если индекса нет, то берем последний

      this.close(data, index); // вызываем метод close с данными и индексом
    });

    Bus.$on('dismiss', index => { // при закрытии окна через dismiss, можем указать индекс определенного окна
      if (index === null)
        index = this.$last; // если нет, берем последний

      this.dismiss(index); // вызываем метод dismiss с индексом
    });


Теперь методы:

splice
methods: {
splice(index = null) { // для внутреннего использования, при закрытии окна
      if (index === -1)
        return;

      if (!this.modals.length)
        return;

      if (index === null) // если индекс не передан, то удаляем последний
        this.modals.pop();
      else
        this.modals.splice(index, 1);

      if (!this.modals.length) { // если окна закончились
        this.body && this.body.classList.remove('modals-open');  // у body убираем класс 'modals-open' 
        Bus.$emit('destroyed'); // и посылаем сигнал, через EventBus, о том, что активных окон нет
      }
    }
}


close
doClose(index) { // здесь мы удаляем из массива modals окно, при помощи метода splice, описанного выше
      if (!this.modals.length)
        return;

      if (!this.modals[index])
        return;

      this.splice(index);
    },

 // собственно, главный обработчик закрытия окна, через close. Можем передать нужные данные и указать на конкретное окно по индексу
    close(data = null, index = null) {
      if (this.modals.length === 0)
        return;

      let localIndex = index;

// если переданный index является функцией, запускаем ее для определения нужного индекса, при этом передаем в эту функцию данные и массив с окнами. То есть можем закрыть определенное окно, при каких-то условиях
      if (index && typeof index === 'function') { 
        localIndex = index(data, this.modals);
      }

      if (typeof localIndex !== 'number') 
        localIndex = this.$last; // иначе берем последнее окно

// далее, смотрим, если в настройках окна есть callback-функция onClose, запускаем ее с переданными данными, если они есть
// мы можем вернуть из onClose какое-то значение, если оно равно false, то отменяем закрытие окна
      if (localIndex !== false && this.modals[localIndex]) {
        if(this.modals[localIndex].options.onClose(data) === false) { 
          return 
        }
      }

       Bus.$emit('closed', { // эмитим событие 'closed' и передаем туда все данные
        index: localIndex, // индекс окна
        instance: this.modals[index], // его настройки 
        data // и данные, если есть
      });

// и собственно, если выше все прошло удачно, удаляем окно из массива modals, тем самым закрывая его
      this.doClose(localIndex); 
    },


В методе dismiss, все аналогично методу close:

dismiss
dismiss(index = null) {
      let localIndex = index;

      if (index && typeof index === 'function')
        localIndex = index(this.$last);

      if (typeof localIndex !== 'number')
        localIndex = this.$last;

      if (this.modals[localIndex].options.onDismiss() === false)
        return;

      Bus.$emit('dismissed', {
        index: localIndex,
        instance: this.modals[localIndex]
      });

      this.doClose(localIndex);
    },


Вычисляемые свойства:

computed
computed: {
    current() { // активное окно
      return this.modals[this.$last];
    },
    $last() { // индекс активного (последнего) окна
      return this.modals.length - 1;
    },
    body() { // елемент body, если есть, для добавления/удаления класса 'modals-open'
      if (typeof document !== 'undefined') {
        return document.querySelector('body');
      }
    }
  }


Ну и последняя функция, теперь мною любимая:

render(h)
render(h) {
// если окон нет, то выводим пустоту
    if(!this.modals.length) { 
        return null
    };

// пробегаем по всем окнам
    let modals = this.modals.map((modal, index) => { // рендерим массив окон
      let modalComponent // здесь будет собранный компонент окна

      if(modal.isVmodal) { // если переданный в опциях компонент уже обернут Modal (vu-modal)
// рендерим его и передаем в него props-ы, включающие в себя vModal c параметрами для компонента vu-modal и переданными props-ами для самого компонента, если таковые имеются
        modalComponent = h(modal.options.component, {
          props: Object.assign({}, {vModal: modal.options}, modal.options.props)
       })
      } else {
// иначе рендерим компонент Modal с параметрами окна вычисленными выше и в него рендерим компонент, переданный в опциях с props-ами, опять же, если таковые были переданы
        modalComponent = h(ModalCmp, {
          props: modal.options
        }, [
          h(modal.options.component, { 
            props: modal.options.props
          })
        ])
      }
// возвращаем отрендеренную маску с окном внутри, если окно не последнее - глушим маску через css
// если dismissable окна равно true, подключаем обработчик, для закрытия 
      return h('div', {
        class: ['vu-modal__mask', {'vu-modal__mask--disabled': index != this.$last }], 
        on: {click: () => {modal.options.dismissable && this.dismiss()}}, 
        key: index
      }, [ 
        modalComponent // наше итоговое модальное окно
      ])
    })
// и конечный рендер враппера с окнами внутри
    return h('div', {
      class: 'vu-modal__wrapper',
    }, [ modals ]) 
  }
// Конец! :)


Вот такая история. Длинная история. В следующий раз буду стараться короче, если так нужно.

Ну и в итоге пример кода для открытия окна, чтобы информация усвоилась лучше.

this.$modals.open({
        title: 'Modal with callbacks',
        component: Example,
        props: {
          lorem: true,
          test: true
        },
        onDismiss() {
          console.log('Dismiss ok!')
        }
        onClose(data) {
          if(data.ended) return false
          console.log('Ok!')
        }
  })

А запускаем мы close, например по кнопке в нашем окне, передаем туда данные:

this.$modals.close({
   ended: true
})

В этом случае перед закрытием запускается наш коллбек onClose.

По аналогии работает onDismiss. Этот коллбек запускается при клике на кнопке-крестике, маске окна или прямо в нашем окне, например при клике в футере на кнопке 'Cancel':

this.$modals.dismiss()

И еще. По поводу render-функций. Они выглядят конечно не так презентабельно, как код в template. Но в них можно делать то, что в template невозможно, или возможно, но с костылями и на порядок большим количеством кода, чем получается в render-функции. И если рисуете компоненты в render-функциях, очень осторожно изменяйте в них props- и data-свойства, от которых зависит рендер, иначе рискуете уйти в бесконечный цикл обновлений (update) компонента.

Наверно пока все. И так кучу накатал. Но хотелось описать всю движуху. Следующая пара статей будет короче. Но, тоже с нюансами, о которых хочется рассказать.

И спасибо всем, кто дожил до этой строки!

P.S. Здесь примеры окон. Там же есть ссылка на Github с исходниками. Документацию дописываю, на русском языке тоже будет.
Share post

Comments 21

    0
    Однозначно в закладки. Сколько не искал, нигде нет нормальной информации о таких случаях. Везде только 1 маленький компонент и ничего более.
    Уже намучился писать костыли для модальных окон (не переживайте, это pet-проекты, никакого продакшена), а времени погрузиться во внутреннюю кухню Vue нет (хотя может и стоит найти для этого время).
    Спасибо большое за подробную статью. Продолжайте писать дальше о таких моментах.
      0
      Да, внутри Vue, не менее интересен, чем снаружи. Рекомендую его всячески ковырять и испытывать на прочность при случае. Откроете много интересных возможностей, о которых не напишут в документации. Впрочем — это применимо для любого инструмента.
      Лично для меня, изучать его — одно удовольствие. В данный момент времени — Vue — для меня идеал из себе подобных.
      Хотя я постоянно мониторю эту сферу, ведь мир JS так изменчив :)
      Писать однозначно буду. И немало.
      0
      вообще не нравится, что верстка кривая, что реализация
        0
        Понравиться всем невозможно.
        Да и нет такой цели. Цель в другом — делиться знаниями с людьми. И конкретно в этой статье речь о Vue. О случаях необычных и нестандартных, по крайне мере для новичка. А верстка здесь дело 10-е.
        Но я буду рад, если вы опишите эти недостатки в реализации. Да и по верстке не помешает. Думаю и другим будет интересно.
        –2
        Код не смотрел, смотрел только демки по ссылке.
        Окна ни разу не модальные — TAB-ом фокус вполне себе ходит по «фоновым» элементам, позволяя работать с немодальными контролами:
          +1
          Смысл статьи не в том, чтобы сделать полностью рабочую версию в виде плагина. В этой статье рассказывается, как именно это можно реализовать, а именно, один переиспользуемый динамический компонент через рендер-функции.
          Ситуация с табами очень напоминает историю про хакера и солонки в кафе.
            –2
            Ситуация с табами очень напоминает историю про хакера и солонки в кафе.
            Вы под лозунгом «всё не предусмотришь» предлагаете всё делать по принципу «и так сойдёт»?
            Есть описание, что должен представлять из себя модальный диалог:
            "...users cannot interact with content outside an active dialog window...", "...That is, Tab and Shift + Tab do not move focus outside the dialog. However, unlike most non-modal dialogs, modal dialogs do not provide means for moving keyboard focus outside the dialog window without closing the dialog...".

            Смысл статьи не в том, чтобы сделать полностью рабочую версию в виде плагина. В этой статье рассказывается, как именно это можно реализовать
            Не важно, насколько хорошо исполнено что-то, если оно плохо делает то, для чего предназначается.
              0
              Согласен с вами. И спасибо за подсказку. Уже все поправил и перезалил.
              Вот добавленное в коде
              modal-wrapper.js
              mounted() {
              // подписываемся на событие keydown
                  if (typeof document !== 'undefined') {
                    document.body.addEventListener('keydown', this.handleTabKey)
                  }
              }
               destroyed() {
              // отписываемся
                  if (typeof document !== 'undefined') {
                    document.body.removeEventListener('keydown', this.handleTabKey)
                  }
              },
              methods: {
                 handleTabKey(e) {
                     if (e.keyCode === 9 && this.modals.length) {
                       e.preventDefault() // если есть окна, глушим Tab/Shift-Tab
                     }
                  }
              // пока полностью отключаю Tab. Надо подумать, как лучше его глушить только вне активного окна. 
              }
              


              и modal.js
              mounted() {
                  if(this.$el.focus) {
                    this.$el.focus() // фокус переводим на окно, при монтировании
                  }
              },
              render(h) {
              ...
                 return h('div', { 
                    style,
                    attrs: { tabindex: -1}, // цепляем tabindex
                    class: ['vu-modal__cmp', {
              ...
              },
              



                0
                пока полностью отключаю Tab. Надо подумать, как лучше его глушить только вне активного окна.
                Это как раз понятно:
                function nodeContains(parentNode, node) {
                  return parentNode && node && (node === parentNode || contains());
                
                  function contains() {
                    return parentNode.contains ? parentNode.contains(node) : !!(parentNode.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_CONTAINED_BY);
                  }
                }
                
                if (nodeContains(activeWindow.node, document.activeElement)) {
                  // сфокусированный элемент находится в activeWindow
                } else {
                  // сфокусированный элемент находится вне activeWindow
                }
                


                Но такой подход («глушить tab») не закрывает проблему:
                • При tab-е «снаружи» страницы (с активного контрола браузера) keydown не «стреляет», и происходит фокусировка на первый фокусируемый элемент страницы (в примере это link «Vue-universal-modal», указывающий на "#/")
                • Если не «глушить» tab внутри активного окна (а по-хорошему, глушить его нельзя, иначе пользователь не сможет tab-ом перемещяться по фокусируемым элементам активного окна), то нужно как-то определять момент, когда «сфокусирован элемент внутри окна, но при tab-бировании фокус уйдёт вовне», а вот как это сделать — я уже не очень представляю :)
                  0
                  На досуге буду думать над этой проблемой. Решение всегда можно найти, если хорошо поискать :)
                    0
                    Рекомендую обратить внимание на этот комментарий. Да и вообще на статью :)
                      0

                      Я делал однопиксельные скрытые поля ввода в начале и конце модального окна, которые при фокусе перекидывали фокус обратно в окно.
                      Так же, чтобы нельзя было из адресной строки перейти табом к контролам позади модального окна, можно вставить спрятанные поля ввола в начале и конце документа.

          0
          Во-первых — реализация в целом понравилась. Но по-моему это для простых случаев, когда в модал нужно показать уже имеющиеся данные.

          Но у меня были задачи, когда нужно в модалку вывести другой компонент + чтобы была возможность дать странице с открытой модалкой свой url.
          Пример: таблица со списком пользователей, клик на имя открывает модалку с компонентом UserProfile, который в свою очередь достает инфо по id.
          Также компонент UserProfile пользуется и на отдельной странице — профиль пользователя.

          Честно, мне пришлось городить свои огороды с named router-view.
            0
            Так любой компонент мы и сейчас можем вывести в окне.
            А по поводу страницы с модалкой со своим url. Может создавать нужный роут в момент открытия окна? В реальном времени. У vue-router вроде есть возможность динамического добавления. Надо только подумать, как после закрытия окна удалять этот роут. Покопаться в исходниках роутера надо. И так каждый раз. Думаю на производительность приложения сильно этот момент не повлияет.
            А для этих динамически добавляемых/удаляемых роутов сделать шаблонную страницу, где будем выводить окно. По сути окна то у нас не привязаны к страницам сейчас. Их обертка висит в корневом шаблоне приложения. Страница-пустышка нужна для роута. Роут нужен для правильной обработки адресной строки в браузере. Как то так, на первый взгляд.
            0

            А как быть в случае, если поп-ап является частью формы?


            К примеру в нём происходит выбор адреса или режима работы. (реальный случай)
            То есть как раз такой случай, когда поп-ап должен иметь доступ к родительской модели. И при этом в разметке он там совсем плохо размещается.


            Решал такие задачи через vue-portal, но у него минусов хватает.

              0
              Для этого и есть коллбек onClose. Вызываем из формы наше окно с выбором чего-то там, передавая в опциях нужные параметры родительской модели. И в опциях передаем коллбек onClose, в который мы будем при закрытии окна методом this.$modals.close(data) передавать нужный ответ (data) и его уже использовать в родительской модели.
              Код для наглядности
              // запускаем из формы окно
              this.$modals.open({
                component: ModalForm // компонент, отображаемый в окне, где происходит выбор чего-то,
                props: {
                  params1, // родительские данные, которые будут использоваться в окне
                  params2,
                  ...
                },
                onClose(data) { // data мы будем передавать при закрытии окна 
                  // здесь мы уже в родительской области видимости, и используем data как нам нужно
                  ...
                  return false // если здесь мы вернем false, то окно не закроется. Например при каком-то условии, по значениям из data
                }
              })
              
              // а в окне, например, при клике по кнопке 'Сохранить' вызываем 
              this.$modals.close(data)
              // тем самым запуская закрытие окна, но прежде чем окно закроется, запускается коллбек onClose, если он есть в опциях окна, c переданными в него данными
              

              Как-то так, если я правильно понял суть проблемы.

                0
                Спасибо за пример.
                Это рабочий вариант, но он несёт ряд усложнений.

                Нам нужно сделать отдельный компонент, описать в нём логику передачи нужных данных в коллбэке, определить метод в родительском компоненте, который эти данные примет и использует…

                Согласитесь, это всё значительно сложнее, чем свойство showModal и пара текстовых инпутов внутри модалки с привязкой v-model.

                По большому счёту, все эти пляски с шинами событий, коллбэками, порталами и прочим — просто из-за проблем с вёрсткой. Ну не хочет глубоко вложенный элемент с positon: fixed вырваться от своих родителей и стать сильным и независимым. Добавим transform к родителю и всё, приплыли.

                Я использовал оба подхода.
                Получалось и примерно ваше решение, и магия с github.com/LinusBorg/portal-vue

                Не то, чтобы теорема Эскобара, но всё равно не идеально, везде какой противный компромисс.
                В целом, считаю что решение которое предлагаете вы — менее Vue-way (в смысле «вау пара свойств и магия»), но, пожалуй, более адекватно и надежно.
                  0
                  Ну правильно, для каждой ситуации — свое решение.
                  Но главное преимущества подхода, который используется здесь — позволяет убрать дублирование кода. Вот нужно вам одно и тоже окно на нескольких страницах, вы будете его вставлять на каждую, и само окно станет привязано к данным страницам. Более того, если нужно будет сделать изменения в этом окне, нужно будет лазить по всем местам, где оно используется и делать изменения. А так мы скинули все наши компоненты с окнами в одно место, например в папку modals и все. Одно окно с дефолтными настройками — используем где хотим, и когда надо, через опции заменяем его дефолтные настройки.
                  Мне например так намного удобнее. И добавить родительский метод, который примет данные, для меня более очевиден. Так как компонент в окне по сути — это отдельная сущность, и изменять данные родителя напрямую, не есть хорошо.
              0
              через npm i vue-universal-modal поставить не получилось. Как вообще включить это в проект? Только руками?

              Only users with full accounts can post comments. Log in, please.