Делаем кастомное модальное окно для React
- суббота, 20 мая 2023 г. в 00:00:15
Хочешь меньше слов, больше кода ? Тогда можно сразу посмотреть демку codesandbox.custom-modal.
А пояснительная бригада к демке ждёт вас дальше по тексту)
Поехали!
Проектируем решение
Пишем портал + тесты на портал
Пишем модалку + тесты на модалку
Запускаем всё в контейнере
Профит
Делать будем модальное окно. Не подсказку, не дропдаун, не pop-up инфо всплывашку, а именно модалку. Это важно, так как основная суть модального окна, это (как правило) приостановить текущий флоу взаимодействия пользователя со страницей и переключить на другой поток действий, а после завершения/отмены которого нужно вернуть пользователя в изначальный поток.
Про смысловые отличия popup/modals/lightboxes/tooltip/notice детали несложно гуглятся. Для удобства оставлю глянуть эту и эту ссылки.
Из этого основного свойства/отличия, появляется важное ограничение - модальное окно должно быть на странице в единственном экземпляре.
Поскольку мы тут пишем про React, то очевидно будем использовать встроенные фишки, порталы.
И кажется, что эти самые порталы, будут лежать в основе любых потенциальных окон, которые мы можем захотеть реализовать в процессе нашей работы в будущем. Поэтому в реализацию напрашивается 2 компонента:
портал в качестве овновы
модалку, в качестве надстройки над порталом
В итоге на текущий момент понятно, что порталов на странице может быть много, а модалка одна.
Модалка будет построена поверх портала, привнося в его работу с одной стороны ограничение (модалка должна быть только 1 на странице в один момент времени), а с другой стороны расширение (способы закрытия).
Начнём с создания основы, с компонента портала.
Задача портала будет простой - отрендерить своё содержимое (children) в контейнере с определённым id.
Для этого, как и обсуждали, будем использовать функцию createPortal.
Так же будем в явном виде выкидывать ошибку, в случае если у нас нет в разметке контейнера, в котором мы пытаемся создать наш портал.
Явное всегда лучше не явного, поэтому лучше мы в явном виде уроним наше компонент при попытке некорректного рендеринга.
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
type PortalProps = { id: string; children: React.ReactNode; };
const PORTAL_ERROR_MSG ='There is no portal container in markup. Please add portal container with proper id attribute.';
const Portal = (props: PortalProps) => {
const { id, children } = props;
const [container, setContainer] = useState<HTMLElement>();
useEffect(() => {
if (id) {
const portalContainer = document.getElementById(id);
if (!portalContainer) {
throw new Error(PORTAL_ERROR_MSG);
}
setContainer(portalContainer);
}
}, [id]);
return container ? createPortal(children, container) : null;
};
Минимум кода, максимум понятности. Теперь апгрейдим.
Портал этот мы сможем применять во множестве кейсов. В модалке, в всплывашках различных, в подсказках или выпадающих списках.
И для того, чтобы каждый раз руками не создавать и не монтировать контейнер для портала, напишем небольшую функцию, которая облегчит процесс создания контейнера для портала.
Её задача будет создать div с нужным id, и зарендерить его в переданной moundNode. Но если контейнер уже существует, то ничего не делать (зачем повторно создавать и дёргать лишний раз dom дерево). Ну и по умолчанию moundNode будет равняться document.body:
type containerOptions = { id: string; mountNode?: HTMLElement };
const createContainer = (options : containerOptions) => {
if (document.getElementById(options.id)) {
return;
}
const { id, mountNode = document.body } = options;
const portalContainer = document.createElement('div');
portalContainer.setAttribute('id', id);
mountNode.appendChild(portalContainer);
};
И в конце не забываем всё наше творчество экспортировать из файла портала для внешних потребителей:
export { createContainer, PORTAL_ERROR_MSG };
export default Portal;
На этом наш портал готов. Главное при работе с порталом не забывать создавать контейнеры и рендеринг отработает без проброса ошибок.
Компонент портала у нас минималистичный, поэтому и тестов будет всего 2 группы по 2 штуки:
протестируем корректную работу правил создания контейнер для портала (mountNode || document.body)
проверим рендеринг портала в контейнер (rendreing || throw new Error).
Перед стартом делаем нужные нам импорты в файл тестов:
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import Portal, { createContainer, PORTAL_ERROR_MSG } from './index';
describe('Portal:', () => {
const mountNodeId = 'mount-node-id';
const containerId = 'container-id';
...
});
...
И не забудем data-testid атрибут на наш контейнер, чтобы мы смогли его легко найти в нашем тесте.
const createContainer = (options : containerOptions) => {
...
const { id, ...} = options;
portalContainer.setAttribute('data-testid', `portalContainer-${id}`);
...
};
describe('CreateContainer:', () => {
it('Должен создавать контейнер для портала в document.body', async () => {
createContainer({ id: containerId });
const container = screen.getByTestId(`portalContainer-${containerId}`);
expect(container).toBeInTheDocument();
});
it('Должен создавать контейнер для портала в предоставленной ноде', async () => {
render(
<div id={mountNodeId} data-testid={mountNodeId}></div>
);
const mountNode = screen.getByTestId(mountNodeId);
createContainer({ id: containerId, mountNode });
const container = screen.getByTestId(`portalContainer-${containerId}`);
expect(mountNode).toContainElement(container);
});
});
describe('React Portal', () => {
it('Должен отображать предоставленный контент в существующей ноде', async () => {
const containerId = 'container-id';
render(
<>
<div id={containerId} data-testid='some-test-id'></div>
<Portal id={containerId}>
some text
</Portal>
</>
);
const container = screen.getByTestId('some-test-id');
expect(container).toContainHTML('some text');
});
it('Должен прокидывать ошибку, если не существует контейнера для рендеринга портала', async () => {
const containerId = 'container-id';
expect(() => render(
<Portal id={containerId}>
some text
</Portal>
))
.toThrow(PORTAL_ERROR_MSG);
});
});
При попытке запуска у нас обнаружится 2 проблемы
jest не очищает нам дом дерево автоматически между тестами. Это надо делать руками.
при тестировании ошибок, консоль jest будет светится кроваво красным стектрейсом, хотя и компонент и тест ведут себя правильно.
Для решения первой проблемы (используя beforeEach и afterEach) мы замокаем console.error и ручками почистим body в нашем dom дереве.
Получим вот такую штуку:
beforeEach(() => {
jest.spyOn(console, 'error')
// @ts-ignore
console.error.mockImplementation(() => null);
});
afterEach(() => {
// @ts-ignore
console.error.mockRestore();
})
А для решения второй будем в ручную очищать document.body после каждого теста:
afterEach(() => {
// eslint-disable-next-line testing-library/no-node-access
document.getElementsByTagName('body')[0].innerHTML = '';
})
PS:
Игнор ts-ignore пишем для того, чтобы убрать ошибки типов:
"Property 'mockImplementation' does not exist on type "
Property 'mockRestore' does not exist on type
jest.spyOn нам добавляет эту функциональность
Для начала соберём воедино все требования относительно нюансов работы модального окошка, которые диктуют нам логика, здравый смысл и лучшие практики UX:
модалку делаем только одну, так как мы по определению хотим применять её в сценариях, когда нам нужно завладеть потоком действий пользователя безрадельно
хорошо бы сделать удобные варианты закрытия:
по нажатию на кнопку закрытия
по нажатию на подложку, т.е. на overlay (по клику за пределы основного контента)
по нажатию на клавишу escape
И на самом деле этого уже достаточно для +- удовлетворённого пользователя.
Для реализации первого требования нам нужно всего лишь использовать уже созданную createContainer функцию и передавать туда один и тот же id. Добавим условного рендеринга, чтобы дёргать наш портал гарантированно после создания контейнера:
import { useEffect, useState } from 'react';
import Portal, { createContainer } from '../portal';
const MODAL_CONTAINER_ID = 'modal-container-id';
const Modal = () => {
const [isMounted, setMounted] = useState(false);
useEffect(() => {
createContainer({ id: MODAL_CONTAINER_ID });
setMounted(true);
}, []);
return (
isMounted
? (<Portal id={MODAL_CONTAINER_ID}>...</Portal>)
: null
);
};
export default Modal;
import { ..., useCallback, useRef } from 'react';
import type { MouseEventHandler } from 'react';
...
import Styles from './index.module.css';
type Props = { onClose?: () => void; };
const Modal = (props: Props) => {
const { onClose } = props;
const rootRef = useRef<HTMLDivElement>(null);
...
const handleClose: MouseEventHandler<HTMLButtonElement> =
useCallback(() => {
onClose?.();
}, [onClose]);
return (
isMounted
? (
<Portal id={MODAL_CONTAINER_ID}>
<div className={Styles.wrap} ref={rootRef}>
<div className={Styles.content}>
<button
type="button"
className={Styles.closeButton}
onClick={handleClose}
>
x
</button>
...
</div>
</div>
</Portal>
)
: null
);
};
const Modal = (props: Props) => {
...
useEffect(() => {
const handleWrapperClick = (event: MouseEvent) => {
const { target } = event;
if (target instanceof Node && rootRef.current === target) {
onClose?.();
}
};
const handleEscapePress = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose?.();
}
};
window.addEventListener('click', handleWrapperClick);
window.addEventListener('keydown', handleEscapePress);
return () => {
window.removeEventListener('click', handleWrapperClick);
window.removeEventListener('keydown', handleEscapePress);
};
}, [onClose]);
return (...);
};
На этом этапе начинается простор для фантазии. Можно реализовать вёрстку как хочется, здесь будет представлен только утилитарный пример.
Добавляем title для нашей модалки, чтобы отображать её название. И конечно же children, которых она будет в себе отображать.
type Props = { ..., title: string; children: React.ReactNode;};
const Modal = (props: Props) => {
const { ..., title, children } = props;
...
return (
isMounted
? (
<Portal id={MODAL_CONTAINER_ID}>
<div className={Styles.wrap} ref={rootRef}>
<div className={Styles.content}>
...
<p className={Styles.title}>{title}</p>
{children}
</div>
</div>
</Portal>
)
: null
);
};
Так же определяем 2 группы для покрытия тестами:
тестируем поведение отображения (рендеринг)
проверяем корректный вызов обработчика onClose
Перед стартом делаем нужные нам импорты в файл тестов:
import '@testing-library/jest-dom';
import { render, fireEvent, screen } from '@testing-library/react';
import Modal from './index';
describe('Modal:', () => {...});
...
Точно так же, как и в случае с контейнером, проставляем наши data-testid. Нам понадобятся обёртка и кнопка закрытия:
<div className={Styles.wrap} {/* rest props */} data-testid="wrap">
...
<button className={Styles.closeButton} {/* rest props */} data-testid="modal-close-button">x</button>
...
describe('Отображение:', () => {
it('Должен отображаться title', async () => {
render(
<Modal title="title" onClose={jest.fn()}>
children
</Modal>
);
const title = screen.queryByText('title');
expect(title).toBeInTheDocument();
});
it('Должны отображаться children (предоставленный контент)', async () => {
render(
<Modal title="title" onClose={jest.fn()}>
some text
</Modal>
);
const children = screen.queryByText('some text');
expect(children).toBeInTheDocument();
});
});
describe('Обработчик закрытия:', () => {
it('Должен вызываться обработчик "onClose" при клике на кнопку "закрыть"', async () => {
const handleClose = jest.fn();
render(
<Modal title="title" onClose={handleClose}>
children
</Modal>
);
const wrapper = screen.getByTestId('modal-close-button');
fireEvent.click(wrapper);
expect(handleClose).toHaveBeenCalledTimes(1);
});
it('Должен вызываться обработчик "onClose" при клике на wrapper (за пределы модального окна)', async () => {
const handleClose = jest.fn();
render(
<Modal title="title" onClose={handleClose}>
children
</Modal>
);
const wrapper = screen.getByTestId('wrap');
fireEvent.click(wrapper);
expect(handleClose).toHaveBeenCalledTimes(1);
});
it('Должен вызываться обработчик "onClose" при нажатии на кнопку "escape"', async () => {
const handleClose = jest.fn();
render(
<Modal title="title" onClose={handleClose}>
children
</Modal>
);
const wrapper = screen.getByTestId('wrap');
fireEvent.keyDown(wrapper, { key: 'Escape', code: 'Escape' });
expect(handleClose).toHaveBeenCalledTimes(1);
});
});
Рендерить модалку будем с помощью стандартного useState:
import { useState } from "react";
import Modal from "./components/modal";
import "./styles.css";
export default function App() {
const [isModalActive, setModalActive] = useState(false);
const handleModalOpen = () => {
setModalActive(true);
};
const handleModalClose = () => {
setModalActive(false);
};
return (
<div className="App">
<h1>Custom Modal component Demo</h1>
<button className="button" type="button" onClick={handleModalOpen}>
open modal
</button>
<div>
{isModalActive && (
<Modal title="some modal title" onClose={handleModalClose}>
Hello world
</Modal>
)}
</div>
</div>
);
}
По итогу мы получили:
компонент портала, поверх которого можно построить любой тип всплывашек и радоваться жизни
компонент модального окна, который гарантированно будет один на странице в каждый конкретный момент времени. В добавок он умеет удобно закрываться тремя разными способами
уверенность в том, что их поведение мы не поломаем незаметно при переработке/рефакторинге, ведь мы всё покрыли тестами.
Спасибо за чтение и удачи в реализации ваших кастомных компонентов)
PS
Ссылки из статьи:
Демо codesandbox.custom-modal.
Общее
про createPortal
про jest.spyOn
Другие мои статьи про React компонентики:
про RadioGroup
про Select
про Пагинацию