Без лишних слов

Хочешь меньше слов, больше кода ? Тогда можно сразу посмотреть демку codesandbox.custom-modal.

А пояснительная бригада к демке ждёт вас дальше по тексту)

Поехали!

План действий

  1. Проектируем решение

  2. Пишем портал + тесты на портал

  3. Пишем модалку + тесты на модалку

  4. Запускаем всё в контейнере

  5. Профит

Думаем и проектируем

Делать будем модальное окно. Не подсказку, не дропдаун, не pop-up инфо всплывашку, а именно модалку. Это важно, так как основная суть модального окна, это (как правило) приостановить текущий флоу взаимодействия пользователя со страницей и переключить на другой поток действий, а после завершения/отмены которого нужно вернуть пользователя в изначальный поток.

Про смысловые отличия popup/modals/lightboxes/tooltip/notice детали несложно гуглятся. Для удобства оставлю глянуть эту и эту ссылки.

Из этого основного свойства/отличия, появляется важное ограничение - модальное окно должно быть на странице в единственном экземпляре.

Поскольку мы тут пишем про React, то очевидно будем использовать встроенные фишки, порталы.

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

  1. портал в качестве овновы

  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 проблемы

  1. jest не очищает нам дом дерево автоматически между тестами. Это надо делать руками.

  2. при тестировании ошибок, консоль 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
  );
};

По нажатию на "escape" и клику на "overlay"

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 (...);
};

Расширяем Props

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

Добавляем 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

Ссылки из статьи:

Другие мои статьи про React компонентики:

Комментарии (3)


  1. gbrdhvndi
    19.05.2023 13:28

    По-моему, было бы гораздо проще реализовать модальное окно на основе элемента dialog, потому что он поддерживает модальность (захват фокуса) и закрытие по Эскейпу "из коробки". Достаточно добавить кнопку X и немного стилей.

    Для того, чтобы фокус работал правильно вне зависимости от наличия или отсуствия элементов ввода в теле диалога, кнопку X можно расположить "внизу", переместив её "наверх" с помощю стилей.

    Например:

    import type { PropsWithChildren, ReactNode } from 'react';
    
    import { forwardRef, useRef, useImperativeHandle } from 'react';
    
    export interface IDialog {
        get isOpen(): boolean;
    
        show(): void;
        hide(): void;
    }
    
    export type DialogProps = PropsWithChildren<{
        label?: string;
        footer?: ReactNode;
    }>;
    
    const Dialog = forwardRef<IDialog, DialogProps>(function Dialog(
        { label, footer, children },
        ref
    ) {
        const dialogRef = useRef<HTMLDialogElement>(null);
    
        useImperativeHandle(
            ref,
            () => {
                return {
                    get isOpen() {
                        return dialogRef?.current?.open;
                    },
                    show() {
                        dialogRef?.current?.showModal();
                    },
                    hide() {
                        dialogRef?.current?.close();
                    },
                } as IDialog;
            },
            []
        );
    
        const handleCloseButtonClick = () => {
            dialogRef?.current?.close();
        };
    
        return (
            <dialog
                ref={dialogRef}
                className="overflow-visible bg-transparent p-0 outline-none backdrop:animate-reveal-100 backdrop:bg-slate-100 backdrop:bg-opacity-50 backdrop:backdrop-blur-xs"
            >
                <div className="flex flex-col-reverse">
                    <article className="relative flex h-screen-50 max-h-100 min-h-60 w-screen-40 min-w-xl max-w-2xl flex-col overflow-hidden rounded-md border border-slate-300 bg-white drop-shadow-md">
                        {label && (
                            <header className="flex items-center justify-between border-b border-b-slate-300 bg-slate-100 p-4">
                                <h2 className="text-lg font-semibold">{label}</h2>
                            </header>
                        )}
                        <div className="flex-1 overflow-hidden overflow-y-auto">
                            {children}
                        </div>
                        {footer && (
                            <footer className="flex justify-end border-t border-t-slate-300 p-4">
                                {footer}
                            </footer>
                        )}
                    </article>
                    <div className="flex w-full justify-end py-2 align-middle">
                        <button
                            onClick={handleCloseButtonClick}
                            className="h-8 w-8 rounded-md bg-transparent hover:bg-slate-200 focus:bg-slate-200"
                        >
                            <span className="leading-none text-slate-800">✕</span>
                        </button>
                    </div>
                </div>
            </dialog>
        );
    });
    
    export default Dialog;
    


  1. Sol0Zon3
    19.05.2023 13:28

    А как правильно обработать ситуацию, когда одно модальное окно порождает другое? В данной реализации вроде это никак не обрабатывается?


    1. robzarel Автор
      19.05.2023 13:28

      Для такого сценария достаточно будет менять контент внутри модалки по каким-то условиям. Т.е. не модалка пораждает другую модалку, а просто идёт смена контента внутри модального окна.

      Например можно попробовать прикинуть на примере пошаговой формы (скажем 3 шага), с сабмитом на последнем шаге.
      Контент внутри modal под условным рендерингом будет находится. Каждый "шаг" отображается по набору условий (допустим стейт в компоненте с номером шага). Далее схема будет следующая:
      - заполнили 1й шаг, нажали кнопку "дальше"
      - по клику на кнопку стейт поменялся ->
      - затем сработали условия для скрытия 1го шага и показа 2го шага
      - заполнили 2й шаг -> клик-> стейт -> смена условий и показ 3го шага
      - сделали сабмит, перестали рендерить модалку