Укрощение 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 для простоты использования. Начнем с того, что просто обернем элемент и передадим все его свойства:
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
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>
);
}
Здесь следует отметить два важных аспекта:
- Мы используем
twMerge
функцию изtailwind-merge
пакета для объединения стилей по умолчанию со стилями, указанными пользователем. - Мы используем элементы 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>
Этот подход поможет создать плавный и визуально привлекательный переход для вашего модального окна.