Explorar el Código

Accessibility for Cascader & Dropdown (#7973)

maranran hace 7 años
padre
commit
81011d1c48

+ 32 - 0
packages/cascader/src/main.vue

@@ -10,9 +10,12 @@
     ]"
     @click="handleClick"
     @mouseenter="inputHover = true"
+    @focus="inputHover = true"
     @mouseleave="inputHover = false"
+    @blur="inputHover = false"
     ref="reference"
     v-clickoutside="handleClickoutside"
+    @keydown="handleKeydown"
   >
     <el-input
       ref="input"
@@ -63,6 +66,7 @@ import emitter from 'element-ui/src/mixins/emitter';
 import Locale from 'element-ui/src/mixins/locale';
 import { t } from 'element-ui/src/locale';
 import debounce from 'throttle-debounce/debounce';
+import { generateId } from 'element-ui/src/utils/util';
 
 const popperMixin = {
   props: {
@@ -195,11 +199,15 @@ export default {
     },
     cascaderSize() {
       return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
+    },
+    id() {
+      return generateId();
     }
   },
 
   watch: {
     menuVisible(value) {
+      this.$refs.input.$refs.input.setAttribute('aria-expanded', value);
       value ? this.showMenu() : this.hideMenu();
     },
     value(value) {
@@ -208,6 +216,10 @@ export default {
     currentValue(value) {
       this.dispatch('ElFormItem', 'el.form.change', [value]);
     },
+    currentLabels(value) {
+      const inputLabel = this.showAllLevels ? value.join('/') : value[value.length - 1] ;
+      this.$refs.input.$refs.input.setAttribute('value', inputLabel);
+    },
     options: {
       deep: true,
       handler(value) {
@@ -230,9 +242,11 @@ export default {
       this.menu.popperClass = this.popperClass;
       this.menu.hoverThreshold = this.hoverThreshold;
       this.popperElm = this.menu.$el;
+      this.menu.$refs.menus[0].setAttribute('id', `cascader-menu-${this.id}`);
       this.menu.$on('pick', this.handlePick);
       this.menu.$on('activeItemChange', this.handleActiveItemChange);
       this.menu.$on('menuLeave', this.doDestroy);
+      this.menu.$on('closeInside', this.handleClickoutside);
     },
     showMenu() {
       if (!this.menu) {
@@ -250,6 +264,7 @@ export default {
     hideMenu() {
       this.inputValue = '';
       this.menu.visible = false;
+      this.$refs.input.focus();
     },
     handleActiveItemChange(value) {
       this.$nextTick(_ => {
@@ -257,6 +272,23 @@ export default {
       });
       this.$emit('active-item-change', value);
     },
+    handleKeydown(e) {
+      const keyCode = e.keyCode;
+      if (keyCode === 13) {
+        this.handleClick();
+      } else if (keyCode === 40) { // down
+        this.menuVisible = true; // 打开
+        setTimeout(() => {
+          const firstMenu = this.popperElm.querySelectorAll('.el-cascader-menu')[0];
+          firstMenu.querySelectorAll("[tabindex='-1']")[0].focus();
+        });
+        e.stopPropagation();
+        e.preventDefault();
+      } else if (keyCode === 27 || keyCode === 9) { // esc  tab
+        this.inputValue = '';
+        if (this.menu) this.menu.visible = false;
+      }
+    },
     handlePick(value, close = true) {
       this.currentValue = value;
       this.$emit('input', value);

+ 63 - 3
packages/cascader/src/menu.vue

@@ -1,6 +1,7 @@
 <script>
   import { isDef } from 'element-ui/src/utils/shared';
   import scrollIntoView from 'element-ui/src/utils/scroll-into-view';
+  import { generateId } from 'element-ui/src/utils/util';
 
   const copyArray = (arr, props) => {
     if (!arr || !Array.isArray(arr) || !props) return arr;
@@ -95,6 +96,9 @@
           formatOptions(optionsCopy);
           return loadActiveOptions(optionsCopy);
         }
+      },
+      id() {
+        return generateId();
       }
     },
 
@@ -139,6 +143,8 @@
         popperClass,
         hoverThreshold
       } = this;
+      let itemId = null;
+      let itemIndex = 0;
 
       let hoverMenuRefs = {};
       const hoverMenuHandler = e => {
@@ -167,6 +173,8 @@
 
       const menus = this._l(activeOptions, (menu, menuIndex) => {
         let isFlat = false;
+        const menuId = `menu-${this.id}-${ menuIndex}`;
+        const ownsId = `menu-${this.id}-${ menuIndex + 1 }`;
         const items = this._l(menu, item => {
           const events = {
             on: {}
@@ -175,12 +183,52 @@
           if (item.__IS__FLAT__OPTIONS) isFlat = true;
 
           if (!item.disabled) {
+            // keydown up/down/left/right/enter
+            events.on.keydown = (ev) => {
+              const keyCode = ev.keyCode;
+              if (![37, 38, 39, 40, 13, 9, 27].includes(keyCode)) {
+                return;
+              }
+              const currentEle = ev.target;
+              const parentEle = this.$refs.menus[menuIndex];
+              const menuItemList = parentEle.querySelectorAll("[tabindex='-1']");
+              const currentIndex = Array.prototype.indexOf.call(menuItemList, currentEle); // 当前索引
+              let nextIndex, nextMenu;
+              if ([38, 40].includes(keyCode)) {
+                if (keyCode === 38) { // up键
+                  nextIndex = currentIndex !== 0 ? (currentIndex - 1) : currentIndex;
+                } else if (keyCode === 40) { // down
+                  nextIndex = currentIndex !== (menuItemList.length - 1) ? currentIndex + 1 : currentIndex;
+                }
+                menuItemList[nextIndex].focus();
+              } else if (keyCode === 37) { // left键
+                if (menuIndex !== 0) {
+                  const previousMenu = this.$refs.menus[menuIndex - 1];
+                  previousMenu.querySelector('[aria-expanded=true]').focus();
+                }
+              } else if (keyCode === 39) { // right
+                if (item.children) {
+                  // 有子menu 选择子menu的第一个menuitem
+                  nextMenu = this.$refs.menus[menuIndex + 1];
+                  nextMenu.querySelectorAll("[tabindex='-1']")[0].focus();
+                }
+              } else if (keyCode === 13) {
+                if (!item.children) {
+                  const id = currentEle.getAttribute('id');
+                  parentEle.setAttribute('aria-activedescendant', id);
+                  this.select(item, menuIndex);
+                  this.$nextTick(() => this.scrollMenu(this.$refs.menus[menuIndex]));
+                }
+              } else if (keyCode === 9 || keyCode === 27) { // esc tab
+                this.$emit('closeInside');
+              }
+            };
             if (item.children) {
               let triggerEvent = {
                 click: 'click',
                 hover: 'mouseenter'
               }[expandTrigger];
-              events.on[triggerEvent] = () => {
+              events.on[triggerEvent] = events.on['focus'] = () => { // focus 选中
                 this.activeItem(item, menuIndex);
                 this.$nextTick(() => {
                   // adjust self and next level
@@ -195,7 +243,10 @@
               };
             }
           }
-
+          if (!item.disabled && !item.children) { // no children set id
+            itemId = `${menuId}-${itemIndex}`;
+            itemIndex++;
+          }
           return (
             <li
               class={{
@@ -206,6 +257,12 @@
               }}
               ref={item.value === activeValue[menuIndex] ? 'activeItem' : null}
               {...events}
+              tabindex= { item.disabled ? null : -1 }
+              role="menuitem"
+              aria-haspopup={ !!item.children }
+              aria-expanded={ item.value === activeValue[menuIndex] }
+              id = { itemId }
+              aria-owns = { !item.children ? null : ownsId }
             >
               {item.label}
             </li>
@@ -236,7 +293,10 @@
             {...hoverMenuEvent}
             style={menuStyle}
             refInFor
-            ref="menus">
+            ref="menus"
+            role="menu"
+            id = { menuId }
+          >
             {items}
             {
               isHoveredMenu

+ 2 - 0
packages/dropdown/src/dropdown-item.vue

@@ -6,6 +6,8 @@
       'el-dropdown-menu__item--divided': divided
     }"
     @click="handleClick"
+    :aria-disabled="disabled"
+    :tabindex="disabled ? null : -1"
   >
     <slot></slot>
   </li>

+ 106 - 5
packages/dropdown/src/dropdown.vue

@@ -4,6 +4,7 @@
   import Migrating from 'element-ui/src/mixins/migrating';
   import ElButton from 'element-ui/packages/button';
   import ElButtonGroup from 'element-ui/packages/button-group';
+  import { generateId } from 'element-ui/src/utils/util';
 
   export default {
     name: 'ElDropdown',
@@ -61,25 +62,43 @@
       return {
         timeout: null,
         visible: false,
-        triggerElm: null
+        triggerElm: null,
+        menuItems: null,
+        menuItemsArray: null,
+        dropdownElm: null,
+        focusing: false
       };
     },
 
     computed: {
       dropdownSize() {
         return this.size || (this.$ELEMENT || {}).size;
+      },
+      listId() {
+        return `dropdown-menu-${generateId()}`;
       }
     },
 
     mounted() {
       this.$on('menu-item-click', this.handleMenuItemClick);
       this.initEvent();
+      this.initAria();
     },
 
     watch: {
       visible(val) {
         this.broadcast('ElDropdownMenu', 'visible', val);
         this.$emit('visible-change', val);
+      },
+      focusing(val) {
+        const selfDefine = this.$el.querySelector('.el-dropdown-selfdefine');
+        if (selfDefine) { // 自定义
+          if (val) {
+            selfDefine.className += ' focusing';
+          } else {
+            selfDefine.className = selfDefine.className.replace('focusing', '');
+          }
+        }
       }
     },
 
@@ -100,6 +119,8 @@
       },
       hide() {
         if (this.triggerElm.disabled) return;
+        this.removeTabindex();
+        this.resetTabindex(this.triggerElm);
         clearTimeout(this.timeout);
         this.timeout = setTimeout(() => {
           this.visible = false;
@@ -109,18 +130,98 @@
         if (this.triggerElm.disabled) return;
         this.visible = !this.visible;
       },
+      handleTriggerKeyDown(ev) {
+        const keyCode = ev.keyCode;
+        if ([38, 40].includes(keyCode)) { // up/down
+          this.removeTabindex();
+          this.resetTabindex(this.menuItems[0]);
+          this.menuItems[0].focus();
+          ev.preventDefault();
+          ev.stopPropagation();
+        } else if (keyCode === 13) { // space enter选中
+          this.handleClick();
+        } else if ([9, 27].includes(keyCode)) { // tab || esc
+          this.hide();
+        }
+        return;
+      },
+      handleItemKeyDown(ev) {
+        const keyCode = ev.keyCode;
+        const target = ev.target;
+        const currentIndex = this.menuItemsArray.indexOf(target);
+        const max = this.menuItemsArray.length - 1;
+        let nextIndex;
+        if ([38, 40].includes(keyCode)) { // up/down
+          if (keyCode === 38) { // up
+            nextIndex = currentIndex !== 0 ? currentIndex - 1 : 0;
+          } else { // down
+            nextIndex = currentIndex < max ? currentIndex + 1 : max;
+          }
+          this.removeTabindex();
+          this.resetTabindex(this.menuItems[nextIndex]);
+          this.menuItems[nextIndex].focus();
+          ev.preventDefault();
+          ev.stopPropagation();
+        } else if (keyCode === 13) { // enter选中
+          this.triggerElm.focus();
+          target.click();
+          if (!this.hideOnClick) { // click关闭
+            this.visible = false;
+          }
+        } else if ([9, 27].includes(keyCode)) { // tab // esc
+          this.hide();
+          this.triggerElm.focus();
+        }
+        return;
+      },
+      resetTabindex(ele) { // 下次tab时组件聚焦元素
+        this.removeTabindex();
+        ele.setAttribute('tabindex', '0'); // 下次期望的聚焦元素
+      },
+      removeTabindex() {
+        this.triggerElm.setAttribute('tabindex', '-1');
+        this.menuItemsArray.forEach((item) => {
+          item.setAttribute('tabindex', '-1');
+        });
+      },
+      initAria() {
+        this.dropdownElm.setAttribute('id', this.listId);
+        this.triggerElm.setAttribute('aria-haspopup', 'list');
+        this.triggerElm.setAttribute('aria-controls', this.listId);
+        this.menuItems = this.dropdownElm.querySelectorAll("[tabindex='-1']");
+        this.menuItemsArray = Array.prototype.slice.call(this.menuItems);
+
+        if (!this.splitButton) { // 自定义
+          this.triggerElm.setAttribute('role', 'button');
+          this.triggerElm.setAttribute('tabindex', '0');
+          this.triggerElm.className += ' el-dropdown-selfdefine'; // 控制
+        }
+      },
       initEvent() {
-        let { trigger, show, hide, handleClick, splitButton } = this;
+        let { trigger, show, hide, handleClick, splitButton, handleTriggerKeyDown, handleItemKeyDown } = this;
         this.triggerElm = splitButton
           ? this.$refs.trigger.$el
           : this.$slots.default[0].elm;
 
+        let dropdownElm = this.dropdownElm = this.$slots.dropdown[0].elm;
+
+        this.triggerElm.addEventListener('keydown', handleTriggerKeyDown); // triggerElm keydown
+        dropdownElm.addEventListener('keydown', handleItemKeyDown, true); // item keydown
+        // 控制自定义元素的样式
+        if (!splitButton) {
+          this.triggerElm.addEventListener('focus', () => {
+            this.focusing = true;
+          });
+          this.triggerElm.addEventListener('blur', () => {
+            this.focusing = false;
+          });
+          this.triggerElm.addEventListener('click', () => {
+            this.focusing = false;
+          });
+        }
         if (trigger === 'hover') {
           this.triggerElm.addEventListener('mouseenter', show);
           this.triggerElm.addEventListener('mouseleave', hide);
-
-          let dropdownElm = this.$slots.dropdown[0].elm;
-
           dropdownElm.addEventListener('mouseenter', show);
           dropdownElm.addEventListener('mouseleave', hide);
         } else if (trigger === 'click') {

+ 2 - 2
packages/theme-chalk/src/cascader.scss

@@ -128,7 +128,7 @@
     line-height: 1.5;
     box-sizing: border-box;
     cursor: pointer;
-
+    outline: none;
     @include m(extensible) {
       &:after {
         font-family: 'element-icons';
@@ -154,7 +154,7 @@
       color: $--select-option-selected;
     }
 
-    &:hover {
+    &:hover, &:focus:not(:active) {
       background-color: $--select-option-hover-background;
     }
 

+ 8 - 2
packages/theme-chalk/src/dropdown.scss

@@ -50,6 +50,12 @@
     font-size: 12px;
     margin: 0 3px;
   }
+
+  .el-dropdown-selfdefine { // 自定义
+    &:focus:active, &:focus:not(.focusing) {
+      outline-width: 0;
+    }
+  }
 }
 
 @include b(dropdown-menu) {
@@ -72,8 +78,8 @@
     font-size: $--font-size-base;
     color: $--color-text-regular;
     cursor: pointer;
-
-    &:not(.is-disabled):hover {
+    outline: none;
+    &:not(.is-disabled):hover, &:focus {
       background-color: $--dropdown-menuItem-hover-fill;
       color: $--dropdown-menuItem-hover-color;
     }