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