Skip to content

Instantly share code, notes, and snippets.

@amcgregor
Last active May 11, 2024 18:02
Show Gist options
  • Save amcgregor/f3529394f032ae517b0a02a0edbac7f7 to your computer and use it in GitHub Desktop.
Save amcgregor/f3529394f032ae517b0a02a0edbac7f7 to your computer and use it in GitHub Desktop.
A fairly complete HTML5 dialog utility implementation with a few "advanced" features, with accessibility in mind. Utilizing https://github.com/GoogleChrome/dialog-polyfill as a polyfill for ancient browsers, omit the first block (and polyfill) if Internet Explorer isn't your bag, baby.
// Present dialog content "lazily loaded" from a dedicated endpoint.
// Additionally, support automatic transformation of navigational links to modal popovers when annotated: rel=modal
// Any element annotated data-dismiss=modal will close the active dialog when clicked.
// Clicking the backdrop will automatically close the dialog when no form is present [or the form is unmodified].
// MIT Licensed: http://www.opensource.org/licenses/mit-license.php
// Copyright (c) 2021-2022, Alice Bevan-McGregor (alice -[at]- gothcandy [*dot*] com)
if ( !document.getElementById("dialog") ) { // Populate the bare dialog element if missing.
let dialog = document.createElement("div")
dialog.id = 'dialog'
document.body.append(dialog)
}
if ( window.hasOwnProperty('dialogPolyfill') ) // Polyfill if required to support your browser needs.
dialogPolyfill.registerDialog(dialog) // Direct access to the DOM node as it is assigned to the window object by ID, ref: support.html
{
function showModal(uri, trigger=undefined) { // Display a modal dialog whose contents are retrieved from a target URI.
// Dynamically fires an event if a trigger value is passed in.
if ( dialog.open ) dialog.close() // Close the modal dialog if already visible; its contents will be lost!
fetch(uri, {"headers": {
"Accept": 'text/html+modal; text/html+fragment; text/html',
"X-Requested-With": 'XMLHttpRequest' // We pretend for the sake of server-side code expecting this.
}}).then(response => {
if ( !response.ok ) throw new Error(`Failed to request modal content from: ${uri}`)
return response.text()
}).then(content => {
dialog.innerHTML = content // Yes, this is a little gross. Y u no DOMParser? T_T
dialog.show()
// It is preferred to use event bubbling, where possible, to handle events on dynamic elements.
if ( trigger ) // Fire a custom event in the 'shown' class.
dialog.dispatchEvent(new CustomEvent('shown', {bubbles: true, detail: {trigger: trigger}}))
}).catch(e => {
console.error("Modal dialog request failure.", e)
})
}
// This diagnostic aid can be removed for production use.
dialog.addEventListener('shown', e => console.debug("Modal dialog shown.", e))
document.body.addEventListener('click', e => { // Automatic presentation as modal; non-standard attribute use.
if ( !e.target.matches('a[rel=modal]') ) return
e.preventDefault()
e.stopPropagation()
if ( e.target.matches('[disabled]') ) return
showModal(e.target.href, e.target.dataset.trigger)
})
document.body.addEventListener('click', e => { // Automatically close the dialog when clicking marked elements.
if ( !e.target.matches('[data-dismiss=modal]') ) return
e.preventDefault()
e.stopPropagation()
if ( e.target.matches('[disabled]') ) return // Can remove this if there is a global handler.
if ( dialog.open ) dialog.close()
})
dialog.addEventListener('click', function (event) {
if ( dialog.querySelector('form') ) return // We skip forms, to prevent data loss.
let rect = dialog.getBoundingClientRect()
let inDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height
&& rect.left <= event.clientX && event.clientX <= rect.left + rect.width)
if ( inDialog ) return
// TODO: Check for form alteration.
// new URLSearchParams(new FormData(document.querySelector('#modal form'))).toString()
const event = new Event('cancel')
dialog.close()
dialog.dispatchEvent(event) // Trigger a cancel event, as per default behavior of pressing escape.
})
}
<!-- An example HTML fragment used as a modal dialog. -->
<header><h3>Dialog Title</h3></header>
<section> <!-- Or a <form>, or… -->
<!-- What non-semantic "frameworks" might call a div.dialog-body … -->
</section>
<footer>
<!-- This is where the button actions might go. -->
</footer>
<!-- An example HTML fragment used as a modal dialog, with a tab panel look. -->
<header><h3>Dialog Title</h3></header>
<dl role=tablist>
<dt role=tab tabindex=0 aria-selected=true aria-controls=first id=first-tab>First Tab Title
<dd id=first role=tabpanel aria-labelledby=first-tab>
First tab content.
</dd>
<dt role=tab tabindex=0 aria-controls=second id=second-tab>First Tab Title
<dd id=second role=tabpanel aria-labelledby=second-tab hidden>
First tab content.
</dd>
</dl>
<footer>
<!-- This is where the button actions might go. -->
</footer>
<dialog id=dialog></dialog> <!-- Put this at the end of your page content, i.e. before where </body> might go. -->
@amcgregor
Copy link
Author

amcgregor commented Mar 9, 2022

Targeting all dialog content that isn't the header or footer, if you don't care about IE: dialog > *:not(header,footer) (this to flexibly cover use of a <section>, <form>, or <dl role=tablist>)

Fun auto-sizing modal with constraints: min- / max- height / width with width and height of fit-content. See also: aspect-ratio (and you probably want to apply overflow-y: auto to the above "inner content" selector…)

Edited to note that yes, this is using show and not showModal. Modal windows are stateful and require dismissal before control returns to the parent context. More than one modal should not be possible to have visible simultaneously, so the above solution only has one dialog element which is re-used. Feel free to use either, though. Which you'll want will depend on if you want a backdrop utilized which disables interaction with the rest of the page.

@amcgregor
Copy link
Author

The X-Requested-With header is useful to allow the server-side to identify that this is a fragment request (even if it's Fetch, and not XHR due to framework support for the flag), not actually a whole-page request. Server-side can totally also handle the request for the dialog as a whole-page request, permitting "progressive enhancement" for clients that might have disabled JS support, as well as to support right-click (or ⌘+click) to open in a new tab or window. And bookmarking.

For RESTful purposes, this is also why the Accept header contains:

  • text/html+modal — explicitly request the resource as the contents for a modal <dialog> element.
  • text/html+fragment — fall back on an HTML fragment for injection into a <dialog> element.
  • text/html — what the heck, I'll throw any old HTML in there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment