Создание модальных диалогов в React
В наши дни трудно представить функциональное веб приложение без модальных диалогов. В контексте пользовательского интерфейса, модальный диалог (или модальное окно) блокирует взаимодействие пользователя с другими элементами интерфейса, пока не будет завершен диалог. На практике, существует всего два способа реализации модальных диалогов:
- Вложенный компонент, который остается дочерним в процессе рендеринга.
Пример кода на React:
<Parent>
<Modal />
</Parent>
Результат рендеринга:
<style>
.modal-component {
position: fixed;
z-index: 1000;
}
</style>
<body>
<div id="root">
<div className="parent-component">
<div className="modal-component">...</div>
</div>
</div>
</body>
- Вложенный компонент с порталом (React Portal), который использует отдельный HTML-тег для рендеринга за пределами родительского
root
.
Пример кода на React:
<Parent>
<ModalWithPortal />
</Parent>
Результат рендеринга:
<style>
.modal-component-with-portal {
position: fixed;
z-index: 1000;
}
</style>
<body>
<div id="root">
<div className="parent-component">...</div>
</div>
<div id="modal-root">
<div className="modal-component-with-portal">...</div>
</div>
</body>
Давайте опробуем оба подхода на практике и определим, в каких случаях каждый из них наиболее эффективен.
Для начала, развернем и запустим тестовое приложение используя create-react-app
:
yarn create react-app react-modal-with-portal
cd react-modal-with-portal
yarn start
Создадим компонент модального диалога в который можно добавлять любой контент через children
:
// src/components/Modal/Modal.js
import { useEffect } from "react";
import "./styles.css";
export function Modal({ children, isOpen, handleClose }) {
if (!isOpen) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal-dialog">
<div className="modal-content">{children}</div>
<div className="modal-actions">
<button onClick={handleClose}>Закрыть</button>
</div>
</div>
</div>
);
}
Создадим фаил стилей для нашего диалога:
/* src/components/Modal/styles.css */
.modal-overlay {
position: fixed;
z-index: 1000;
inset: 0;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.modal-dialog {
position: relative;
padding: 20px;
background-color: white;
color: black;
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-actions {
align-self: flex-end;
}
Разместим компонент модального диалога в App.js
следующим образом:
// src/App.js
import { useState } from "react";
import { Modal } from "./components/Modal/Modal";
import logo from "./logo.svg";
import "./App.css";
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<button onClick={() => setIsOpen(true)}>Открыть Модалку</button>
<Modal handleClose={() => setIsOpen(false)} isOpen={isOpen}>
Превед!
</Modal>
</header>
</div>
);
}
export default App;
Посмотрим как изменилось наше приложение:
Если взглянуть на HTML код страницы, мы увидим, что модальный диалог рендерится внутри родительского компонента:
Чтобы отловить нажатие клавиши Escape
, достаточно добавить обработчик события keydown
и проверить параметр event.key
на соответствие строке Escape
. Воспользуемся хуком useEffect
чтобы внедрить эту логику в компонент модального диалога:
// src/components/Modal/Modal.js
import { useEffect } from "react";
import "./styles.css";
export function Modal({ children, isOpen, handleClose }) {
useEffect(() => {
const closeOnEscapeKey = (e) => (e.key === "Escape" ? handleClose() : null);
document.body.addEventListener("keydown", closeOnEscapeKey);
return () => {
document.body.removeEventListener("keydown", closeOnEscapeKey);
};
}, [handleClose]);
if (!isOpen) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal-dialog">
<div className="modal-content">{children}</div>
<div className="modal-actions">
<button onClick={handleClose}>Закрыть</button>
</div>
</div>
</div>
);
}
В случае, когда страница содержит много контента и появляется скролл, модальный диалог будет открыт поверх страницы с возможностью использовать этот скролл на элементах позади модального диалога, что не совсем корректно. На практике, чаще всего используется специальный хук, который модифицирует стиль тега body
, чтобы спрятать скролл во время открытия модального диалога и затем вернуть его в исходное значение, когда модальный диалог будет закрыт.
Давайте создадим новый хук useDisableBodyScroll
и внедрим его в код компонента модального окна:
// src/components/useDisableBodyScroll.js
import { useEffect } from "react";
export const useDisableBodyScroll = (open) => {
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
}, [open]);
};
// src/components/Modal/Modal.js
import { useEffect } from "react";
import { useDisableBodyScroll } from "../useDisableBodyScroll";
import "./styles.css";
export function Modal({ children, isOpen, handleClose }) {
useDisableBodyScroll(isOpen);
useEffect(() => {
const closeOnEscapeKey = (e) => (e.key === "Escape" ? handleClose() : null);
document.body.addEventListener("keydown", closeOnEscapeKey);
return () => {
document.body.removeEventListener("keydown", closeOnEscapeKey);
};
}, [handleClose]);
if (!isOpen) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal-dialog">
<div className="modal-content">{children}</div>
<div className="modal-actions">
<button onClick={handleClose}>Закрыть</button>
</div>
</div>
</div>
);
}
Вероятно, вы также захотите добавить скролл в модальный диалог который автоматически будет появляться тогда, когда высота модального диалога больше чем высота экрана пользователя. Для этого включим всего один новый стиль в соответствующий фаил:
/* src/components/Modal/styles.css */
.modal-dialog {
...
overflow: auto;
}
Результат:
Рассмотрим способ добавления анимаций появления и исчезновения для модального диалога с помощью CSS стилей. В данном способе нам понадобится убрать проверку !isOpen
из компонента модального окна, что позволит не удалять его из структуры DOM в то время, когда диалог скрыт.
// src/components/Modal/Modal.js
...
if (!isOpen) {
return null; // предотвращает рендеринг компонента
}
...
Изменим компонент модального диалога следующим образом:
// src/components/Modal/Modal.js
import { useEffect } from "react";
import { useDisableBodyScroll } from "../useDisableBodyScroll";
import "./styles.css";
export function Modal({ children, isOpen, handleClose }) {
useDisableBodyScroll(isOpen);
useEffect(() => {
const closeOnEscapeKey = (e) => (e.key === "Escape" ? handleClose() : null);
document.body.addEventListener("keydown", closeOnEscapeKey);
return () => {
document.body.removeEventListener("keydown", closeOnEscapeKey);
};
}, [handleClose]);
return (
<div className={`modal-overlay${isOpen ? " open" : ""}`}>
<div className="modal-dialog">
<div className="modal-content">{children}</div>
<div className="modal-actions">
<button onClick={handleClose}>Закрыть</button>
</div>
</div>
</div>
);
}
Затем изменим фаил стилей компонента:
/* src/components/Modal/styles.css */
.modal-overlay {
position: fixed;
z-index: 1000;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.4s, visibility 0.4s;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
pointer-events: initial;
}
.modal-dialog {
position: relative;
padding: 20px;
background-color: white;
color: black;
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-actions {
align-self: flex-end;
}
Опробуем наши изменения в приложении и обратим внимание на то, что теперь после закрытия диалога его HTML узел остается в DOM дереве, а сам диалог скрыт при помощи CSS стилей.
Рассмотрим еще один способ добавления анимаций, в данном случае с отсутствием рендеринга модального диалога в DOM-дереве. Нам понадобится вернуть проверку isOpen
, а также добавить несколько новых стилей. Чтобы анимация исчезновения успела отработать, необходимо добавить задержку перед тем, как диалог будет закрыт.
Отредактируем фаил стилей компонента:
/* src/components/Modal/styles.css */
.modal-overlay {
position: fixed;
z-index: 1000;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: fadeIn 0.4s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-overlay.closing {
transition: opacity 0.4s;
opacity: 0;
pointer-events: none;
}
.modal-dialog {
position: relative;
padding: 20px;
background-color: white;
color: black;
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-actions {
align-self: flex-end;
}
Затем изменим компонент модального диалога следующим образом:
// src/components/Modal/Modal.js
import { useEffect, useState, useCallback } from "react";
import { useDisableBodyScroll } from "../useDisableBodyScroll";
import "./styles.css";
export function Modal({ children, isOpen, handleClose }) {
const [closing, setClosing] = useState(false);
useDisableBodyScroll(isOpen);
const closeWithAnimation = useCallback(() => {
if (closing) {
return;
}
setClosing(true);
setTimeout(() => {
handleClose();
setClosing(false);
}, 400); // задержка, указанная в фаиле стилей - 0.4s
}, [handleClose, closing]);
useEffect(() => {
const closeOnEscapeKey = (e) =>
e.key === "Escape" ? closeWithAnimation() : null;
document.body.addEventListener("keydown", closeOnEscapeKey);
return () => {
document.body.removeEventListener("keydown", closeOnEscapeKey);
};
}, [closeWithAnimation]);
if (!isOpen) {
return null;
}
return (
<div className={`modal-overlay${closing ? " closing" : ""}`}>
<div className="modal-dialog">
<div className="modal-content">{children}</div>
<div className="modal-actions">
<button onClick={closeWithAnimation}>Закрыть</button>
</div>
</div>
</div>
);
}
В качестве альтернативного решения добавления анимаций переходов для модального диалога, вы можете использовать библиотеки вроде react-transition-group.
В React существует альтернативный способ создания модальных диалогов используя порталы.
В React, порталы (Portals) представляют собой механизм для рендеринга дочерних компонентов в DOM-узлы, которые находятся вне иерархии DOM-дерева родительского компонента. Это позволяет помещать компоненты в другие части страницы вне контейнера, к которому они принадлежат.
Создадим специальный компонент Portal
, который будет оборачивать модальный диалог в портал:
// src/components/Portal.js
import { useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
function createWrapperAndAppendToBody(wrapperId) {
const wrapperElement = document.createElement("div");
wrapperElement.setAttribute("id", wrapperId);
document.body.appendChild(wrapperElement);
return wrapperElement;
}
export function Portal({ children, wrapperId = "modal-root" }) {
const [wrapperElement, setWrapperElement] = useState(null);
// Этот эффект ищет тег-обертку с указанным ID в документе, и если находит то использует её, иначе добавляет новый тег к body
useLayoutEffect(() => {
let element = document.getElementById(wrapperId);
let createdByEffect = false;
if (!element) {
createdByEffect = true;
element = createWrapperAndAppendToBody(wrapperId);
}
setWrapperElement(element);
return () => {
// Как только модальное окно закроется, добавленный ранее тег будет удален из DOM
if (createdByEffect && element.parentNode) {
element.parentNode.removeChild(element);
}
};
}, [wrapperId]);
if (wrapperElement === null) {
return null;
}
return createPortal(children, wrapperElement);
}
Затем обернем содержимое модального диалога в компонент Portal
:
// src/components/Modal/Modal.js
import { useEffect } from "react";
import { useDisableBodyScroll } from "../useDisableBodyScroll";
import { Portal } from "../Portal";
import "./styles.css";
export function Modal({ children, isOpen, handleClose }) {
useDisableBodyScroll(isOpen);
useEffect(() => {
const closeOnEscapeKey = (e) => (e.key === "Escape" ? handleClose() : null);
document.body.addEventListener("keydown", closeOnEscapeKey);
return () => {
document.body.removeEventListener("keydown", closeOnEscapeKey);
};
}, [handleClose]);
return (
<Portal>
<div className={`modal-overlay${isOpen ? " open" : ""}`}>
<div className="modal-dialog">
<div className="modal-content">{children}</div>
<div className="modal-actions">
<button onClick={handleClose}>Закрыть</button>
</div>
</div>
</div>
</Portal>
);
}
Откроем модальный диалог в приложении и посмотрим на HTML код страницы:
Как видим, теперь модальный диалог рендерится в отдельном теге <div id="modal-root">
.
Порталы (React Portals) предоставляют удобный и мощный инструмент для управления отображением компонентов внутри иерархии React. Вот несколько случаев, когда использование порталов может быть полезным:
-
Модальные окна и диалоги: Порталы идеально подходят для реализации модальных окон и диалогов, поскольку они позволяют размещать такие компоненты на верхнем уровне DOM-дерева, независимо от местоположения в иерархии React-компонентов.
-
Всплывающие окна и подсказки: Порталы позволяют создавать всплывающие окна и подсказки (tooltip), которые должны появляться в определенном месте на странице независимо от позиции родительского элемента.
-
Перетаскивание (drag and drop): Если требуется реализовать функциональность перетаскивания элементов между различными областями на странице, порталы помогают создать перемещаемый элемент на верхнем уровне документа.
-
Меню и выпадающие списки: Использование порталов для меню и выпадающих списков позволяет обеспечить корректное отображение компонентов на верхнем уровне, что упрощает управление и стилизацию.
-
Обработка событий на верхнем уровне: Если компонентам требуется обработка событий на верхнем уровне DOM, например, чтобы перехватывать клики или события клавиатуры независимо от местоположения в иерархии компонентов, порталы могут упростить реализацию.
-
Загрузочные экраны и индикаторы загрузки: При загрузке длительных операций можно использовать порталы для создания загрузочного экрана, который затеняет всю страницу и предотвращает действия пользователя.
Помните, что применение одновременно нескольких порталов на одной странице требует дополнительной обработки порядка их отображения. Например, выпадающее меню, которое находится внутри модального диалога, должно быть отображено поверх основного диалога.
Похожие публикации