mirror of https://github.com/ElemeFE/element
				
				
				
			Accessibility for Cascader & Dropdown (#7973)
							parent
							
								
									4ea53ab896
								
							
						
					
					
						commit
						81011d1c48
					
				|  | @ -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); | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -6,6 +6,8 @@ | |||
|       'el-dropdown-menu__item--divided': divided | ||||
|     }" | ||||
|     @click="handleClick" | ||||
|     :aria-disabled="disabled" | ||||
|     :tabindex="disabled ? null : -1" | ||||
|   > | ||||
|     <slot></slot> | ||||
|   </li> | ||||
|  |  | |||
|  | @ -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') { | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 maranran
						maranran