<modal-toggle> + <modal-content>
Wrapper components that make working with the <dialog>
element a bit easier.
Basic Usage
Wrap a <dialog>
element in the <modal-content>
component.
If you add a [close]
attribute to any <button>
element in the component, activating the button will automatically close the <dialog>
element.
<modal-content id="greeting">
<dialog>
<p>Hey there!</p>
<button close>Close</button>
</dialog>
</modal-content>
To open the modal, you can run the open()
method on your custom element.
let modal = document.querySelector('modal-content');
modal.open();
You can also wrap any button or link in a <modal-toggle>
custom element to turn it into a toggle for your modal.
Include a [target]
attribute, and provide a selector for the modal to open as its value. If you include a [hidden]
attribute, the toggle will automatically become visible after the JavaScript has instantiated.
<modal-toggle target="#greeting" hidden>
<button>Open the Modal</button>
</modal-toggle>
Modal Controls
If you set the method
to dialog
on a <form>
element inside a <dialog>
, the form will automatically close the <dialog>
element instead of submitting.
<modal-content>
<dialog>
<form method="dialog">
<button>Close</button>
</form>
</dialog>
</modal-content>
If you include a value
on the submit button, it gets assigned to the .returnValue
property on the <dialog>
element.
In this example, clicking [close]
button results in an empty string (''
) for a .returnValue
, while clicking the Ok
button results in a returnValue
of 'ok'
.
<modal-content>
<dialog>
<form method="dialog">
<button close>Cancel</button>
<button value="ok">Ok</button>
</form>
</dialog>
</modal-content>
If you include a [modal-value]
attribute on any form field, the <modal-content>
component will automatically update the .returnValue
when the value of that field changes.
<modal-content>
<dialog>
<form method="dialog">
<label for="wizard">Who is your favorite wizard?</label>
<input type="text" id="wizard" name="wizard" modal-value>
<button close>Cancel</button>
<button>Submit</button>
</form>
</dialog>
</modal-content>
You can detect when modals are opened, closed, and cancelled, and get the the .returnValue
, using one of the custom modal events.
Events
The <modal-content>
component emits a few custom events to let you detect user interactions and extend it’s functionality beyond what it offers out-of-the-box.
modal:open
is emitted when the modal is opened.modal:cancel
is emitted when the user clicks a[close]
button, submits a[method="dialog"]
form with no.returnValue
, or closes the modal with the Escape key.modal:submit
is emitted when the modal has a.returnValue
. Theevent.detail
property is.returnValue
value.
All events are emitted on the <modal-content>
element, and bubble so that you can use event delegation.
You can listen for events with the Element.addEventListener()
method.
document.addEventListener('modal:submit', function (event) {
// The `<modal-content>` element
console.log(event.target);
// The .returnValue property
console.log(event.detail);
});
Styling
The browser automatically adds an [open]
attribute to the <dialog>
element when it’s visible. You can hook into it for styling if desired.
dialog[open] {
/* Style your open modal*/
}
You can use the dialog::backdrop
pseudo-element to style the overlay that covers the page content when a modal is open.
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
The <modal-content>
element adds a [modal-open]
attribute to the body
when a modal is open, and removes it when the modal is closed.
You can hook into this attribute to prevent the page behind the modal from scrolling.
[modal-open] {
overflow: hidden;
}
Ajax Modals
You can use <modal-toggle>
and <modal-content>
to create progressively enhanced modals that load HTML from another webpage.
First, include a link to your external HTML as a fallback if JavaScript isn’t supported or is not yet available. Do not use the [hidden]
attribute in this case.
<modal-toggle target="#tos">
<a href="/terms-of-service">Terms of Service</a>
</modal-toggle>
Next, create your <modal-content>
with some sort of “loading” indicator.
<modal-content id="tos">
<p>Loading...</p>
</modal-content>
Finally, run a modal:open
event listener on your #tos
component the first time it opens using the once
option with the addEventListener()
method.
Use the fetch()
method to asynchronously get the HTML. Then, parse it to a text string, and inject it into the modal.
// Get the #tos <modal-content>
let tos = document.querySelector('#tos');
// The first time it opens, asynchronously load HTML
tos.addEventListener('modal:open', async function (event) {
// Fetch external HTML file
let response = await fetch(event.target.href);
// Parse the response
let html = await response.text();
// Inject the HTML into the modal
tos.innerHTML = html;
}, {once: true});
The Web Components
<modal-toggle>
customElements.define('modal-toggle', class extends HTMLElement {
/**
* Instantiate the component
*/
constructor () {
// Inherit parent class properties
super();
// Set properties
this.modal = document.querySelector(this.getAttribute('target'));
this.toggle = this.querySelector('button, a');
this.handler = this.createHandler();
// Show button
this.removeAttribute('hidden');
// If toggle is a link, add proper role
if (this.toggle.matches('a')) {
this.toggle.setAttribute('role', 'button');
}
}
/**
* Create the event handler function
* @return {Function} The handler function
*/
createHandler () {
return (event) => {
event.preventDefault();
if (!this.modal) return;
this.modal.open();
};
}
/**
* Add event listener
*/
connectedCallback () {
this.toggle.addEventListener('click', this.handler);
}
/**
* Remove event listener
*/
disconnectedCallback () {
this.toggle.removeEventListener('click', this.handler);
}
});
<modal-content>
customElements.define('modal-content', class extends HTMLElement {
/**
* Instantiate the component
*/
constructor () {
// Inherit parent class properties
super();
// Set properties
this.modal = this.querySelector('dialog');
this.form = this.querySelector('[method="dialog"]');
this.cancelling = false;
this.clickHandler = this.createClickHandler();
this.closeHandler = this.createCloseHandler();
this.cancelHandler = this.createCancelHandler();
this.inputHandler = this.createInputHandler();
// Setup the DOM
this.setup();
}
/**
* Prevent form buttons from submitting
*/
setup () {
let btns = this.querySelectorAll('form [close]');
for (let btn of btns) {
btn.setAttribute('type', 'button');
}
}
/**
* Create the click handler callback function
* @return {Function} The callback function
*/
createClickHandler () {
return (event) => {
if (!event.target.closest('[close]')) return;
this.modal.returnValue = '';
this.close('cancel', true);
};
}
/**
* Create the close handler callback function
* @return {Function} The callback function
*/
createCloseHandler () {
return (event) => {
if (this.cancelling) {
this.cancelling = false;
return;
}
this.close(this.modal.returnValue ? 'submit' : 'cancel');
};
}
/**
* Create the cancel handler callback function
* @return {Function} The callback function
*/
createCancelHandler () {
return (event) => {
this.close('cancel', true);
};
}
/**
* Create the input handler callback function
* @return {Function} The callback function
*/
createInputHandler () {
return (event) => {
if (!event.target.matches('[modal-value]')) return;
this.modal.returnValue = event.target.value || '';
};
}
/**
* Attach event listeners
*/
connectedCallback () {
if (!this.modal) return;
this.modal.addEventListener('click', this.clickHandler);
this.modal.addEventListener('close', this.closeHandler);
this.modal.addEventListener('cancel', this.cancelHandler);
if (!this.form) return;
this.modal.addEventListener('input', this.inputHandler);
}
/**
* Remove event listeners
*/
disconnectedCallback () {
if (!this.modal) return;
this.modal.removeEventListener('click', this.clickHandler);
this.modal.removeEventListener('close', this.closeHandler);
this.modal.removeEventListener('cancel', this.cancelHandler);
this.modal.removeEventListener('input', this.inputHandler);
}
/**
* Emit a custom event
* @param {String} type The event type name
*/
emit (type) {
// Set the event detail
let detail = type === 'submit' ? this.modal.returnValue : null;
// Create a new event
let event = new CustomEvent(`modal:${type}`, {
bubbles: true,
detail
});
// Dispatch the event
return this.dispatchEvent(event);
}
/**
* Open the modal
*/
open () {
// If there's no modal, bail
if (!this.modal) return;
// Show the modal
this.modal.showModal();
// Add styling hook to body
document.body.setAttribute('modal-open', '');
// Emit an open event
this.emit('open');
}
/**
* Close the modal
* @param {string} type The close type
* @param {boolean} cancel If true, temporary set cancel state
*/
close (type, cancel = false) {
// If there's no modal, bail
if (!this.modal) return;
// Remove the style hook from the body
document.body.removeAttribute('modal-open');
// Temporarily set cancel state
this.cancelling = cancel;
// Close the modal
this.modal.close();
// Emit a close event
this.emit(type);
}
});