better a11y for modal: retain focus when closed
This commit is contained in:
		
							parent
							
								
									27f15d4030
								
							
						
					
					
						commit
						2e2c278dee
					
				
					 4 changed files with 153 additions and 73 deletions
				
			
		
							
								
								
									
										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">
 | 
			
		||||
  <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>
 | 
			
		||||
<ModalDialog :shown>
 | 
			
		||||
  <video poster="{{poster}}"
 | 
			
		||||
         src="{{src}}"
 | 
			
		||||
         width="{{width}}"
 | 
			
		||||
| 
						 | 
				
			
			@ -11,75 +6,22 @@
 | 
			
		|||
         aria-label="Video: {{description || ''}}"
 | 
			
		||||
         controls
 | 
			
		||||
  />
 | 
			
		||||
</div>
 | 
			
		||||
</ModalDialog>
 | 
			
		||||
<style>
 | 
			
		||||
  :global(.video-dialog) {
 | 
			
		||||
    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) {
 | 
			
		||||
  :global(.modal-dialog video) {
 | 
			
		||||
    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>
 | 
			
		||||
<script>
 | 
			
		||||
 | 
			
		||||
  import { importDialogPolyfill } from '../../_utils/asyncModules'
 | 
			
		||||
  import ModalDialog from '../ModalDialog.html'
 | 
			
		||||
 | 
			
		||||
  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.get('dialog').style.width = `${this.get('width')}px`
 | 
			
		||||
      }
 | 
			
		||||
      this.registration = this.register()
 | 
			
		||||
    components: {
 | 
			
		||||
      ModalDialog
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
      async register() {
 | 
			
		||||
        if (typeof HTMLDialogElement === 'undefined') {
 | 
			
		||||
          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'))
 | 
			
		||||
      async show() {
 | 
			
		||||
        this.set({shown: true})
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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 { createDialogElement } from './dialogs'
 | 
			
		||||
 | 
			
		||||
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({
 | 
			
		||||
    target: dialog,
 | 
			
		||||
    target: createDialogElement('Video dialog'),
 | 
			
		||||
    data: {
 | 
			
		||||
      poster: poster,
 | 
			
		||||
      src: src,
 | 
			
		||||
      dialog: dialog,
 | 
			
		||||
      width: width,
 | 
			
		||||
      height: height,
 | 
			
		||||
      description: description
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
  videoDialog.showModal()
 | 
			
		||||
  videoDialog.show()
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		
		Reference in a new issue