Ver código fonte

Accessibility for message & message-box & tabs (#7424)

* 	add accessibility for message & message-box & tabs

* Update message-box.md
maranran 7 anos atrás
pai
commit
542deb779b

+ 50 - 14
packages/message-box/src/main.vue

@@ -1,14 +1,27 @@
 <template>
   <transition name="msgbox-fade">
-    <div class="el-message-box__wrapper" tabindex="-1" v-show="visible" @click.self="handleWrapperClick">
+    <div
+      class="el-message-box__wrapper"
+      tabindex="-1"
+      v-show="visible"
+      @click.self="handleWrapperClick"
+      role="dialog"
+      aria-modal="true"
+      :aria-label="title || 'dialog'"
+    >
       <div class="el-message-box" :class="[customClass, center && 'el-message-box--center']">
         <div class="el-message-box__header" v-if="title !== undefined">
           <div class="el-message-box__title">
             <div class="el-message-box__status" :class="[ typeClass ]" v-if="typeClass && center"></div>
             <span>{{ title }}</span>
           </div>
-          <button type="button" class="el-message-box__headerbtn" aria-label="Close" 
-                  v-if="showClose" @click="handleAction('cancel')">
+          <button type="button"
+                  class="el-message-box__headerbtn"
+                  aria-label="Close"
+                  v-if="showClose"
+                  @click="handleAction('cancel')"
+                  @keydown.enter="handleAction('cancel')"
+          >
             <i class="el-message-box__close el-icon-close"></i>
           </button>
         </div>
@@ -32,7 +45,9 @@
             v-show="showCancelButton"
             :round="roundButton"
             size="small"
-            @click.native="handleAction('cancel')">
+            @click.native="handleAction('cancel')"
+            @keydown.enter="handleAction('cancel')"
+          >
             {{ cancelButtonText || t('el.messagebox.cancel') }}
           </el-button>
           <el-button
@@ -42,7 +57,9 @@
             v-show="showConfirmButton"
             :round="roundButton"
             size="small"
-            @click.native="handleAction('confirm')">
+            @click.native="handleAction('confirm')"
+            @keydown.enter="handleAction('confirm')"
+          >
             {{ confirmButtonText || t('el.messagebox.confirm') }}
           </el-button>
         </div>
@@ -58,7 +75,9 @@
   import ElButton from 'element-ui/packages/button';
   import { addClass, removeClass } from 'element-ui/src/utils/dom';
   import { t } from 'element-ui/src/locale';
+  import Dialog from 'element-ui/src/utils/aria-dialog';
 
+  let messageBox;
   let typeMap = {
     success: 'circle-check',
     info: 'information',
@@ -132,7 +151,7 @@
         this._closing = true;
 
         this.onClose && this.onClose();
-
+        messageBox.closeDialog(); // 解绑
         if (this.lockScroll) {
           setTimeout(() => {
             if (this.modal && this.bodyOverflow !== 'hidden') {
@@ -148,7 +167,9 @@
         if (!this.transition) {
           this.doAfterClose();
         }
-        if (this.action) this.callback(this.action, this);
+        setTimeout(() => {
+          if (this.action) this.callback(this.action, this);
+        });
       },
 
       handleWrapperClick() {
@@ -195,6 +216,11 @@
         this.editorErrorMessage = '';
         removeClass(this.$refs.input.$el.querySelector('input'), 'invalid');
         return true;
+      },
+      getFistFocus() {
+        const $btns = this.$el.querySelector('.el-message-box__btns .el-button');
+        const $title = this.$el.querySelector('.el-message-box__btns .el-message-box__title');
+        return $btns && $btns[0] || $title;
       }
     },
 
@@ -211,12 +237,18 @@
       },
 
       visible(val) {
-        if (val) this.uid++;
-        if (this.$type === 'alert' || this.$type === 'confirm') {
-          this.$nextTick(() => {
-            this.$refs.confirm.$el.focus();
-          });
-        }
+        if (val) {
+          this.uid++;
+          if (this.$type === 'alert' || this.$type === 'confirm') {
+            this.$nextTick(() => {
+              this.$refs.confirm.$el.focus();
+            });
+          }
+          this.focusAfterClosed = document.activeElement;
+          messageBox = new Dialog(this.$el, this.focusAfterClosed, this.getFistFocus());
+        };
+
+        // prompt
         if (this.$type !== 'prompt') return;
         if (val) {
           setTimeout(() => {
@@ -241,6 +273,9 @@
       if (this.closeOnHashChange) {
         window.removeEventListener('hashchange', this.close);
       }
+      setTimeout(() => {
+        messageBox.closeDialog();
+      });
     },
 
     data() {
@@ -268,7 +303,8 @@
         cancelButtonClass: '',
         editorErrorMessage: null,
         callback: null,
-        dangerouslyUseHTMLString: false
+        dangerouslyUseHTMLString: false,
+        focusAfterClosed: null
       };
     }
   };

+ 30 - 6
packages/message/src/main.vue

@@ -8,14 +8,16 @@
         customClass]"
       v-show="visible"
       @mouseenter="clearTimer"
-      @mouseleave="startTimer">
+      @mouseleave="startTimer"
+      role="alertdialog"
+    >
       <i :class="iconClass" v-if="iconClass"></i>
       <i :class="typeClass" v-else></i>
       <slot>
-        <p v-if="!dangerouslyUseHTMLString" class="el-message__content">{{ message }}</p>
-        <p v-else v-html="message" class="el-message__content"></p>
+        <p v-if="!dangerouslyUseHTMLString" class="el-message__content"  tabindex="0">{{ message }}</p>
+        <p v-else v-html="message" class="el-message__content"  tabindex="0"></p>
       </slot>
-      <i v-if="showClose" class="el-message__closeBtn el-icon-close" @click="close"></i>
+      <i v-if="showClose" class="el-message__closeBtn el-icon-close" @click="close" tabindex="0" role="button" aria-label="close" @keydown.enter.stop="close"></i>
     </div>
   </transition>
 </template>
@@ -42,7 +44,9 @@
         closed: false,
         timer: null,
         dangerouslyUseHTMLString: false,
-        center: false
+        center: false,
+        initFocus: null,
+        originFocus: null
       };
     },
 
@@ -83,6 +87,7 @@
         if (typeof this.onClose === 'function') {
           this.onClose(this);
         }
+        this.originFocus && this.originFocus.focus(); // 键盘焦点回归
       },
 
       clearTimer() {
@@ -97,11 +102,30 @@
             }
           }, this.duration);
         }
+      },
+      keydown(e) {
+        if (e.keyCode === 46 || e.keyCode === 8) {
+          this.clearTimer(); // detele 取消倒计时
+        } else if (e.keyCode === 27) { // esc关闭消息
+          if (!this.closed) {
+            this.close();
+          }
+        } else {
+          this.startTimer(); // 恢复倒计时
+        }
       }
     },
-
     mounted() {
       this.startTimer();
+      this.originFocus = document.activeElement;
+      this.initFocus = this.showClose ? this.$el.querySelector('.el-icon-close') : this.$el.querySelector('.el-message__content');
+      setTimeout(() => {
+        this.initFocus && this.initFocus.focus();
+      });
+      document.addEventListener('keydown', this.keydown);
+    },
+    beforeDestroy() {
+      document.removeEventListener('keydown', this.keydown);
     }
   };
 </script>

+ 9 - 2
packages/radio/src/radio-button.vue

@@ -4,7 +4,8 @@
     :class="[
       size ? 'el-radio-button--' + size : '',
       { 'is-active': value === label },
-      { 'is-disabled': isDisabled }
+      { 'is-disabled': isDisabled },
+      { 'is-focus': focus }
     ]"
     role="radio"
     :aria-checked="value === label"
@@ -21,6 +22,8 @@
       @change="handleChange"
       :disabled="isDisabled"
       tabindex="-1"
+      @focus="focus = true"
+      @blur="focus = false"
     >
     <span class="el-radio-button__inner" :style="value === label ? activeStyle : null">
       <slot></slot>
@@ -43,7 +46,11 @@
       disabled: Boolean,
       name: String
     },
-
+    data() {
+      return {
+        focus: false
+      };
+    },
     computed: {
       value: {
         get() {

+ 2 - 2
packages/radio/src/radio.vue

@@ -4,6 +4,7 @@
     :class="[
       border && radioSize ? 'el-radio--' + radioSize : '',
       { 'is-disabled': isDisabled },
+      { 'is-focus': focus },
       { 'is-bordered': border },
       { 'is-checked': model === label }
     ]"
@@ -16,8 +17,7 @@
     <span class="el-radio__input"
       :class="{
         'is-disabled': isDisabled,
-        'is-checked': model === label,
-        'is-focus': focus
+        'is-checked': model === label
       }"
     >
       <span class="el-radio__inner"></span>

+ 51 - 6
packages/tabs/src/tab-nav.vue

@@ -34,7 +34,8 @@
     data() {
       return {
         scrollable: false,
-        navOffset: 0
+        navOffset: 0,
+        isFocus: false
       };
     },
 
@@ -119,6 +120,38 @@
             this.navOffset = 0;
           }
         }
+      },
+      changeTab(e) {
+        const keyCode = e.keyCode;
+        let nextIndex;
+        let currentIndex, tabList;
+        if ([37, 38, 39, 40].indexOf(keyCode) !== -1) { // 左右上下键更换tab
+          tabList = e.currentTarget.querySelectorAll('[role=tab]');
+          currentIndex = Array.prototype.indexOf.call(tabList, e.target);
+        } else {
+          return;
+        }
+        if (keyCode === 37 || keyCode === 38) { // left
+          if (currentIndex === 0) { // first
+            nextIndex = tabList.length - 1;
+          } else {
+            nextIndex = currentIndex - 1;
+          }
+        } else { // right
+          if (currentIndex < tabList.length - 1) { // not last
+            nextIndex = currentIndex + 1;
+          } else {
+            nextIndex = 0;
+          }
+        }
+        tabList[nextIndex].focus(); // 改变焦点元素
+        tabList[nextIndex].click(); // 选中下一个tab
+      },
+      setFocus() {
+        this.isFocus = true;
+      },
+      removeFocus() {
+        this.isFocus = false;
       }
     },
 
@@ -136,9 +169,11 @@
         navStyle,
         scrollable,
         scrollNext,
-        scrollPrev
+        scrollPrev,
+        changeTab,
+        setFocus,
+        removeFocus
       } = this;
-
       const scrollBtn = scrollable
       ? [
         <span class={['el-tabs__nav-prev', scrollable.prev ? '' : 'is-disabled']} on-click={scrollPrev}><i class="el-icon-arrow-left"></i></span>,
@@ -156,17 +191,27 @@
           : null;
 
         const tabLabelContent = pane.$slots.label || pane.label;
+        const tabindex = pane.active ? 0 : -1;
         return (
           <div
             class={{
               'el-tabs__item': true,
               'is-active': pane.active,
               'is-disabled': pane.disabled,
-              'is-closable': closable
+              'is-closable': closable,
+              'is-focus': this.isFocus
             }}
+            id={`tab-${tabName}`}
+            aria-controls={`pane-${tabName}`}
+            role="tab"
+            aria-selected= { pane.active }
             ref="tabs"
+            tabindex= {tabindex}
             refInFor
-            on-click={(ev) => { onTabClick(pane, tabName, ev); }}
+            on-focus= { ()=> { setFocus(); }}
+            on-blur = { ()=> { removeFocus(); }}
+            on-click={(ev) => { removeFocus(); onTabClick(pane, tabName, ev); }}
+            on-keydown={(ev) => { if (closable && (ev.keyCode === 46 || ev.keyCode === 8)) { onTabRemove(pane, ev);} }}
           >
             {tabLabelContent}
             {btnClose}
@@ -177,7 +222,7 @@
         <div class={['el-tabs__nav-wrap', scrollable ? 'is-scrollable' : '']}>
           {scrollBtn}
           <div class={['el-tabs__nav-scroll']} ref="navScroll">
-            <div class="el-tabs__nav" ref="nav" style={navStyle}>
+            <div class="el-tabs__nav" ref="nav" style={navStyle} role="tablist" on-keydown={ changeTab }>
               {!type ? <tab-bar tabs={panes}></tab-bar> : null}
               {tabs}
             </div>

+ 11 - 1
packages/tabs/src/tab-pane.vue

@@ -1,5 +1,12 @@
 <template>
-  <div class="el-tab-pane" v-show="active">
+  <div
+    class="el-tab-pane"
+    v-show="active"
+    role="tabpanel"
+    :aria-hidden="!active"
+    :id="`pane-${paneName}`"
+    :aria-labelledby="`tab-${paneName}`"
+  >
     <slot></slot>
   </div>
 </template>
@@ -29,6 +36,9 @@
       },
       active() {
         return this.$parent.currentName === (this.name || this.index);
+      },
+      paneName() {
+        return this.name || this.index;
       }
     },
 

+ 2 - 0
packages/tabs/src/tabs.vue

@@ -102,6 +102,8 @@
             <span
               class="el-tabs__new-tab"
               on-click={ handleTabAdd }
+              tabindex="0"
+              on-keydown={ (ev) => { if (ev.keyCode === 13) { handleTabAdd(); }} }
             >
                 <i class="el-icon-plus"></i>
             </span>

+ 6 - 0
packages/theme-chalk/src/message.scss

@@ -68,6 +68,9 @@
     padding: 0;
     font-size: 14px;
     line-height: 1;
+    &:focus {
+      outline-width: 0;
+    }
   }
 
   @include e(closeBtn) {
@@ -79,6 +82,9 @@
     color: $--message-close-color;
     font-size: $--message-close-size;
 
+    &:focus {
+      outline-width: 0;
+    }
     &:hover {
       color: $--message-close-hover-color;
     }

+ 5 - 9
packages/theme-chalk/src/radio-button.scss

@@ -5,6 +5,7 @@
 @include b(radio-button) {
   position: relative;
   display: inline-block;
+  outline: none;
 
   @include e(inner) {
     display: inline-block;
@@ -78,15 +79,6 @@
       box-shadow: none !important;
     }
   }
-  &:focus {
-    outline: none;
-    .el-radio-button__inner { /*获得焦点时 样式提醒*/
-      box-shadow: 0 0 1px 1px $--radio-button-checked-border-color;
-    }
-    &.is-disabled .el-radio-button__inner {
-      box-shadow: none;
-    }
-  }
   &:last-child {
     .el-radio-button__inner {
       border-radius: 0 $--border-radius-base $--border-radius-base 0;
@@ -114,4 +106,8 @@
       @include button-size($--button-mini-padding-vertical, $--button-mini-padding-horizontal, $--button-mini-font-size, 0);
     }
   }
+
+  &:focus:not(.is-focus):not(:active){ /*获得焦点时 样式提醒*/
+    box-shadow: 0 0 2px 2px $--radio-button-checked-border-color;
+  }
 }

+ 7 - 11
packages/theme-chalk/src/radio.scss

@@ -11,6 +11,7 @@
   cursor: pointer;
   display: inline-block;
   white-space: nowrap;
+  outline: none;
   @include utils-user-select(none);
 
   @include when(bordered) {
@@ -72,17 +73,6 @@
     }
   }
 
-  &:focus { /*获得焦点时 样式提醒*/
-    outline: none;
-    .el-radio__inner {
-      border-color: $--color-primary;
-    }
-
-    .is-disabled .el-radio__inner {
-      border-color: $--border-color-base;
-    }
-  }
-
   & + .el-radio {
     margin-left: 30px;
   }
@@ -189,6 +179,12 @@
     margin: 0;
   }
 
+  &:focus:not(.is-focus):not(:active){ /*获得焦点时 样式提醒*/
+    .el-radio__inner {
+      box-shadow: 0 0 2px 2px $--radio-input-border-color-hover;
+    }
+  }
+
   @include e(label) {
     font-size: $--radio-font-size;
     padding-left: 10px;

+ 4 - 0
packages/theme-chalk/src/tabs.scss

@@ -95,6 +95,10 @@
     color: $--color-text-primary;
     position: relative;
 
+    &:focus:not(.is-focus), &:focus:active {
+      outline: none;
+    }
+
     & .el-icon-close {
       border-radius: 50%;
       text-align: center;

+ 90 - 0
src/utils/aria-dialog.js

@@ -0,0 +1,90 @@
+import Utils from './aria-utils';
+
+/**
+ * @constructor
+ * @desc Dialog object providing modal focus management.
+ *
+ * Assumptions: The element serving as the dialog container is present in the
+ * DOM and hidden. The dialog container has role='dialog'.
+ *
+ * @param dialogId
+ *          The ID of the element serving as the dialog container.
+ * @param focusAfterClosed
+ *          Either the DOM node or the ID of the DOM node to focus when the
+ *          dialog closes.
+ * @param focusFirst
+ *          Optional parameter containing either the DOM node or the ID of the
+ *          DOM node to focus when the dialog opens. If not specified, the
+ *          first focusable element in the dialog will receive focus.
+ */
+var aria = aria || {};
+var tabEvent;
+
+aria.Dialog = function(dialog, focusAfterClosed, focusFirst) {
+  this.dialogNode = dialog;
+  if (this.dialogNode === null || this.dialogNode.getAttribute('role') !== 'dialog') {
+    throw new Error('Dialog() requires a DOM element with ARIA role of dialog.');
+  }
+
+  if (typeof focusAfterClosed === 'string') {
+    this.focusAfterClosed = document.getElementById(focusAfterClosed);
+  } else if (typeof focusAfterClosed === 'object') {
+    this.focusAfterClosed = focusAfterClosed;
+  } else {
+    this.focusAfterClosed = null;
+  }
+
+  if (typeof focusFirst === 'string') {
+    this.focusFirst = document.getElementById(focusFirst);
+  } else if (typeof focusFirst === 'object') {
+    this.focusFirst = focusFirst;
+  } else {
+    this.focusFirst = null;
+  }
+
+  if (this.focusFirst) {
+    this.focusFirst.focus();
+  } else {
+    Utils.focusFirstDescendant(this.dialogNode);
+  }
+
+  this.lastFocus = document.activeElement;
+  tabEvent = (e) => {
+    this.trapFocus(e);
+  };
+  this.addListeners();
+};
+
+aria.Dialog.prototype.addListeners = function() {
+  document.addEventListener('focus', tabEvent, true);
+};
+
+aria.Dialog.prototype.removeListeners = function() {
+  document.removeEventListener('focus', tabEvent, true);
+};
+
+aria.Dialog.prototype.closeDialog = function() {
+  this.removeListeners();
+  if (this.focusAfterClosed) {
+    setTimeout(() => {
+      this.focusAfterClosed.focus();
+    });
+  }
+};
+
+aria.Dialog.prototype.trapFocus = function(event) {
+  if (Utils.IgnoreUtilFocusChanges) {
+    return;
+  }
+  if (this.dialogNode.contains(event.target)) {
+    this.lastFocus = event.target;
+  } else {
+    Utils.focusFirstDescendant(this.dialogNode);
+    if (this.lastFocus === document.activeElement) {
+      Utils.focusLastDescendant(this.dialogNode);
+    }
+    this.lastFocus = document.activeElement;
+  }
+};
+
+export default aria.Dialog;

+ 1 - 0
src/utils/popup/popup-manager.js

@@ -103,6 +103,7 @@ const PopupManager = {
     if (zIndex) {
       modalDom.style.zIndex = zIndex;
     }
+    modalDom.tabIndex = 0;
     modalDom.style.display = '';
 
     this.modalStack.push({ id: id, zIndex: zIndex, modalClass: modalClass });

+ 5 - 3
test/unit/specs/message-box.spec.js

@@ -164,9 +164,11 @@ describe('MessageBox', () => {
     });
     setTimeout(() => {
       document.querySelector('.el-message-box__close').click();
-      expect(msgAction).to.equal('cancel');
-      done();
-    }, 50);
+      setTimeout(() => {
+        expect(msgAction).to.equal('cancel');
+        done();
+      }, 10);
+    }, 10);
   });
 
   it('beforeClose', done => {