DEV Community

Cover image for React modal using html "dialog"
Ellis
Ellis

Posted on • Updated on

React modal using html "dialog"

The goal:

  • Create a React modal dialog box using the new html "dialog" element. Content provided as children. (Compare to: React modal using an html "div"
  • Is it possible fully declaratively, without any javascript calls?
  • Is it better than the usual declarative implementation using React and div's?

w3schools.com says: the "dialog" element makes it easy to create popup dialogs and modals on a web page.

Support: all major browsers.

Notes:

  • The "Modal" component is generic, it shows content from the parent container, provided as children.
  • onCancel() is necessary for resetting the state variable "open" when Escape is pressed.
  • preventAutoClose() prevents closing when we click inside the modal dialog box.

I've created two components:

First, the "DialogModalTester" component contains and opens the modal:

import { useState } from "react";

import DialogModal from "./DialogModal";

const DialogModalTester = () => {
  const [isOpened, setIsOpened] = useState(false);

  const onProceed = () => {
    console.log("Proceed clicked");
  };

  return (
    <div>
      <button onClick={() => setIsOpened(true)}>Open "dialog" modal</button>

      <DialogModal
        title="Dialog modal example"
        isOpened={isOpened}
        onProceed={onProceed}
        onClose={() => setIsOpened(false)}
      >
        <p>To close: click Close, press Escape, or click outside.</p>
      </DialogModal>
    </div>
  );
};

export default DialogModalTester;
Enter fullscreen mode Exit fullscreen mode

Secondly, the "DialogModal" component itself:

import { MouseEvent, useEffect, useRef } from "react";
import styled from "styled-components";

const Container = styled.dialog`
  width: 400px;
  border-radius: 8px;
  border: 1px solid #888;

  ::backdrop {
    background: rgba(0, 0, 0, 0.3);
  }
`;

const Buttons = styled.div`
  display: flex;
  gap: 20px;
`;

const isClickInsideRectangle = (e: MouseEvent, element: HTMLElement) => {
  const r = element.getBoundingClientRect();

  return (
    e.clientX > r.left &&
    e.clientX < r.right &&
    e.clientY > r.top &&
    e.clientY < r.bottom
  );
};

type Props = {
  title: string;
  isOpened: boolean;
  onProceed: () => void;
  onClose: () => void;
  children: React.ReactNode;
};

const DialogModal = ({
  title,
  isOpened,
  onProceed,
  onClose,
  children,
}: Props) => {
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    if (isOpened) {
      ref.current?.showModal();
      document.body.classList.add("modal-open"); // prevent bg scroll
    } else {
      ref.current?.close();
      document.body.classList.remove("modal-open");
    }
  }, [isOpened]);

  const proceedAndClose = () => {
    onProceed();
    onClose();
  };

  return (
    <Container
      ref={ref}
      onCancel={onClose}
      onClick={(e) =>
        ref.current && !isClickInsideRectangle(e, ref.current) && onClose()
      }
    >
      <h3>{title}</h3>

      {children}

      <Buttons>
        <button onClick={proceedAndClose}>Proceed</button>
        <button onClick={onClose}>Close</button>
      </Buttons>
    </Container>
  );
};

export default DialogModal;
Enter fullscreen mode Exit fullscreen mode

As a nice-to-have, add the following class to your global css to prevent the body from scrolling when the modal is open. In browser developer tools you can observe this class actually being added and removed.

body.modal-open {
  overflow: hidden; /* see "modal-open" in Modal.tsx */
}
Enter fullscreen mode Exit fullscreen mode

Conclusion:

  • Is it better than the typical declarative implementation using React and div's? KIND OF YES, this seems simpler and shorter, though the need for javascript calls makes it still not too simple with React. One feels it should have been simpler.
  • Is it possible fully declaratively, without javascript calls? NO, unfortunately we need useRef and javascript calls if we want a modal dialog with a backdrop and not to close itself on click inside the dialog. If we have to write javascript to make an HTML element usable, I think that points at a design flaw.

Thanks for reading. Suggestions/corrections are welcome.

Top comments (14)

Collapse
 
charlex profile image
HCB • Edited

BTW, if you don't want to have that inner div to block clicks, you could use something like this:

<div className="App">
  <button onClick={() => toggle(!show)}>Open</button>
  <dialog
    ref={ref}
    onClick={(e) => {
      const dialogDimensions = ref.current.getBoundingClientRect();
      if (
        e.clientX < dialogDimensions.left ||
        e.clientX > dialogDimensions.right ||
        e.clientY < dialogDimensions.top ||
        e.clientY > dialogDimensions.bottom
      ) {
        ref.current.close();
      }
    }}
  >
    <h1>Test</h1>
  </dialog>
</div>
Enter fullscreen mode Exit fullscreen mode
Collapse
 
elsyng profile image
Ellis • Edited

That's good as well, I've replaced the extra div with the rectangle check as a function, thanks.

If we have to write javascript to make an HTML element usable, I think that points at a design flaw. :o)

Collapse
 
demondragong profile image
Gaël de Mondragon

There are not many examples of React modals using the HTMLDialogElement and even less with such a clear and clean implementation.
Thank you very much!

Collapse
 
demondragong profile image
Gaël de Mondragon

I made it into an npm package for a training project: npmjs.com/package/react-basic-moda...

Collapse
 
elsyng profile image
Ellis

Great. The npm page looks neat. I'll try it when I have time :)

Collapse
 
shimbarks profile image
Shimbarks • Edited

Thanks for this well detailed yet concise guide!

A few comments though:

  1. If you use the keyboard you'll find out there's a bug: after closing the dialog with the Esc key, the dialog won't open again. That's because the isOpened prop isn't being updated upon Esc, hence clicking the Open "dialog" modal button doesn't change the state and the useEffect isn't being called. In order to fix this you need to listen to keyboard events on the window/document (not on the dialog itself, otherwise it won't work if the Esc key is pressed after shifting the focus from the dialog element) and call onClose after Esc was pressed.

  2. Just a minor tedious TypeScript suggestion: replace const ref: any = useRef(null) with const ref = useRef<HTMLDialogElement>(null).

  3. Not really a comment but a question: why do we need the preventAutoClose stuff? I tried your code without it and I haven't encountered the situation in which clicking the dialog content closes it. What am I missing?

Thanks!

Collapse
 
elsyng profile image
Ellis • Edited

Thanks.

Point 1. Just tested with Firefox (v112) and Edge (v111), and I'm afraid I haven't been able to reproduce that. (isOpened "is" being updated properly.)

Point 2. If I do that, I get

TS2339: Property 'showModal' does not exist on type 'HTMLDialogElement'.
  > 38 |       ref.current?.showModal();
       |                    ^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

Point 3. Again on Firefox & Edge, if I remove preventAutoClose() and click inside the dialog, it closes.

Which perhaps goes to show, that the various implementations of the <Dialog> html element may have some bugs or inconsistencies as yet.

Collapse
 
jibbs profile image
Kenny G

Still got the issue here, as pressing Esc doesn't update the dialog state.

As I'm wondering to use dialog vs a full React solution, I still have to create a Esc key listener in order to make it works am I right?

Does anyone succeeded by fixing this issue/totally normal behavior?
Is my code below right (made from scratch from my storybook)

export const HtmlMarkup = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <Button label="Please Open that Modal" onClick={() => setIsOpen(true)} />
      <HtmlModal isOpened={isOpen} onClose={() => setIsOpen(false)} />
    </div>
  );
};

interface HtmlModalProps {
  isOpened: boolean;
  onClose: () => void;
}

const HtmlModal: FC<HtmlModalProps> = ({ isOpened, onClose }) => {
  const modalRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    if (isOpened) {
      modalRef.current?.showModal();
      document.body.classList.add('body--scroll-lock');
    } else {
      modalRef.current?.close();
      document.body.classList.remove('body--scroll-lock');
    }
  }, [isOpened]);

  return (
    <dialog ref={modalRef} className="c-html-modal">
      Check ce contenu!
      <Button size="xsmall" label="Close" onClick={onClose} />
    </dialog>
  );
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
elsyng profile image
Ellis

A quick note. Personally, I would still prefer the React way: I find it simpler and more intuitive to implement and to read. The html dialog should have been simpler, but it feels a bit messy, and not well enough designed.

Collapse
 
bp181 profile image
Bartłomiej Pawlak

You have to add type="button" to the close button, otherwise you won't be able to open the dialog again after closing it with the Esc key.

Collapse
 
elsyng profile image
Ellis • Edited

Hi Bartłomiej,
Thanks. What you are saying: I have tested it on Firefox and Edge (both Windows and up-to-date), but I cannot reproduce it. The dialog can re-open without a problem after "Escape".

Actually, (if I understand it correctly) with React, we (or I :o) typically use the js action (ie. the onClick) to trigger the action and not the HTML action as such, so the html element doesn't matter much. (In React, typically:) We are just using the "onClick" for js, and we don't care much about the html element or its html structure. (Well, this is foremost a React and js application.)

That we are using a button is just for the show really. All of the following will work just fine:

<button onClick={onClose}>Close</button>
<div onClick={onClose}>Close</div>
<p onClick={onClose}>Close</p>
<span onClick={onClose}>Close</span>
<header onClick={onClose}>Close</header>
<footer onClick={onClose}>Close</footer>
<potato onClick={onClose}>Potato</potato>
<tomato onClick={onClose}>Tomato</tomato>
Enter fullscreen mode Exit fullscreen mode

(Actually, within the React context, I am "personally" a proponent of using div's instead of buttons and such where we use the onClick, and the html element name actually does not matter.)

Thread Thread
 
shimbarks profile image
Shimbarks • Edited

AAMOF the html tag does matter. Using div's instead of buttons is really bad for accessibility.

Thread Thread
 
elsyng profile image
Ellis

True, there is that.

To be honest, most projects I work on, neither accessibility nor keyboard use is included in the requirements or functionality. I'm not saying it is good or bad. Just what it is.

Thread Thread
 
elsyng profile image
Ellis • Edited

And very coincidentally, I've just read this new article. It is somewhat relevant perhaps. And i sympathise with the point of view there. Simplicity is also a thing. thinkdobecreate.com/articles/a-cal...