Привет всем!
Много уже их создано для Vue. Пользовался всякими. И видимо, когда достигаешь какого-то определенного уровня владения инструментом (в данном случае Vue), сразу хочется сделать велосипед, но конечно со своими прибамбасами, типа, чтобы круче всех и т.д. И я не стал исключением из правил.
Из всех доступных модальных компонентов, использовал в основном этот — Vuedals.
Но решил я его проапгрейдить. В принципе от основы остался только EventBus и взаимодействие событий связанных с открытием-закрытием окон. Основной компонент переписан и стал оберткой-контейнером и добавлен новый компонент — само модальное окно.
Но обо всем по порядку. И статья получится очень немаленькая, кто осилит, тот красавчик :)
В основном модальные окна во всех примерах вызываются в таком стиле:
Вроде все красиво. Но!
Какие вижу недостатки такого подхода.
Во-первых, темплейт модального окна находится внутри родительского компонента, где мы его вызываем. И контекст окна не изолирован от родителя. Мне так не всегда удобно и нужно.
Во-вторых, если одно и то-же окно используется в нескольких местах, приходится дублировать код. Что не есть гуд!
В-третьих, и что наверно является самым главным недостатком — мы можем использовать модальное окно только внутри страниц или других компонентов Vue, а вот в местах типа Vuex, Router, да и вообще в любых скриптах не можем. Мне например, надо вызвать модальное окно входа/регистрации из роутера или из стора при каком-то событии. Примеров можно привести мильён.
Поэтому подход используемый в Vuedals, когда мы открываем/закрываем окна путем вызова функции с параметрами и передавая «сырой» компонент, вида —
или полноценный, который мы импортнули извне, мне оказался больше по душе.
Больше контроля, возможностей переиспользования и вызвать такое окно можно практически отовсюду.
Выглядит в общем это так, у нас есть компонент ModalWrapper, который мы импортируем в приложение и вставляем например в корневой App-компонент. Где нибудь внизу.
Потом в любом месте вызываем метод this.$modals.open({ component: SimpleModal, title: 'Simple modal'}), куда передаем настройки нашего окна и компонент, который будем показывать и видим наше модальное окошко, которое рендерится в ModalWrapper.
Есть куча событий, возникающие при всех манипуляциях с окнами, события эти управляются при помощи EventBus-а, и их можно прослушивать и как-то реагировать.
Это вводные данные, чтобы информация легче осваивалась. В принципе, статья больше для новичков в Vue. Но надеюсь, будет пара моментов интересных и для искушенных.
Буду кидать куски кода и попутно комментировать, что к чему. Ссылка на примеры и исходники в конце есть.
Ну и пожалуй начнем с главного файла —
В нем мы импортируем компоненты, используемые для наших модальных-премодальных окон:
Сразу приведу код Bus.js и опишу что там происходит. Как говорил ранее, EventBus оставил так, как есть в оригинале.
Здесь мы создаем singleton-экземпляр EventBus-а, который может подписываться на события ($on) и вызывать ($emit) события. Думаю здесь объяснять особо нечего. EventBus собирает коллбеки и когда надо их вызывает. Дальше по ходу дела будет видно и понятно, как он связывает все наши компоненты.
А теперь по index.js
Здесь мы экспортируем по умолчанию дефолтную функцию install — для подключения наших окон к приложению (используя Vue.use()) и компоненты ModalWrapper, Modal и Bus. Ну и при подключении VueUniversalModal через script-тег в браузере, активируем наш компонент, если подключен глобальный VueJs на странице.
И по порядку:
Здесь мы цепляем к глобальному 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-миксин всем компонентам Vue метод created, в котором при запуске включаем прослушку компонентами событий modals:new, modals:close и modals:dismiss и при их вызове, через EventBus опять же запускаем соответствующие события в ModalWrapper.
Все эти адовы вызовы здесь нужны для управления нашими модальными окнами. И дают нам 4 варианта запуска событий open, close и dismiss.
Первый способ вызова нашего модального окна в приложении:
Второй способ:
Третий:
И четвертый (для этого способа нам нужно импортировать Bus.js, но это дает нам возможность вызвать окно не из компонента Vue, а из любого скрипта):
Ну и close, dismiss по аналогии.
Тут как говорится — «на вкус и цвет».
Дальше пойдет код повеселее. С недавнего времени в своих компонентах использую render-функции (как стандартные так и в jsx-формате). Использовать начал повсеместно тогда, когда понял, что они дают больше возможностей для рендеринга. С render-функциями вся мощь Javascript-а плюс крутая внутренняя VueJs кухня с vNode дают ощутимые бонусы. В момент их появления я на них как-то косо глянул, подумал, нафиг надо и продолжил рисовать компоненты в template-шаблонах. Но теперь я знаю, где собака зарыта.
Modal — это полноценный компонент, который рендерит само модальное окно. У него куча входящих параметров:
В коде прокомментировал все параметры, чтобы было нагляднее. А код в спойлер кидаю, чтобы не получилась портянка. Итак текста немало.
Дальше:
Вначале импортируем иконку-крестик в виде функционального компонента, который рендерит ее в SVG-формате.
Зачем я ее так сообразил, даже не знаю.
Дальше параметр name — ну это стандартный ход.
А вот componentName здесь неспроста. Он нам нужен будет дальше, в ModalWrapper-е при рендеринге.
И вычисляемый параметр propsData:
Здесь уже сложнее.
А дело вот в чем. В оригинальном Vuedals все окна тоже вызываются с помощью тех 4-х способов, описанных выше. В каждом из них мы должны передавать компонент, который хотим показать в окне и параметры окна (все они сейчас есть во входящих параметрах Modal и плюс добавлены несколько новых). И если мы хотим запустить одно и то же окно в разных частях приложения, мы каждый раз передаем параметры окна (размеры, другие настройки). Что опять является дублированием. Да и запутаться недолго. А мы, программисты, существа крайне ленивые в основе своей. Поэтому и был создан этот компонент Modal.
Теперь мы можем создать компонент модального окна, например, вот так:
То есть, стандартный компонент. Обернутый нашим Modal (vu-modal). Этому vu-modal мы передаем нужные нам параметры. Они и будут значениями по умолчанию для этого окна.
И теперь мы вызываем это окно так:
Все нужные нам значения дефолтных настроек окна берутся автоматом из того самого компонента SimpleModal, снятых с обертки vu-modal. Мы один раз создали компонент окна с нужными нам настройками и потом используем его в любом месте не парясь о настройках. Более того, если нам надо переназначить те значения по умолчанию, мы указываем нужные нам значения при вызове этого окна:
Теперь параметр center заменит дефолтный параметр указанный в шаблоне окна — SimpleModal.
То есть приоритет такой при мерджинге (слиянии) параметров:
Чем ниже, тем главнее.
Так вот вычисляемое свойство 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. Сюда попадают все без исключения параметры, которые мы передали. А потом уже фильтруются и лишние отбрасываются.
Приведу еще раз этот кусок кода, он маленький, чтобы с мысли не сбивать.
Сейчас возможно не совсем все понятно. Информации много, и не совсем все стандартное, как во многих уроках. Пишу такого рода статью в первый раз, не совсем пока ясно, как лучше преподать. Во внутренностях Vue копаются наверняка многие, но мало кто пишет об этом. Сам долгое время искал инфу по таким моментам. Что-то находил, остальное ковырял сам. И хочется рассказывать о таких вещах.
Но более понятно станет, когда будем разбирать ModalWrapper. Там мы будем формировать и отправлять смердженные props-ы нашим окнам.
Ну и осталась render-функция нашего компонента Modal (vu-modal):
Здесь вроде ничего необычного.
Сначала вытаскиваем все наши параметры из ранее описанного вычисляемого значения 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 на маске и наше окно закроется.
Уффф!
Подключаем наши стили:
В массиве modals мы будем хранить наши окна, которые активны в данный момент.
Ну и при монтировании и удалении нашего ModalWrapper-компонента вешаем обработчик keyup на window (если window есть), который запускает метод handleEscapeKey:
Который в свою очередь, если нажата Esc-клавиша и есть окно(а) и у текущего (запущенного последним) окна свойство escapable равно true, запускает метод dismiss, который закрывает это самое текущее окно.
Ну и пожалуй самое интересное началось. Попробую описывать происходящее прямо в коде, может так лучше будет.
При создании нашего ModalWrapper включаем прослушку событий от EventBus-а. Тех самых, которые запускаются в методах $modals, описанных раннее:
Далее события:
Теперь методы:
В методе dismiss, все аналогично методу close:
Вычисляемые свойства:
Ну и последняя функция, теперь мною любимая:
Вот такая история. Длинная история. В следующий раз буду стараться короче, если так нужно.
Ну и в итоге пример кода для открытия окна, чтобы информация усвоилась лучше.
А запускаем мы close, например по кнопке в нашем окне, передаем туда данные:
В этом случае перед закрытием запускается наш коллбек onClose.
По аналогии работает onDismiss. Этот коллбек запускается при клике на кнопке-крестике, маске окна или прямо в нашем окне, например при клике в футере на кнопке 'Cancel':
И еще. По поводу render-функций. Они выглядят конечно не так презентабельно, как код в template. Но в них можно делать то, что в template невозможно, или возможно, но с костылями и на порядок большим количеством кода, чем получается в render-функции. И если рисуете компоненты в render-функциях, очень осторожно изменяйте в них props- и data-свойства, от которых зависит рендер, иначе рискуете уйти в бесконечный цикл обновлений (update) компонента.
Наверно пока все. И так кучу накатал. Но хотелось описать всю движуху. Следующая пара статей будет короче. Но, тоже с нюансами, о которых хочется рассказать.
И спасибо всем, кто дожил до этой строки!
P.S. Здесь примеры окон. Там же есть ссылка на Github с исходниками. Документацию дописываю, на русском языке тоже будет.
Моя первая публикация прошла с неприятным осадком. Я обещал исправить это недоразумение и на ваш суд представляю свою первую статью-урок по 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
}
В нем мы импортируем компоненты, используемые для наших модальных-премодальных окон:
- ModalWrapper.js — общая обертка для вывода наших окон
- Modal.js — собственно сам компонент модального окна. Его нет в оригинальном Vuedals. Использовать напрямую его не обязательно. Он в любом случае работает под капотом. Дальше по ходу пьесы увидите этот финт ушами и станет понятно для чего я его добавил.
- 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)
}
},
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 с исходниками. Документацию дописываю, на русском языке тоже будет.