Improved a11y of dialogs
authorMarcel Werk <burntime@woltlab.com>
Sun, 9 Sep 2018 18:42:12 +0000 (20:42 +0200)
committerMarcel Werk <burntime@woltlab.com>
Sun, 9 Sep 2018 18:42:12 +0000 (20:42 +0200)
See #2713

com.woltlab.wcf/templates/pageHeaderUser.tpl
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog.js

index 60796f72a9932e3a80ee07417f736bbebc61e5f1..5cd367eca1f5e2b2b43d028492614dadb7b2562f 100644 (file)
                                                                <dl>
                                                                        <dt><label for="username">{lang}wcf.user.usernameOrEmail{/lang}</label></dt>
                                                                        <dd>
-                                                                               <input type="text" id="username" name="username" value="" required class="long jsDialogAutoFocus" autocomplete="username">
+                                                                               <input type="text" id="username" name="username" value="" required class="long" autocomplete="username">
                                                                        </dd>
                                                                </dl>
                                                                
index 9c4777563995d117894b68c026efc398fdf65fe0..2aa1158a4614e627b977fd0f7445efb58aef6fba 100644 (file)
@@ -23,11 +23,13 @@ define(
        "use strict";
        
        var _activeDialog = null;
+       var _callbackFocus = null;
        var _container = null;
        var _dialogs = new Dictionary();
        var _dialogFullHeight = false;
        var _dialogObjects = new ObjectMap();
        var _dialogToObject = new Dictionary();
+       var _focusedBeforeDialog = null;
        var _keyupListener = null;
        var _staticDialogs = elByClass('jsStaticDialog');
        var _validCallbacks = ['onBeforeClose', 'onClose', 'onShow'];
@@ -35,6 +37,20 @@ define(
        // list of supported `input[type]` values for dialog submit
        var _validInputTypes = ['number', 'password', 'search', 'tel', 'text', 'url'];
        
+       var _focusableElements = [
+               'a[href]:not([tabindex^="-"]):not([inert])',
+               'area[href]:not([tabindex^="-"]):not([inert])',
+               'input:not([disabled]):not([inert])',
+               'select:not([disabled]):not([inert])',
+               'textarea:not([disabled]):not([inert])',
+               'button:not([disabled]):not([inert])',
+               'iframe:not([tabindex^="-"]):not([inert])',
+               'audio:not([tabindex^="-"]):not([inert])',
+               'video:not([tabindex^="-"]):not([inert])',
+               '[contenteditable]:not([tabindex^="-"]):not([inert])',
+               '[tabindex]:not([tabindex^="-"]):not([inert])'
+       ];
+       
        /**
         * @exports     WoltLabSuite/Core/Ui/Dialog
         */
@@ -361,6 +377,8 @@ define(
                        if (options.closable) {
                                var closeButton = elCreate('a');
                                closeButton.className = 'dialogCloseButton jsTooltip';
+                               elAttr(closeButton, 'role', 'button');
+                               elAttr(closeButton, 'tabindex', '0');
                                elAttr(closeButton, 'title', options.closeButtonLabel);
                                elAttr(closeButton, 'aria-label', options.closeButtonLabel);
                                closeButton.addEventListener(WCF_CLICK_EVENT, this._close.bind(this));
@@ -488,6 +506,11 @@ define(
                        }
                        
                        if (elAttr(data.dialog, 'aria-hidden') === 'true') {
+                               if (_callbackFocus === null) {
+                                       _callbackFocus = this._maintainFocus.bind(this);
+                                       document.body.addEventListener('focus', _callbackFocus, { capture: true });
+                               }
+                               
                                if (data.closable && elAttr(_container, 'aria-hidden') === 'true') {
                                        window.addEventListener('keyup', _keyupListener);
                                }
@@ -497,18 +520,14 @@ define(
                                elData(_container, 'close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
                                _activeDialog = id;
                                
-                               // set focus on first applicable element
-                               var focusElement = elBySel('.jsDialogAutoFocus', data.dialog);
-                               if (focusElement !== null && focusElement.offsetParent !== null) {
-                                       if (focusElement.id === 'username' || focusElement.name === 'username') {
-                                               if (Environment.browser() === 'safari' && Environment.platform() === 'ios') {
-                                                       // iOS Safari's username/password autofill breaks if the input field is focused 
-                                                       focusElement = null;
-                                               }
-                                       }
-                                       
-                                       if (focusElement) focusElement.focus();
-                               }
+                               // Keep a reference to the currently focused element to be able to restore it later.
+                               _focusedBeforeDialog = document.activeElement;
+                               
+                               // Set the focus to the first focusable child of the dialog element.
+                               var closeButton = elBySel('.dialogCloseButton', data.header);
+                               if (closeButton) elAttr(closeButton, 'inert', true);
+                               this._setFocusToFirstItem(data.dialog);
+                               if (closeButton) closeButton.removeAttribute('inert');
                                
                                if (typeof data.onShow === 'function') {
                                        data.onShow(data.content);
@@ -531,6 +550,53 @@ define(
                        DomChangeListener.trigger();
                },
                
+               /**
+                * @param {Event} event
+                */
+               _maintainFocus: function(event) {
+                       if (_activeDialog) {
+                               var data = _dialogs.get(_activeDialog);
+                               if (!data.dialog.contains(event.target) && !event.target.closest('.dropdownMenuContainer')) {
+                                       this._setFocusToFirstItem(data.dialog, true);
+                               }
+                       }
+               },
+               
+               /**
+                * @param {Element} dialog
+                * @param {boolean} maintain
+                */
+               _setFocusToFirstItem: function(dialog, maintain) {
+                       var focusElement = this._getFirstFocusableChild(dialog);
+                       if (focusElement !== null) {
+                               if (maintain) {
+                                       if (focusElement.id === 'username' || focusElement.name === 'username') {
+                                               if (Environment.browser() === 'safari' && Environment.platform() === 'ios') {
+                                                       // iOS Safari's username/password autofill breaks if the input field is focused 
+                                                       focusElement = null;
+                                               }
+                                       }
+                               }
+                               
+                               if (focusElement) focusElement.focus();
+                       }
+               },
+               
+               /**
+                * @param {Element} node
+                * @returns {?Element}
+                */
+               _getFirstFocusableChild: function(node) {
+                       var nodeList = elBySelAll(_focusableElements.join(','), node);
+                       for (var i = 0, length = nodeList.length; i < length; i++) {
+                               if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
+                                       return nodeList[i];
+                               }
+                       }
+                       
+                       return null;    
+               },
+               
                /**
                 * Rebuilds dialog identified by given id.
                 *