better a11y for modal: retain focus when closed

This commit is contained in:
Nolan Lawson 2018-02-04 13:49:43 -08:00
parent 27f15d4030
commit 2e2c278dee
4 changed files with 153 additions and 73 deletions

View file

@ -0,0 +1,87 @@
<div class="dialog-wrapper" ref:node>
<div class="close-dialog-button-wrapper">
<button class="close-dialog-button" aria-label="Close dialog" on:click="onCloseButtonClicked()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<slot></slot>
</div>
<style>
:global(.modal-dialog) {
position: fixed;
top: 50%;
transform: translate(0, -50%);
background: #000;
padding: 0;
border: 3px solid var(--main-border);
}
:global(.modal-dialog-wrapper) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: calc(100vw - 40px);
}
.close-dialog-button-wrapper {
text-align: right;
width: 100%;
background: var(--nav-bg)
}
.close-dialog-button {
margin: 0 0 2px;
padding: 0;
background: none;
border: none;
}
.close-dialog-button span {
padding: 0 15px;
font-size: 48px;
color: var(--button-primary-text);
}
:global(dialog.modal-dialog::backdrop) {
background: rgba(51, 51, 51, 0.8);
}
:global(dialog.modal-dialog + .backdrop) {
background: rgba(51, 51, 51, 0.8);
}
</style>
<script>
import { importDialogPolyfill } from '../_utils/asyncModules'
import { registerFocusRestoreDialog } from '../_utils/dialogs'
export default {
oncreate() {
// TODO: this hack is for Edge 16, which makes the modal too wide
if (typeof setImmediate === 'function' && navigator.userAgent.match(/Edge/)) {
this.getDialogElement().style.width = `${this.get('width')}px`
}
this.observe('shown', shown => {
if (shown) {
this.show()
}
})
this.registration = this.register()
},
methods: {
async register() {
if (typeof HTMLDialogElement === 'undefined') {
let dialogPolyfill = await importDialogPolyfill()
dialogPolyfill.registerDialog(this.getDialogElement())
}
registerFocusRestoreDialog(this.getDialogElement())
},
async show() {
await this.registration
this.getDialogElement().showModal()
},
onCloseButtonClicked() {
this.getDialogElement().close()
document.body.removeChild(this.getDialogElement())
},
getDialogElement() {
return this.refs.node.parentElement
}
}
}
</script>

View file

@ -1,9 +1,4 @@
<div class="video-dialog-wrapper"> <ModalDialog :shown>
<div class="close-video-button-wrapper">
<button class="close-video-button" aria-label="Close dialog" on:click="onCloseButtonClicked()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<video poster="{{poster}}" <video poster="{{poster}}"
src="{{src}}" src="{{src}}"
width="{{width}}" width="{{width}}"
@ -11,75 +6,22 @@
aria-label="Video: {{description || ''}}" aria-label="Video: {{description || ''}}"
controls controls
/> />
</div> </ModalDialog>
<style> <style>
:global(.video-dialog) { :global(.modal-dialog video) {
position: fixed;
top: 50%;
transform: translate(0, -50%);
background: #000;
padding: 0;
border: 3px solid var(--main-border);
}
:global(.video-dialog-wrapper) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: calc(100vw - 40px);
}
:global(.video-dialog video) {
max-width: 100%; max-width: 100%;
} }
.close-video-button-wrapper {
text-align: right;
width: 100%;
background: var(--nav-bg)
}
.close-video-button {
margin: 0 0 2px;
padding: 0;
background: none;
border: none;
}
.close-video-button span {
padding: 0 15px;
font-size: 48px;
color: var(--button-primary-text);
}
:global(dialog.video-dialog::backdrop) {
background: rgba(51, 51, 51, 0.8);
}
:global(dialog.video-dialog + .backdrop) {
background: rgba(51, 51, 51, 0.8);
}
</style> </style>
<script> <script>
import ModalDialog from '../ModalDialog.html'
import { importDialogPolyfill } from '../../_utils/asyncModules'
export default { export default {
oncreate() { components: {
// TODO: this hack is for Edge 16, which makes the modal too wide ModalDialog
if (typeof setImmediate === 'function' && navigator.userAgent.match(/Edge/)) {
this.get('dialog').style.width = `${this.get('width')}px`
}
this.registration = this.register()
}, },
methods: { methods: {
async register() { async show() {
if (typeof HTMLDialogElement === 'undefined') { this.set({shown: true})
let dialogPolyfill = await importDialogPolyfill()
dialogPolyfill.registerDialog(this.get('dialog'))
}
},
async showModal() {
await this.registration
this.get('dialog').showModal()
},
onCloseButtonClicked() {
this.get('dialog').close()
document.body.removeChild(this.get('dialog'))
} }
} }
} }

55
routes/_utils/dialogs.js Normal file
View file

@ -0,0 +1,55 @@
// From https://gist.github.com/samthor/babe9fad4a65625b301ba482dad284d1
// Via https://github.com/GoogleChrome/dialog-polyfill/issues/139
let registered = new WeakMap()
// store previous focused node centrally
let previousFocus = null
document.addEventListener('focusout', (e) => {
previousFocus = e.target
}, true)
/**
* Updates the passed dialog to retain focus and restore it when the dialog is closed.
* @param {!HTMLDialogElement} dialog to upgrade
*/
export function registerFocusRestoreDialog(dialog) {
// replace showModal method directly, to save focus
let realShowModal = dialog.showModal
dialog.showModal = function () {
let savedFocus = document.activeElement
if (savedFocus === document || savedFocus === document.body) {
// some browsers read activeElement as body
savedFocus = previousFocus
}
registered.set(dialog, savedFocus)
realShowModal.call(this)
}
// on close, try to focus saved, if possible
dialog.addEventListener('close', function () {
if (dialog.hasAttribute('open')) {
return // in native, this fires the frame later
}
let savedFocus = registered.get(dialog)
if (document.contains(savedFocus)) {
let wasFocus = document.activeElement
savedFocus.focus()
if (document.activeElement !== savedFocus) {
wasFocus.focus() // restore focus, we couldn't focus saved
}
}
savedFocus = null
})
}
export function createDialogElement(label) {
if (!label) {
throw new Error('the modal must have a label')
}
let dialogElement = document.createElement('dialog')
dialogElement.classList.add('modal-dialog')
dialogElement.setAttribute('aria-label', label)
document.body.appendChild(dialogElement)
return dialogElement
}

View file

@ -1,20 +1,16 @@
import VideoDialog from '../_components/status/VideoDialog.html' import VideoDialog from '../_components/status/VideoDialog.html'
import { createDialogElement } from './dialogs'
export function showVideoDialog(poster, src, width, height, description) { export function showVideoDialog(poster, src, width, height, description) {
let dialog = document.createElement('dialog')
dialog.classList.add('video-dialog')
dialog.setAttribute('aria-label', 'Video dialog')
document.body.appendChild(dialog)
let videoDialog = new VideoDialog({ let videoDialog = new VideoDialog({
target: dialog, target: createDialogElement('Video dialog'),
data: { data: {
poster: poster, poster: poster,
src: src, src: src,
dialog: dialog,
width: width, width: width,
height: height, height: height,
description: description description: description
} }
}) })
videoDialog.showModal() videoDialog.show()
} }