DevGang
Авторизоваться

Укрощение dialog HTML с помощью React и TailwindCSS

Сегодня мы собираемся создать модальный компонент, используя собственный dialog элемент HTML, а также React и TailwindCSS.

Чтобы увидеть это в действии, перейдите по этой ссылке.

npx degit \
   fibonacid/html-dialog-react-tailwind/src/Step3.tsx \
   ./Modal.tsx

Убедитесь, что TailwindCSS настроен и tailwind-merge установлен в качестве зависимости времени выполнения вашего проекта. Давайте начнем!

Шаг 1. Оберните элемент Dialog HTML

Работа с элементом диалога HTML может быть немного сложной. Хорошей практикой является создание для него оболочки, предоставляющей декларативный API для простоты использования. Начнем с того, что просто обернем элемент и передадим все его свойства:

components/Modal.tsx
import { type ComponentPropsWithoutRef } from "react";

export type ModalProps = ComponentPropsWithoutRef<"dialog">;

export default function Modal(props: ModalProps) {
  return (
    <dialog {...rest}>
      {children}
    </dialog>
  );
}

В документации элемента dialog HTML вы найдете атрибут open. Естественно, вы можете подумать об использовании этого с React для управления видимостью модального окна. Однако все не так просто. Использование следующего кода показывает, что после открытия модальное окно невозможно закрыть:

import { useState } from "react";
import Modal from "./components/Modal";

export default function App() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button
        className="m-4 underline outline-none focus-visible:ring"
        onClick={() => setOpen(true)}
        aria-controls="modal"
        aria-labelledby="modal-title"
        aria-describedby="modal-desc"
      >
        Open modal
      </button>
      <Modal id="modal" open={open} onClose={() => setOpen(false)}>
        <h2 id="modal-title" className="mb-1 text-lg font-bold">
          Modal
        </h2>
        <p id="modal-desc">
          Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ab optio
          totam nihil eos, dolor aut maiores, voluptatum reprehenderit sit
          incidunt culpa? Voluptatum corrupti blanditiis nihil voluptatem atque,
          dolor ducimus! Beatae.
        </p>
        <button
          autoFocus={true}
          className="float-right underline outline-none focus-visible:ring"
          onClick={() => setOpen(false)}
          aria-label="Close modal"
        >
          Close
        </button>
      </Modal>
    </div>
  );
}

Атрибут open предназначен для чтения состояния диалога, а не для его установки. Чтобы открыть и закрыть диалог, мы должны использовать методы showModal и close:

const dialog = document.querySelector('dialog');

dialog.showModal();
console.log(dialog.open); // true

dialog.close();
console.log(dialog.open); // false 

Чтобы синхронизировать состояние диалога с React, мы воспользуемся хуком useEffect. Он будет прослушивать изменения в open prop и соответственно вызывать методы showModal и close

components/Modal.tsx
import { useEffect, useRef, type ComponentPropsWithoutRef } from "react";

export type ModalProps = ComponentPropsWithoutRef<"dialog"> & {
  onClose: () => void;
};

export default function Modal(props: ModalProps) {
  const { children, open, onClose, ...rest } = props;
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = ref.current!;
    if (open) {
      dialog.showModal();
    } else {
      dialog.close();
    }
  }, [open]);

  return (
    <dialog ref={ref} {...rest}>
      {children}
    </dialog>
  );
}

Если вы перезапустите код в App.tsx, модальное окно теперь будет вести себя так, как ожидалось. Однако проблема все еще существует. Если вы откроете модальное окно, а затем нажмете, оно закроется, но состояние React не обновится. Следовательно, повторное нажатие кнопки открытия не приведет к повторному срабатыванию эффекта, и модальное окно не откроется. Чтобы решить эту проблему, нам нужно прослушивать события закрытия и отмены диалога, соответствующим образом обновляя состояние:

useEffect(() => {
  const dialog = ref.current!;
  const handler = (e: Event) => {
    e.preventDefault();
    onClose();
  };
  dialog.addEventListener("close", handler);
  dialog.addEventListener("cancel", handler);
  return () => {
    dialog.removeEventListener("close", handler);
    dialog.removeEventListener("cancel", handler);
  };
}, [onClose]);

Благодаря этим изменениям наш модальный компонент теперь должен быть полностью функциональным.

Шаг 2: Оформление модального окна

Теперь, когда наш диалог работоспособен, давайте улучшим его внешний вид с помощью TailwindCSS. Мы изменим модальный компонент, чтобы применить набор стилей по умолчанию, который пользователи могут расширять через свойство className:

// same imports...
import { twMerge } from "tailwind-merge";

// same type...

export default function Modal(props: ModalProps) {
  const { children, open, onClose, className, ...rest } = props;

 // same hooks...

  return (
    <dialog ref={ref} className={twMerge("group", className)} {...rest}>
      <div className="fixed inset-0 grid place-content-center bg-black/75">
        <div className="w-full max-w-lg bg-white p-4 shadow-lg">{children}</div>
      </div>
    </dialog>
  );
}

Здесь следует отметить два важных аспекта:

  1. Мы используем twMerge функцию из tailwind-merge пакета для объединения стилей по умолчанию со стилями, указанными пользователем.
  2. Мы используем элементы div для рендеринга фона и модального контейнера вместо использования псевдоэлемента ::backdrop. Это связано с тем, что стилизовать сам элемент диалога сложно, особенно когда дело касается CSS-переходов.

Шаг 3. Анимируйте модальное окно

Анимация модального компонента с помощью <dialog> элемента HTML может быть довольно сложной. Ключевая проблема возникает потому, что когда этот элемент закрывается, браузеры автоматически применяют display: none стиль. Этот стиль мешает плавному применению переходов и анимации CSS.

Чтобы обойти эту проблему, нам нужно управлять временем применения свойства open. Важно отложить его применение до завершения переходов входа/выхода. Для этого мы будем использовать data-open атрибут. Этот атрибут позволяет нам переключаться между открытым и закрытым состояниями без активации display: none стиля.

Вот как мы можем обновить useEffect хук, чтобы справиться с этим:

useEffect(() => {
  const dialog = ref.current!;
  if (open) {
    dialog.showModal();
    dialog.dataset.open = "";
  } else {
    delete dialog.dataset.open;
    const handler = () => dialog.close();
    const inner = dialog.children[0] as HTMLElement;
    inner.addEventListener("transitionend", handler);
    return () => inner.removeEventListener("transitionend", handler);
  }
}, [open]);

Для применения переходов мы можем использовать класс TailwindCSS с именем group. Этот класс позволяет нам эффективно применять условные стили к дочерним элементам dialog нашего компонента. Вот как это интегрировать:

<dialog ref={ref} className={twMerge("group", className)} {...rest}>
  <div className="fixed inset-0 grid place-content-center bg-black/75 opacity-0 transition-all group-data-[open]:opacity-100">
    <div className="w-full max-w-lg scale-75 bg-white p-4 opacity-0 shadow-lg transition-all group-data-[open]:scale-100 group-data-[open]:opacity-100">
      {children}
    </div>
  </div>
</dialog>

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

Источник:

#JavaScript #React #HTML
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться