better a11y for modal: retain focus when closed
This commit is contained in:
parent
27f15d4030
commit
2e2c278dee
87
routes/_components/ModalDialog.html
Normal file
87
routes/_components/ModalDialog.html
Normal 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">×</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>
|
|
@ -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">×</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
55
routes/_utils/dialogs.js
Normal 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
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
Loading…
Reference in a new issue