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

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

Для тех, кто интересуется только финальным открытым исходным кодом, он, как обычно, находится в конце статьи!

При создании SPA с Vue.js мой подход в основном заключался в создании <Modal /> компонента-оболочки (с slot для содержимого), который содержал внутреннее логическое состояние открытия и имел методы такие как open() и close(), которые переключают его состояние. Я получил доступ к этим методам через $ref в компоненте, который отображал модальное окно. Пример может выглядеть примерно так:

methods: {
  openModal () {
    this.$refs.modal.show()
  }
}

Мне понравился этот подход в том смысле, что каждый компонент имел свой собственный модальный файл и отвечал за него. Благодаря этому приложение не было монолитным. Мне не очень нравится сохранять модальное состояние внутри компонента, который отображает модальный (и передает его как опору), потому что код часто становится слишком повторяющимся и надежным, если вам нужно изменить модальное состояние в отображаемых компонентах. . Вот почему я выбрал подход, при котором модальное окно заботится о своем собственном открытом состоянии и предоставляет методы переключения через $ ref.

Обратной стороной этого было то, что по мере роста моего приложения было просто слишком много <Modal /> компонентов. Они также находились внутри компонента, который отображал их в формате HTML, поэтому иногда возникали проблемы с наложением, которые z-index просто не мог решить. Мне было интересно, есть ли способ лучше.

Один модальный корень, чтобы управлять ими всеми

Я уже упоминал о проблеме z-индекса. Когда вам нужно отобразить что-то поверх всего остального с точки зрения HTML, имеет смысл только то, что ваше дерево должно быть структурировано соответствующим образом, вместо того, чтобы использовать хаки, чтобы заставить ваши элементы отображаться вверху вашего сайта, когда на самом деле ... они нет.

Итак, мы стремимся к чему-то вроде этого

<!doctype html>
<html>
<body>
  <div id="app">
    <div class="content">Lorem Ipsum..</div>
    <div id="modal-root"></div>
  </div>
</body>
</html>

Где #modal-root всегда будет внизу (но сверху, визуально 😅) вашей структуры и всегда будет отображать ваш текущий выбранный элемент.

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

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

1. Создание EventBus

Начнем с создания простого ModalBus внутри нашего файла eventBus.js. Я выбрал именованный экспорт, потому что я стремлюсь работать с большим количеством автобусов для мероприятий, но на самом деле это личное предпочтение. Если вы чувствуете себя комфортно при использовании экспорта по умолчанию, не стесняйтесь делать это.

2. Создание модального (презентационного) компонента

Как видите, это просто презентационный компонент. Он получает свойства isOpen и title от родителя, которого у нас еще нет.

Здесь мы занимаемся несколькими вещами:

  • Вызов onClose при щелчке по фону за пределами диалога
  • Отображение всей условной логики модального компонента
  • Отображение условной логики заголовка
  • Использование <slot /> для отображения дочерних элементов внутри модального окна
  • Определение некоторых основных стилей

3. Создание компонента ModalRoot.

Компонент ModalRoot - это тот, который прослушивает ModalBus события и обрабатывает всю логику, и тот, который отображает <Modal />. Это также тот объект, который мы хотим разместить внутри нашего <App /> в нижней части нашей HTML-структуры.

Давайте развенчаем то, что происходит под капотом:

  • Скрипт:

В нашем состоянии (данных) хранятся 3 вещи:

data () {
  return {
    component: null,
    title: '',
    props: null
  }
},

Эти свойства будут получены через ModalBus, и они несут информацию о компоненте, который мы собираемся отображать, заголовке диалогового окна и любых необходимых реквизитах для дочернего компонента внутри <Modal />.

created () {
  ModalBus.$on('open', ({ component, title = '', props = null }) => {
    this.component = component
    this.title = title
    this.props = props
  })
  document.addEventListener('keyup', this.handleKeyup)
},

Сразу после создания <ModalRoot /> мы подключаемся к ModalBus и слушаем событие open, которое мы вызываем, когда хотим открыть Modal из любого компонента, передавая наши значения. Вы можете видеть, что мы принимаем и устанавливаем те параметры, о которых я говорил выше - компонент, заголовок и свойства. Мы также добавляем слушателя для клавиши (Escape).

beforeDestroy () {
  document.removeEventListener('keyup', this.handleKeyup)
},

Мы должны быть осторожны и уничтожить слушателя до того, как будет уничтожен сам <ModalRoot />.

methods: {
  handleClose () {
    this.component = null
  },
  handleKeyup (e) {
    if (e.keyCode === 27) this.handleClose()
  }
},

Затем есть наш простой метод handleClose(), который только устанавливает компонент в null (который меняет нашу isOpen опору для компонента <Modal />, подробнее в разделе шаблона) и метод handleKeyup(), который проверяет, нажимаем ли мы Escape, а затем вызов handleClose(), если условие будет выполнено.

(Обратите внимание, что вы также можете прослушивать событие close так же, как вы слушаете open и вызываете handleClose () внутри, поэтому модальное окно также можно закрыть из любого места)

  • Шаблон:
  • Мы используем наш <Modal /> презентационный компонент, чтобы обернуть динамический <component />
  • Мы устанавливаем для свойств isOpen и title <Modal /> на основе наших входящих данных (isOpen имеет значение true, если компонент не пуст)
  • Мы также передаем @onClose слушателя, который реагирует на <Modal /> фоновый щелчок, когда $emit('onClose') происходит в <Modal />
<component :is="component" @onClose="handleClose" v-bind="props" />
  • Мы показываем <component /> внутри <Modal />, поэтому используем <slot /> функциональность внутри него
  • Мы также слушаем излучение @onClose (так же, как в <Modal />)
  • Мы привязываем все props, которые мы установили в вызове open() ModalBus, к <component />, чтобы они находили свой путь к предназначенному компоненту.

4. Использование ‹ModalRoot /›

Теперь все должно быть подключено - нам просто нужно этим воспользоваться! Я создал несколько примеров использования Tailwind, чтобы показать, как на самом деле можно использовать <ModalRoot />.

  1. Уведомление об успешном завершении

Первый случай очень простой. Мы вызываем общий <Alert /> компонент и передаем некоторую базовую конфигурацию:

openSuccessAlert () {
  ModalBus.$emit('open', {
    component: Alert,
    props: { text: 'Everything is working great!', type: 'success' }
  })
},

Компонент <Alert /> ожидает text и type. Мы не передаем заголовок нашему диалогу, поэтому он не будет отображаться.

2. Предупреждение об опасности

openDangerAlert () {
  const props = {
    type: 'error',
    text: 'The server returned 500 again! omg!'
  }
  ModalBus.$emit('open', { component: Alert, title: 'An error has occured', props: props })
},

Мы снова используем компонент <Alert />, на этот раз передавая тип "error", а также предоставляя title для диалогового окна.

3. Компонент, который закрывается изнутри

openClosableInside () {
  ModalBus.$emit('open', { component: ClosableInside, title: 'Close dialog from component' })
},

Мы не передаем ничего особенного в вызов open(), вместо этого волшебство происходит внутри компонента <ClosableInside /> example:

<Button @click="$emit('onClose')" color="gray">Close</Button>

Поскольку <ModalRoot /> отображает component, в нашем случае компонент <ClosableInside />, и он прослушивает событие @onClose, мы можем $ испустить его внутри компонента, и модальное окно закроется. «Реагировать» можно было бы передать этот обработчик close через props, что, конечно, тоже возможно.

4. Форма входа.

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

openSignIn () {
  ModalBus.$emit('open', { component: SignInForm, title: 'New user' })
}

Самое прекрасное в этом то, что вы можете расширять его как хотите. Допустим, мы хотим добавить модальную анимацию!

Мы просто добавили <transition name=”fade”> и несколько правил CSS в раздел <style />:

.fade-enter-active, .fade-leave-active {
  transition: 0.5s;
}

.fade-enter, .fade-leave-to {
  opacity: 0;
}

.fade-enter .modal-dialog, .fade-leave-to .modal-dialog {
  transform: translateY(-20%);
}

Довольно распространенный случай, когда вы хотите сохранить модальное (открытое) состояние при нажатии на фон, например, когда вы заполняете форму и хотите защитить пользователя от случайного закрытия окна.

  • <ModalRoot /> сейчас хранит другое свойство в data() - closeOnClick: true. Мы изменили функцию ModalBus.$on(‘open’, ...), чтобы принять новый параметр.
ModalBus.$on('open', ({ component, title = '', props = null, closeOnClick = true }) => {
  this.component = component
  this.title = title
  this.props = props
  this.closeOnClick = closeOnClick
})

Давайте создадим другой обработчик для события @onClose <Modal />, которое запускается, когда пользователь щелкает фон <Modal />.

@onClose="handleOutsideClick"

Теперь тело функции определяет, вызывать handleClose() или нет, на основе свойства closeOnClick.

handleOutsideClick () {
  if (!this.closeOnClick) return
  this.handleClose()
},

Вот и все! Давайте попробуем это с компонентом <SignInForm />, чтобы проверить, работает ли он, передав параметр closeOnClick (теперь вы можете закрыть его, используя только клавишу Escape):

openSignIn () {
  ModalBus.$emit('open', { component: SignInForm, title: 'New user', closeOnClick: false })
}

Вы можете найти полный рабочий репозиторий по адресу https://github.com/DJanoskova/Vue.js-Modal-context

Демо-приложение с улучшениями доступно по адресу https://vue-modal-context.netlify.com/.