0

This is the main page.

"use client";

import React, { useEffect, useState } from "react";
import TestModal from "./TestModal";

const App = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [isDiscardModalOpen, setIsDiscardModalOpen] = useState(false);

  useEffect(() => {
    const handleEscape = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !isDiscardModalOpen) {
        setIsDiscardModalOpen(true);
      }
    };

    document.addEventListener("keydown", handleEscape);

    return () => {
      document.removeEventListener("keydown", handleEscape);
    };
  }, [isDiscardModalOpen]);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <TestModal
        title="Test Modal"
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
      >
        <div>This is a test Modal</div>
      </TestModal>
      <TestModal
        title="Discard Modal"
        isOpen={isDiscardModalOpen}
        onClose={() => {
          setIsDiscardModalOpen(false);
        }}
      >
        <div>This is a discard Modal</div>
      </TestModal>
    </div>
  );
};

export default App;

This is a modal component.

"use client";

import React, { useEffect, useRef } from "react";

interface ModalProps {
  isOpen: boolean;
  title: string;
  children: React.ReactNode;
  onClose: () => void;
}

const TestModal: React.FC<ModalProps> = ({
  isOpen,
  title,
  children,
  onClose,
}) => {
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (
        isOpen &&
        modalRef.current &&
        !modalRef.current.contains(event.target as Node)
      ) {
        event.preventDefault();
      }
    };

    const handleEscape = (event: KeyboardEvent) => {
      if (event.key === "Escape" && isOpen) {
        onClose();
      }
    };

    if (isOpen) {
      document.addEventListener("keydown", handleKeyDown);
      document.addEventListener("keydown", handleEscape);
    }

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("keydown", handleEscape);
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <>
      <div
        role="dialog"
        aria-modal="true"
        ref={modalRef}
        className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center p-4"
      >
        <div className="bg-white p-6 rounded-lg shadow-lg max-w-lg w-full">
          <header className="flex justify-between items-center mb-4">
            <h2 className="text-xl font-semibold">{title}</h2>
            <button
              onClick={onClose}
              className="text-gray-600 hover:text-gray-800"
            >
              Close
            </button>
          </header>
          <div className="modal-content overflow-auto max-h-[80vh]">
            {children}
          </div>
        </div>
      </div>
    </>
  );
};

export default TestModal;

I have a page and a TestModal component. I have a esc button functionality on my page where hitting the esc opens the discard modal. And I also have a escape functionality in the testModal component. When the modal is open, hitting esc closes the modal. The problem that I am having is that when the modal is open and I hit the esc key, the esc button event on the page triggers on top of the modal esc event. So now there are two modals open and the discard modal is open on top of the testModal. It shouldn't be happening. Hitting the esc key from the modal should only close the modal and not progagate the click event to the parent. How can I make it so that when a modal is open, all the keyboard and mouse events are disabled for its all parent components?

Try it yourself for better context: CodesandboxLink

2
  • event.stopPropagation?
    – Konrad
    Commented Jun 28 at 11:59
  • @Konrad, tried. Didn't work. Commented Jun 28 at 12:09

2 Answers 2

0

If I'm not mistaken, you want the discard modal to appear only when there are no modals open, correct? So this should help:

App.tsx:

useEffect(() => {
    const handleEscape = (event: KeyboardEvent) => {
        if (event.key === "Escape" && !isDiscardModalOpen && !isOpen) {
            setIsDiscardModalOpen(true);
        }
    };

    document.addEventListener("keydown", handleEscape);

    return () => {
        document.removeEventListener("keydown", handleEscape);
    };
}, [isDiscardModalOpen, isOpen]);

TestModal.tsx:

useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
        if (event.key === "Escape" && isOpen) {
            onClose();
        }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
        document.removeEventListener("keydown", handleKeyDown);
    };
}, [isOpen, onClose]);

The useEffect in App.tsx will only call setIsDiscardModalOpen when the isOpen state is false. I've removed event.stopPropagation from TestModal.tsx because it's not really required anymore.

1
  • Actually, the thing is that here I have presented a simplified problem. What if the child component, which has modals, is deeply nested? The only resort we have is to use context or redux, and that might be the optimum solution. There may be something that we can do to make it so that we disable all the keyboard and mouse events outside the modal when the modal is opened. Am I missing something here? Commented Jun 28 at 15:20
0

I don't think it's a good choice to add ref to the TestModal component since you are using the same component to render the "test modal" as well as "discard modal".

App.tsx:

const App = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [isDiscardModalOpen, setIsDiscardModalOpen] = useState(false);

  useEffect(() => {
    const handleEscape = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        /*escape key to open "discard Modal" when "open modal" is in close state*/
        if (!isDiscardModalOpen && !isOpen) {
          setIsDiscardModalOpen(true);
        }

        /*escape key to close "discard Modal" when "open modal" is in close state*/
        if (isDiscardModalOpen && !isOpen) {
          setIsDiscardModalOpen(false);
        }

        /*escape key to close "open modal" when it is in open state */
        if (isOpen && !isDiscardModalOpen) {
          setIsOpen(false);
        }
      }
    };
    document.addEventListener("keydown", handleEscape);

    return () => {
      document.removeEventListener("keydown", handleEscape);
    };
  }, [isDiscardModalOpen, isOpen]);

  return (
    <div>
      <button
        onClick={() => {
          //also in case if u want to make sure discard is closed when we
          // want to open the text modal we can set the isDiscardModalOpen to false
          if (isDiscardModalOpen) {
            setIsDiscardModalOpen(false);
          }
          setIsOpen(true);
        }}
      >
        Open Modal
      </button>
      {isOpen && (
        <TestModal
          title="Test Modal"
          isOpen={isOpen}
          onClose={() => {
            setIsOpen(false);
          }}
        >
          <div>This is a test Modal</div>
        </TestModal>
      )}
      {isDiscardModalOpen && !isOpen && (
        <TestModal
          title="Discard Modal"
          isOpen={isDiscardModalOpen}
          onClose={() => {
            setIsDiscardModalOpen(false);
          }}
        >
          <div>This is a discard Modal</div>
        </TestModal>
      )}
    </div>
  );
};

TestModal.tsx:

const TestModal: React.FC<ModalProps> = ({
  isOpen,
  title,
  children,
  onClose,
}) => {
  // const modalRef = useRef<HTMLDivElement>(null);
  return (
    <>
      <div
        role="dialog"
        aria-modal="true"
        // ref={modalRef}
        id={"#" + title.split(" ").join("_")}
        className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center p-4"
      >
        <div className="bg-white p-6 rounded-lg shadow-lg max-w-lg w-full">
          <header className="flex justify-between items-center mb-4">
            <h2 className="text-xl font-semibold">{title}</h2>
            <button
              onClick={onClose}
              className="text-gray-600 hover:text-gray-800"
            >
              Close
            </button>
          </header>
          <div className="modal-content overflow-auto max-h-[80vh]">
            {children}
          </div>
        </div>
      </div>
    </>
  );
};

I have added comments for better understanding, but if you have any doubts or need clarification don't hesitate to reach out.

Here's the link to the codesandbox - modal-escape

3
  • I get it but what happens if the modal is in some deeply nested component? I can’t directly use the modal states so I would be forced to use context or redux. Maybe there is some other better workaround to this. Like disabling keyboard and mouse events for all the elements outside the scope of the modal when modal is open. Commented Jun 28 at 21:05
  • I can think of some way if you can present with the scenario where u have a modal nested deeply inside the app, sure you can use context or redux to manage the state "isOpen" and "isDiscardModalOpen" app wide, but depends on what you are trying to achieve, in this case you don't need. Is it possible for you to share the code where u have modal nested deeply?
    – B_B
    Commented Jun 29 at 3:33
  • I think context is one way you can do this, in the context you can have an array containing all modals and only show the discard modal when there are no modals open.
    – arjndr
    Commented Jun 29 at 6:43

Not the answer you're looking for? Browse other questions tagged or ask your own question.