How to close a native HTML dialog when clicking outside with JavaScript?
Asked Answered
B

7

30

I use an HTML <dialog> element. I want to be able to close the dialog when clicking outside of it. Using "blur" or "focusout" event does not work.

I want the same thing as Material Design dialog, where it closes the dialog when you click outside of it:

https://material-components-web.appspot.com/dialog.html

How can I achieve that?

Thanks in advance.

Burgonet answered 26/4, 2018 at 7:54 Comment(1)
Does this answer your question? How to close the new html <dialog> tag by clicking on its ::backdropSuperadd
A
24

When a dialog is opened in modal mode, a click anywhere on the viewport will be recorded as a click on that dialog.

The showModal() method of the HTMLDialogElement interface displays the dialog as a modal, over the top of any other dialogs that might be present. It displays into the top layer, along with a ::backdrop pseudo-element. Interaction outside the dialog is blocked and the content outside it is rendered inert. Source: HTMLDialogElement.showModal()

One way to solve the question is to:

  • Nest a div inside your dialog and, using CSS, make sure it covers the same area as the dialog (note that browsers apply default styles to dialogs such as padding)
  • Add an event listener to close the dialog when a user clicks on the dialog element (anywhere on the viewport)
  • Add an event listener to prevent propagation of clicks on the div nested inside the dialog (so that the dialog does not get closed if a user clicks on it)

You can test this with the code snippet below.

const myButton = document.getElementById('myButton');
myButton.addEventListener('click', () => myDialog.showModal());

const myDialog = document.getElementById('myDialog');
myDialog.addEventListener('click', () => myDialog.close());

const myDiv = document.getElementById('myDiv');
myDiv.addEventListener('click', (event) => event.stopPropagation());
#myDialog {
  width: 200px;
  height: 100px;
  padding: 0;
}

#myDiv {
  width: 100%;
  height: 100%;
  padding: 1rem;
}
<button id="myButton">Open dialog</button>
<dialog id="myDialog">
  <div id="myDiv">
    Click me and I'll stay...
  </div>
</dialog>
Amadavat answered 7/10, 2022 at 14:19 Comment(1)
That's an interesting solution. Another idea is to check if the target of the event is the dialog itself. If not, then it's the div that covers the visible part of the modal (or a child). This way we don't need the second event handler, I think it's easier to understand.Handkerchief
I
12

This is how I did it:

function dialogClickHandler(e) {
    if (e.target.tagName !== 'DIALOG') //This prevents issues with forms
        return;

    const rect = e.target.getBoundingClientRect();

    const clickedInDialog = (
        rect.top <= e.clientY &&
        e.clientY <= rect.top + rect.height &&
        rect.left <= e.clientX &&
        e.clientX <= rect.left + rect.width
    );

    if (clickedInDialog === false)
        e.target.close();
}
Infield answered 12/8, 2019 at 15:10 Comment(5)
Can you go into more detail on how clickedInDialog works?Flaherty
I test it, and it works. I like this because I don't need to make my markup more complex. I don't like how the calculation is complex. I appears that I have the choice to have the complexity in the markup or in jsFlaherty
its a bit hard to read and for that reason I prever the wrapping div solutionFlaherty
This should be a modal feature. They can also add focus trap to the modal. With this two missing features doing modals would become so easyFlaherty
In your code there is a comment that says "This prevents issues with forms". What kind of problem do you mean? The only one I can think of is when you have a <select> element that has many options and some of them are displayed outside of the dialog. Are there other cases where the dialog might close when it shouldn't with your code and forms?Superadd
S
8

Modal

To close a modal dialog (i.e. a dialog opened with showModal) by clicking on the backdrop, you could do as follows:

const button = document.getElementById('my-button');
const dialog = document.getElementById('my-dialog');
button.addEventListener('click', () => {dialog.showModal();});
// here's the closing part:
dialog.addEventListener('click', (event) => {
    if (event.target.id !== 'my-div') {
        dialog.close();
    }
});
#my-dialog {padding: 0;}
#my-div {padding: 16px;}
<button id="my-button">open dialog</button>
<dialog id="my-dialog">
    <div id="my-div">click outside to close</div>
</dialog>

This places the dialog content in a <div>, which is then used to detect whether the click was outside the dialog, as suggested here. The padding and margins in the example are adjusted to make sure the <dialog> border and <div> border coincide.

Note that the modal dialog's "background" can be selected in CSS using ::backdrop.

Non-modal

For a non-modal dialog (opened using show), you could add the event listener to the window element instead of the dialog, e.g.:

window.addEventListener('click', (event) => {
    if (!['my-button', 'my-div'].includes(event.target.id)) {
        dialog.close();
    }
});

In this case we also need to filter out button clicks, otherwise the dialog is immediately closed after clicking the "open dialog" button.

Stickseed answered 26/7, 2022 at 11:30 Comment(3)
Your modal dialog example closes literally if you click anywhere - including clicks inside the dialog. This works more like a tooltip - I can't think of any use case for that?Veld
@Veld One use case is simply showing a message: click anywhere to make it go away. This was just the simplest example I could think of to illustrate the use of HTMLDialogElement.close(). You can attach the listener to another element and add some logic, if you don't like this behavior.Stickseed
The crucial part I was missing: with showModal() all clicks anywhere in the page end up in the dialog element, which is why adding the click listener actually works.Norther
R
2

This is 5 year-old question but I thought I'd provide a straightforward solution for 2024. By that I mean not adding any new elements to the screen or measuring anything to achieve it. Here's a codepen if you want to jump straight to the code:

https://codepen.io/dengel29/pen/vYPVMXE

The idea here is you can just target the id of your dialog to close it, so if your HTML looks like this:

<button id='opener'>Open Dialog</button>
<button id='closer'>Close Dialog</button>
<dialog id='modal'> // <-- here's the id to target
  <div class="dialog-inner">
    <form>
      <button id="cancel" formmethod="dialog" >x</button>
      <h1>A title for your modal?</h1>
      <div class="content">
        <p>Anything else you'd like to add here</p>
      </div>
    </form>
  </div>
</dialog>

Your js logic for opening and closing programmatically can simply be:


// save a reference to the modal, selected by the id used in markup
const modal = document.querySelector('#modal')

// provide a function to 'click outside to close' event listener
function clickOutsideToClose(e) {
  if (e.target.id === 'modal') closeModalHandler()
}

// only add the event when the modal is open. you can add this function to fire on any click event listener elsewhere on your page
function openModal() {
  modal.showModal();
  modal.addEventListener('click', clickOutsideToClose)
}

// this programmatically closes the dialog, and cleans up the event listener
function closeModalHandler() {
  modal.removeEventListener('click', clickOutsideToClose)
  modal.close();
}

It works, but not quite perfect – you'll realize if you simply implement the HTML and JS that sometimes your clicks inside the modal / pop-up will still close it. This is because some of the user-agent padding bleeds into the inside of dialog. In other words, the border of the actual modal is not the actual edge of the HTML element, so we simply reset the padding with this CSS:

dialog {
  padding: 0 0 0 0;
}

I'm sure there are plenty of accessibility improvements that could be made, but I believe this gets the job done with the fewest

Reck answered 12/2, 2024 at 5:58 Comment(2)
Why do you add the event listener to the dialog every time you open it? Can't you just add the event listener to the dialog only once and keep it instead?Superadd
Yeah you're right @Adrian, I don't think it's necessary to add and remove every time since the element with those listeners persists in the DOM. I think this would only be necessary in the case that the element was removed from the DOM each time, in which case the event listeners would need to be re-applied. I think my instinct here is informed by SPA frameworks often encouraging you to clean-up unused events.Reck
C
1

Here is a full example with two dialog elements, one purely information, and the other including an dialog form.

const initializeDialog = function(dialogElement) {
  // enhance opened standard HTML dialog element by closing it when clicking outside of it
  dialogElement.addEventListener('click', function(event) {
    const eventTarget = event.target;
    if (dialogElement === eventTarget) {
      console.log("click on dialog element's content, padding, border, or margin");
      const dialogElementRect = dialogElement.getBoundingClientRect();
      console.log("dialogElementRect.width", dialogElementRect.width);
      console.log("dialogElementRect.height", dialogElementRect.height);
      console.log("dialogElementRect.top", dialogElementRect.top);
      console.log("dialogElementRect.left", dialogElementRect.left);
      console.log("event.offsetX", event.offsetX);
      console.log("event.clientX", event.clientX);
      console.log("event.offsetY", event.offsetY);
      console.log("event.clientY", event.clientY);
      if (
        (dialogElementRect.top > event.clientY) ||
        (event.clientY > (dialogElementRect.top + dialogElementRect.height)) ||
        (dialogElementRect.left > event.clientX) ||
        (event.clientX > (dialogElementRect.left + dialogElementRect.width))
      ) {
        console.log("click on dialog element's margin. closing dialog element");
        dialogElement.close();
      }
      else {
        console.log("click on dialog element's content, padding, or border");
      }
    }
    else {
      console.log("click on an element WITHIN dialog element");
    }
  });
  
  const maybeDialogFormElement = dialogElement.querySelector('form[method="dialog"]');
  if (! maybeDialogFormElement) {
    // this dialog element does NOT contain a "<form method="dialog">".
    // Hence, any contained buttons intended for closing the dialog will
    // NOT be automatically set up for closing the dialog
    // (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes ).
    // Therefore, programmatically set up close buttons
    const closeButtons = dialogElement.querySelectorAll('button[data-action-close], button[data-action-cancel]');
    closeButtons.forEach(closeButton => {
      closeButton.addEventListener('click', () => dialogElement.close() );
    });
  }
  
  return dialogElement;
};

const initializeFormDialog = function(formDialog, formCloseHandler) {
  const submitButton = formDialog.querySelector('button[type="submit"]');
  const inputElement = formDialog.querySelector('input');
  
  formDialog.originalShowModal = formDialog.showModal;
  formDialog.showModal = function() {
    // populate input element with initial or latest submit value
    inputElement.value = submitButton.value;
    formDialog.dataset.initialInputElementValue = inputElement.value;
    formDialog.originalShowModal();
  }
  
  // allow confirm-input-by-pressing-Enter-within-input-element
  inputElement.addEventListener('keydown', event => {
    if (event.key === 'Enter') {
      //prevent default action, which in dialog-form case would effectively cancel, not confirm the dialog
      event.preventDefault();
      submitButton.click();
    }
  });
  
  submitButton.addEventListener('click', () => {
    submitButton.value = inputElement.value;
    // add dialog-was-confirmed marker
    formDialog.dataset.confirmed = "true";
  });
  
  formDialog.addEventListener('close', event => {
    if (formCloseHandler) {
      const returnValue = formDialog.returnValue;
      const dialogWasConfirmed = (formDialog.dataset.confirmed === "true");
      let inputElementValueHasChanged;
      if (dialogWasConfirmed) {
        inputElementValueHasChanged = (returnValue === formDialog.dataset.initialInputElementValue) ? false : true;
      }
      else {
        inputElementValueHasChanged = false;
      }
      formCloseHandler(returnValue, dialogWasConfirmed, inputElementValueHasChanged);
    }
    
    // remove dialog-was-confirmed marker
    delete formDialog.dataset.confirmed;
  });
};

const myFormDialogCloseHandler = function(returnValue, dialogWasConfirmed, inputElementValueHasChanged) {
  const resultDebugOutput = document.getElementById('output-result');
  const resultDebugEntryString = `<pre>dialog confirmed?    ${dialogWasConfirmed}
input value changed? ${inputElementValueHasChanged}
returnValue:         "${returnValue}"</pre>`;
  resultDebugOutput.insertAdjacentHTML('beforeend', resultDebugEntryString);
};

const informationalDialog = document.getElementById('dialog-informational');
initializeDialog(informationalDialog);

const showDialogInformationalButton = document.getElementById('button-show-dialog-informational');
showDialogInformationalButton.addEventListener('click', () => informationalDialog.showModal());

const formDialog = document.getElementById('dialog-form');
initializeDialog(formDialog);
initializeFormDialog(formDialog, myFormDialogCloseHandler);

const showDialogFormButton = document.getElementById('button-show-dialog-form');
showDialogFormButton.addEventListener('click', () => {
  formDialog.showModal();
});
dialog {
  /* for demonstrational purposes, provide different styles for content, padding, and border */
  background-color: LightSkyBlue;
  border: 2rem solid black;
  /* give padding a color different from content; see https://mcmap.net/q/173296/-can-i-add-background-color-only-for-padding */
  padding: 1rem;
  box-shadow: inset 0 0 0 1rem LightGreen;
}
dialog header {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  align-items: flex-start;
}

dialog header button[data-action-close]::before,
dialog header button[data-action-cancel]::before {
  content: "✕";
}

dialog footer {
  display: flex;
  justify-content: flex-end;
  gap: 1rem;
}
<button id="button-show-dialog-informational" type="button">Show informational dialog</button>
<button id="button-show-dialog-form" type="button">Show dialog with form</button>

<dialog id="dialog-informational">
  <header>
    <strong>Informational dialog header</strong>
    <button aria-labelledby="dialog-close" data-action-close="true"></button>
  </header>
  <div>
    <p>This is the dialog content.</p>
  </div>
  <footer>
    <button id="dialog-close" data-action-close="true">Close dialog</button>
  </footer>
</dialog>

<dialog id="dialog-form">
  <form method="dialog">
    <header>
      <strong>Dialog with form</strong> 
      <button aria-labelledby="dialog-form-cancel" data-action-cancel="true" value="cancel-header"></button>
    </header>
    <div>
      <p>This is the dialog content.</p>
      <label for="free-text-input">Text input</label>
      <input type="text" id="free-text-input" name="free-text-input" />
    </div>
    <footer>
      <button id="dialog-form-cancel" value="cancel-footer">Cancel</button>
      <button type="submit" id="dialog-form-confirm" value="initial value">Confirm</button>
    </footer>
  </form>
</dialog>

<div id="output-result"></div>
Cant answered 17/4, 2023 at 8:55 Comment(0)
F
1

The below code will automatically apply the desired functionality to all dialog elements on the page.

HTMLDialogElement.prototype.triggerShow = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = function() {
    this.triggerShow();
    this.onclick = event => {
        let rect = this.getBoundingClientRect();
        if(event.clientY < rect.top || event.clientY > rect.bottom) return this.close();
        if(event.clientX < rect.left || event.clientX > rect.right) return this.close();
    }
}
Flake answered 16/10, 2023 at 9:13 Comment(0)
S
0

A summary of the provided answers

  • Add a click event listener in the dialog to close it, but wrap its contents in a div with a click event listener that calls to event.stopPropagation()). Problem: You have to alter the html structure and potentially the CSS rules you might have. Link
  • Get the coordinates of the dialog with e.target.getBoundingClientRect() and compare them with the coordinates of the click event. Problem: You have to also check that you, in fact, clicked in a dialog element with e.target.tagName to prevent issues with forms. Link
  • Wrap the contents of the dialog in a div with an id and make sure that event.target.id !== 'my-div-id' before closing. Problem: It won't work if you have anything nested in that div unless everything within also has the same id. Link
  • Add an id to the dialog element itself and make sure that event.target.id === 'my-dialog-id'. This way you don't have to wrap its content in a redundant div. Problem: You have to add an id to the dialog just to make this work. Link

My answer

The solution I liked the most is the last one I mentioned above, but you don't really have to add an id attribute because you might as well check that the element instance is exactly the same, like so:

myDialog.addEventListener('click', event => {
    if(event.target === myDialog) {
        myDialog.close();
    }
});

Advantages:

  • No need to add more html elements or attributes anywhere
  • The only one element that adds a listener is the dialog itself
  • Its also the simplest solution
Superadd answered 14/3, 2024 at 18:7 Comment(2)
Just a clarification – you don't need to add an id to make my solution above work. I put that there simply as a clear way to identify which element was getting the event listener. Nevertheless, I don't see adding an id as a "problem", just as one way to identify the target element. It's not clear from your answer how you're accessing the myDialog element, but you'd still have to do something with a querySelector to get it, whether that's by id or some other attribute.Reck
I usually go with checking target against currentTarget: dialog.addEventListener( 'click', event => { if ( event.target === event.currentTarget ) dialog.close() });Whyte

© 2022 - 2025 — McMap. All rights reserved.