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

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