14

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.

djvg
  • 11,722
  • 5
  • 72
  • 103

5 Answers5

15

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>
Gaël
  • 151
  • 1
  • 5
  • 6
    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. – Leonardo Raele Dec 29 '22 at 20:57
10

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();
}
Josh
  • 874
  • 7
  • 8
  • Can you go into more detail on how clickedInDialog works? – Royer Adames Aug 12 '23 at 00:52
  • 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 js – Royer Adames Aug 12 '23 at 01:16
  • its a bit hard to read and for that reason I prever the wrapping div solution – Royer Adames Aug 12 '23 at 01:33
  • 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 easy – Royer Adames Aug 12 '23 at 01:34
5

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.

djvg
  • 11,722
  • 5
  • 72
  • 103
  • 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? – mindplay.dk Aug 22 '22 at 08:19
  • @mindplay.dk 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. – djvg Aug 22 '22 at 08:48
  • 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. – Harald Oct 02 '22 at 09:30
0

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://stackoverflow.com/a/35252091/923560 */
  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>
Abdull
  • 26,371
  • 26
  • 130
  • 172
-1

Well, code it the same way you speak it. If you click on an element that isn't the desired dialog, close the dialog. Here is an example:

<div id="content">

  <div id="dialog" class="dialogComponent">

      <div id="foo" class="dialogComponent">
        test 123 123 123 123 
        <input class="dialogComponent class2" type="text">
      </div>
      <button class="dialogComponent">Submit</button>
  </div>

</div>



#content { width: 100%; height: 333px; background-color: black;}
#dialog { margin: 33px;  background-color: blue; }


$('#content').click(function(e) {
    if (!e.target.classList.contains("dialogComponent"))
    alert('Closing Dialog');
});

https://jsfiddle.net/scd9mwk7/

Airwavezx
  • 898
  • 5
  • 14
  • 26