Reputation: 361
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.
Upvotes: 36
Views: 28424
Reputation: 1
You can solve the problem of closing a native HTML <dialog>
when clicking outside of it by using an approach that calculates the dimensions of the dialog and listens for click events. Here’s a React implementation of a native HTML <dialog>
component styled with Tailwind CSS that includes this functionality:
here is an article : HTML Native Dialog
import React, { useRef, useEffect } from "react";
const HtmlDialog: React.FC = () => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleOutsideClick = (e: MouseEvent) => {
const dialogDimensions = dialog.getBoundingClientRect();
if (
e.clientX < dialogDimensions.left ||
e.clientX > dialogDimensions.right ||
e.clientY < dialogDimensions.top ||
e.clientY > dialogDimensions.bottom
) {
dialog.close();
}
};
dialog.addEventListener("click", handleOutsideClick);
return () => {
dialog.removeEventListener("click", handleOutsideClick);
};
}, []);
return (
<div className="flex flex-col items-center py-12">
<button
type="button"
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
onClick={() => dialogRef.current?.showModal()}
>
Open Dialog
</button>
<dialog
ref={dialogRef}
className="bg-white rounded shadow-lg p-6 open:animate-fade-in"
>
<div className="flex flex-col items-center gap-4">
<h1 className="text-2xl font-bold">Dialog</h1>
<p>This is an interactive dialog box.</p>
<button
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
onClick={() => dialogRef.current?.close()}
>
Close
</button>
</div>
</dialog>
</div>
);
};
export default HtmlDialog;
Explanation:
dialog.showModal():
Opens the dialog in modal mode, blocking interaction with the rest of the page
Closing Logic:
The useEffect
hook adds an event listener to the dialog, checking if the click occurred outside the dialog’s boundaries using getBoundingClientRect
.
If it does, the dialog closes using dialog.close()
.
Styling with Tailwind CSS:
Tailwind classes are used for styling and animations. Custom animations can be added via the tailwind.config.js file. Backdrops:
Tailwind’s backdrop utilities are used to style the modal’s background. This is a modern and efficient way to handle native HTML components in React. You can check out my Medium article : HTML Native Dialog and Component in React with Tailwind CSS for a more detailed explanation and additional features.
Upvotes: 0
Reputation: 333
You can add the attribute popover
to the dialog element. This enables you to use the popover API, which has native support for detecting clicks outside. You can also use the popover API with any HTML element too!
Upvotes: 0
Reputation: 1999
event.stopPropagation())
. Problem: You have to alter the html structure and potentially the CSS rules you might have. Linke.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. Linkevent.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. Linkevent.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. LinkThe 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:
Upvotes: 2
Reputation: 117
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
Upvotes: 3
Reputation: 323
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();
}
}
Upvotes: 2
Reputation: 27832
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>
Upvotes: 1
Reputation: 14255
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
.
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.
Upvotes: 9
Reputation: 391
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:
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)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>
Upvotes: 28
Reputation: 924
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();
}
Upvotes: 13