add accessibility for input & rate & collapse & progress & upload (#7196)

pull/7292/head
maranran 2017-09-29 15:58:07 +08:00 committed by 杨奕
parent 5ce0e22823
commit d66473f005
21 changed files with 248 additions and 53 deletions

View File

@ -163,7 +163,7 @@ Use attribute `size` to set additional sizes with `medium`, `small` or `mini`.
|debounce| debounce delay when typing, in millisecond | number | — | 300 |
|controls-position | position of the control buttons | string | right | - |
|name | same as `name` in native input | string | — | — |
|label | label text | string | — | — |
### Events
| Event Name | Description | Parameters |

View File

@ -630,7 +630,7 @@ Search data from server-side.
|autofocus | same as `autofocus` in native input | boolean | — | false |
|form | same as `form` in native input | string | — | — |
| on-icon-click | hook function when clicking on the input icon | function | — | — |
| label | label text | string | — | — |
### Input slot
| Name | Description |
@ -663,7 +663,7 @@ Attribute | Description | Type | Options | Default
| on-icon-click | hook function when clicking on the input icon | function | — | — |
| name | same as `name` in native input | string | — | — |
| select-when-unmatched | whether to emit a `select` event on enter when there is no autocomplete match | boolean | — | false |
| label | label text | string | — | — |
### props
| Attribute | Description | Type | Accepted Values | Default |
| --------- | ----------------- | ------ | ------ | ------ |

View File

@ -36,7 +36,7 @@
:::demo 要使用它,只需要在`el-input-number`元素中使用`v-model`绑定变量即可,变量的初始值即为默认值。
```html
<template>
<el-input-number v-model="num1" @change="handleChange" :min="1" :max="10"></el-input-number>
<el-input-number v-model="num1" @change="handleChange" :min="1" :max="10" label="描述文字"></el-input-number>
</template>
<script>
export default {
@ -162,7 +162,7 @@
| debounce | 输入时的去抖延迟,毫秒 | number | — | 300 |
| controls-position | 控制按钮位置 | string | right | - |
| name | 原生属性 | string | — | — |
| label | 输入框关联的label文字 | string | — | — |
### Events
| 事件名称 | 说明 | 回调参数 |
|---------|--------|---------|

View File

@ -785,7 +785,7 @@ export default {
| resize | 控制是否能被用户缩放 | string | none, both, horizontal, vertical | — |
| autofocus | 原生属性,自动获取焦点 | boolean | true, false | false |
| form | 原生属性 | string | — | — |
| label | 输入框关联的label文字 | string | — | — |
### Input slot
| name | 说明 |
|------|--------|
@ -821,7 +821,7 @@ export default {
| icon | 输入框尾部图标 | string | — | — |
| name | 原生属性 | string | — | — |
| select-when-unmatched | 在输入没有任何匹配建议的情况下,按下回车是否触发 `select` 事件 | boolean | — | false |
| label | 输入框关联的label文字 | string | — | — |
### props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
| -------- | ----------------- | ------ | ------ | ------ |

View File

@ -5,6 +5,7 @@
class="el-autocomplete-suggestion el-popper"
:class="{ 'is-loading': parent.loading }"
:style="{ width: dropdownWidth }"
role="region"
>
<el-scrollbar
tag="ul"
@ -44,7 +45,8 @@
gpuAcceleration: false
};
}
}
},
id: String
},
methods: {
@ -62,6 +64,9 @@
mounted() {
this.$parent.popperElm = this.popperElm = this.$el;
this.referenceElm = this.$parent.$refs.input.$refs.input;
this.referenceList = this.$el.querySelector('.el-autocomplete-suggestion__list');
this.referenceList.setAttribute('role', 'listbox');
this.referenceList.setAttribute('id', this.id);
},
created() {

View File

@ -1,5 +1,12 @@
<template>
<div class="el-autocomplete" v-clickoutside="close">
<div
class="el-autocomplete"
v-clickoutside="close"
aria-haspopup="listbox"
role="combobox"
:aria-expanded="suggestionVisible"
:aria-owns="id"
>
<el-input
ref="input"
v-bind="$props"
@ -13,6 +20,7 @@
@keydown.down.native.prevent="highlight(highlightedIndex + 1)"
@keydown.enter.native="handleKeyEnter"
@keydown.native.tab="close"
:label="label"
>
<template slot="prepend" v-if="$slots.prepend">
<slot name="prepend"></slot>
@ -24,12 +32,17 @@
<el-autocomplete-suggestions
visible-arrow
:class="[popperClass ? popperClass : '']"
ref="suggestions">
ref="suggestions"
:id="id">
<li
v-for="(item, index) in suggestions"
:key="index"
:class="{'highlighted': highlightedIndex === index}"
@click="select(item)">
@click="select(item)"
:id="`${id}-item-${index}`"
role="option"
:aria-selected="highlightedIndex === index"
>
<slot :item="item">
{{ item[props.label] }}
</slot>
@ -42,6 +55,7 @@
import Clickoutside from 'element-ui/src/utils/clickoutside';
import ElAutocompleteSuggestions from './autocomplete-suggestions.vue';
import Emitter from 'element-ui/src/mixins/emitter';
import { generateId } from 'element-ui/src/utils/util';
export default {
name: 'ElAutocomplete',
@ -85,7 +99,8 @@
selectWhenUnmatched: {
type: Boolean,
default: false
}
},
label: String
},
data() {
return {
@ -101,6 +116,9 @@
const suggestions = this.suggestions;
let isValidData = Array.isArray(suggestions) && suggestions.length > 0;
return (isValidData || this.loading) && this.activated;
},
id() {
return `el-autocomplete-${generateId()}`;
}
},
watch: {
@ -191,14 +209,19 @@
if (offsetTop < scrollTop) {
suggestion.scrollTop -= highlightItem.scrollHeight;
}
this.highlightedIndex = index;
this.$el.querySelector('.el-input__inner').setAttribute('aria-activedescendant', `${this.id}-item-${this.highlightedIndex}`);
}
},
mounted() {
this.$on('item-click', item => {
this.select(item);
});
let $input = this.$el.querySelector('.el-input__inner');
$input.setAttribute('role', 'textbox');
$input.setAttribute('aria-autocomplete', 'list');
$input.setAttribute('aria-controls', 'id');
$input.setAttribute('aria-activedescendant', `${this.id}-item-${this.highlightedIndex}`);
},
beforeDestroy() {
this.$refs.suggestions.$destroy();

View File

@ -1,6 +1,5 @@
<template>
<button
v-bind="$props"
class="el-button"
@click="handleClick"
:type="nativeType"

View File

@ -1,11 +1,35 @@
<template>
<div class="el-collapse-item" :class="{'is-active': isActive}">
<div class="el-collapse-item__header" @click="handleHeaderClick">
<i class="el-collapse-item__arrow el-icon-arrow-right"></i>
<slot name="title">{{title}}</slot>
<div
role="tab"
:aria-expanded="isActive"
:aria-controls="`el-collapse-content-${id}`"
:aria-describedby ="`el-collapse-content-${id}`"
>
<div
class="el-collapse-item__header"
@click="handleHeaderClick"
role="button"
:id="`el-collapse-head-${id}`"
tabindex="0"
@keyup.space.enter.stop="handleEnterClick"
:class="{'focusing': focusing}"
@focus="focusing = true"
@blur="focusing = false"
>
<i class="el-collapse-item__arrow el-icon-arrow-right"></i>
<slot name="title">{{title}}</slot>
</div>
</div>
<el-collapse-transition>
<div class="el-collapse-item__wrap" v-show="isActive">
<div
class="el-collapse-item__wrap"
v-show="isActive"
role="tabpanel"
:aria-hidden="!isActive"
:aria-labelledby="`el-collapse-head-${id}`"
:id="`el-collapse-content-${id}`"
>
<div class="el-collapse-item__content">
<slot></slot>
</div>
@ -16,6 +40,7 @@
<script>
import ElCollapseTransition from 'element-ui/src/transitions/collapse-transition';
import Emitter from 'element-ui/src/mixins/emitter';
import { generateId } from 'element-ui/src/utils/util';
export default {
name: 'ElCollapseItem',
@ -32,7 +57,8 @@
height: 'auto',
display: 'block'
},
contentHeight: 0
contentHeight: 0,
focusing: false
};
},
@ -49,6 +75,9 @@
computed: {
isActive() {
return this.$parent.activeNames.indexOf(this.name) > -1;
},
id() {
return generateId();
}
},
@ -60,6 +89,10 @@
methods: {
handleHeaderClick() {
this.dispatch('ElCollapse', 'item-click', this);
this.focusing = false;
},
handleEnterClick() {
this.dispatch('ElCollapse', 'item-click', this);
}
},

View File

@ -1,5 +1,5 @@
<template>
<div class="el-collapse">
<div class="el-collapse" role="tablist" aria-multiselectable="true">
<slot></slot>
</div>
</template>

View File

@ -12,6 +12,8 @@
class="el-input-number__decrease"
:class="{'is-disabled': minDisabled}"
v-repeat-click="decrease"
@keydown.enter="decrease"
role="button"
>
<i :class="`el-icon-${controlsAtRight ? 'arrow-down' : 'minus'}`"></i>
</span>
@ -20,6 +22,8 @@
class="el-input-number__increase"
:class="{'is-disabled': maxDisabled}"
v-repeat-click="increase"
@keydown.enter="increase"
role="button"
>
<i :class="`el-icon-${controlsAtRight ? 'arrow-up' : 'plus'}`"></i>
</span>
@ -36,6 +40,7 @@
:min="min"
:name="name"
ref="input"
:label="label"
>
<template slot="prepend" v-if="$slots.prepend">
<slot name="prepend"></slot>
@ -111,7 +116,8 @@
type: Number,
default: 300
},
name: String
name: String,
label: String
},
data() {
return {
@ -223,6 +229,18 @@
this.debounceHandleInput = debounce(this.debounce, value => {
this.handleInput(value);
});
},
mounted() {
let innerInput = this.$refs.input.$refs.input;
innerInput.setAttribute('role', 'spinbutton');
innerInput.setAttribute('aria-valuemax', this.max);
innerInput.setAttribute('aria-valuemin', this.min);
innerInput.setAttribute('aria-valuenow', this.currentValue);
innerInput.setAttribute('aria-disabled', this.disabled);
},
updated() {
let innerInput = this.$refs.input.$refs.input;
innerInput.setAttribute('aria-valuenow', this.currentValue);
}
};
</script>

View File

@ -13,17 +13,9 @@
]">
<template v-if="type !== 'textarea'">
<!-- 前置元素 -->
<div class="el-input-group__prepend" v-if="$slots.prepend">
<div class="el-input-group__prepend" v-if="$slots.prepend" tabindex="0">
<slot name="prepend"></slot>
</div>
<!-- 前置内容 -->
<span class="el-input__prefix" v-if="$slots.prefix || prefixIcon">
<slot name="prefix"></slot>
<i class="el-input__icon"
v-if="prefixIcon"
:class="prefixIcon">
</i>
</span>
<input
v-if="type !== 'textarea'"
class="el-input__inner"
@ -34,7 +26,16 @@
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
:aria-label="label"
>
<!-- 前置内容 -->
<span class="el-input__prefix" v-if="$slots.prefix || prefixIcon">
<slot name="prefix"></slot>
<i class="el-input__icon"
v-if="prefixIcon"
:class="prefixIcon">
</i>
</span>
<!-- 后置内容 -->
<span class="el-input__suffix" v-if="$slots.suffix || suffixIcon || validateState">
<span class="el-input__suffix-inner">
@ -63,7 +64,9 @@
v-bind="$props"
:style="textareaStyle"
@focus="handleFocus"
@blur="handleBlur">
@blur="handleBlur"
:aria-label="label"
>
</textarea>
</div>
</template>
@ -127,7 +130,8 @@
},
onIconClick: Function,
suffixIcon: String,
prefixIcon: String
prefixIcon: String,
label: String
},
computed: {

View File

@ -9,6 +9,10 @@
'el-progress--text-inside': textInside,
}
]"
role="progressbar"
:aria-valuenow="percentage"
aria-valuemin="0"
aria-valuemax="100"
>
<div class="el-progress-bar" v-if="type === 'line'">
<div class="el-progress-bar__outer" :style="{height: strokeWidth + 'px'}">

View File

@ -1,12 +1,24 @@
<template>
<div class="el-rate">
<div class="el-rate"
@keydown="handelKey"
role="slider"
:aria-valuenow="currentValue"
:aria-valuetext="text"
aria-valuemin="0"
:aria-valuemin="max"
tabindex="0"
@focus="focusing = true"
@blur="focusing = false"
:class="{'focusing': focusing}"
>
<span
v-for="item in max"
class="el-rate__item"
@mousemove="setCurrentValue(item, $event)"
@mouseleave="resetCurrentValue"
@click="selectValue(item)"
:style="{ cursor: disabled ? 'auto' : 'pointer' }">
:style="{ cursor: disabled ? 'auto' : 'pointer' }"
>
<i
:class="[classes[item - 1], { 'hover': hoverIndex === item }]"
class="el-rate__icon"
@ -34,7 +46,8 @@
classMap: {},
pointerAtLeftHalf: true,
currentValue: this.value,
hoverIndex: -1
hoverIndex: -1,
focusing: false
};
},
@ -237,6 +250,34 @@
this.$emit('input', value);
this.$emit('change', value);
}
this.focusing = false;
},
handelKey(e) {
let currentValue = this.currentValue;
const keyCode = e.keyCode;
if (keyCode === 38 || keyCode === 39) { // left / down
if (this.allowHalf) {
currentValue += 0.5;
} else {
currentValue += 1;
}
e.stopPropagation();
e.preventDefault();
} else if (keyCode === 37 || keyCode === 40) {
if (this.allowHalf) {
currentValue -= 0.5;
} else {
currentValue -= 1;
}
e.stopPropagation();
e.preventDefault();
}
currentValue = currentValue < 0 ? 0 : currentValue;
currentValue = currentValue > this.max ? this.max : currentValue;
this.$emit('input', currentValue);
this.$emit('change', currentValue);
},
setCurrentValue(value, event) {

View File

@ -1,5 +1,12 @@
<template>
<label class="el-switch" :class="{ 'is-disabled': disabled, 'is-checked': checked }">
<div
class="el-switch"
:class="{ 'is-disabled': disabled, 'is-checked': checked }"
role="switch"
:aria-checked="checked"
:aria-disabled="disabled"
@click="switchValue"
>
<input
class="el-switch__input"
type="checkbox"
@ -8,12 +15,14 @@
:name="name"
:true-value="onValue"
:false-value="offValue"
:disabled="disabled">
:disabled="disabled"
@keydown.enter="switchValue"
>
<span
:class="['el-switch__label', 'el-switch__label--left', !checked ? 'is-active' : '']"
v-if="offIconClass || offText">
<i :class="[offIconClass]" v-if="offIconClass"></i>
<span v-if="!offIconClass && offText">{{ offText }}</span>
<span v-if="!offIconClass && offText" :aria-hidden="checked">{{ offText }}</span>
</span>
<span class="el-switch__core" ref="core" :style="{ 'width': coreWidth + 'px' }">
<span class="el-switch__button" :style="{ transform }"></span>
@ -22,11 +31,10 @@
:class="['el-switch__label', 'el-switch__label--right', checked ? 'is-active' : '']"
v-if="onIconClass || onText">
<i :class="[onIconClass]" v-if="onIconClass"></i>
<span v-if="!onIconClass && onText">{{ onText }}</span>
<span v-if="!onIconClass && onText" :aria-hidden="!checked">{{ onText }}</span>
</span>
</label>
</div>
</template>
<script>
export default {
name: 'ElSwitch',
@ -114,6 +122,9 @@
let newColor = this.checked ? this.onColor : this.offColor;
this.$refs.core.style.borderColor = newColor;
this.$refs.core.style.backgroundColor = newColor;
},
switchValue() {
this.$refs.input.click();
}
},
mounted() {

View File

@ -16,6 +16,9 @@
font-size: $--collapse-header-size;
font-weight: 500;
transition: border-bottom-color .3s;
&:focus:not(.focusing), &:active {
outline-width: 0;
}
@include e(arrow) {
margin-right: 8px;
@ -42,9 +45,8 @@
}
@include when(active) {
> .el-collapse-item__header {
.el-collapse-item__header {
border-bottom-color: transparent;
.el-collapse-item__arrow {
transform: rotate(90deg);
}

View File

@ -5,6 +5,10 @@
height: $--rate-height;
line-height: 1;
&:focus:not(.focusing), &:active {
outline-width: 0;
}
@include e(item) {
display: inline-block;
position: relative;

View File

@ -43,7 +43,13 @@
}
@include e(input) {
display: none;
position: absolute;
width: 0;
height: 0;
opacity: 0;
&:focus ~ .el-switch__core {
outline: 1px solid #f00;
}
}
@include e(core) {

View File

@ -156,6 +156,17 @@
}
}
& .el-icon-close-tip {
display: none;
position: absolute;
top: 5px;
right: 0;
cursor: pointer;
opacity: 1;
color: $--color-primary;
transform: translate(15%,0) scale(.7);
}
&:hover {
background-color: $--background-color-base;
@ -173,12 +184,25 @@
display: block;
}
.el-upload-list__item-name:hover {
.el-upload-list__item-name:hover, .el-upload-list__item-name:focus {
color: $--link-hover-color;
cursor: pointer;
}
&:hover {
&:focus {
.el-icon-close-tip {
display: inline-block;
}
}
&:focus:not(.focusing), &:active {
outline-width: 0;
.el-icon-close-tip {
display: none;
}
}
&:hover, &:focus { /*键盘焦点时 显示提示文字 focus*/
.el-upload-list__item-status-label {
display: none;
}
@ -255,7 +279,6 @@
.el-icon-close {
display: none;
}
&:hover {
.el-upload-list__item-status-label {
display: none;
@ -378,7 +401,6 @@
.el-upload-list__item-name {
line-height: 70px;
margin-top: 0;
i {
display: none;
}

View File

@ -10,8 +10,13 @@
>
<li
v-for="(file, index) in files"
:class="['el-upload-list__item', 'is-' + file.status]"
:class="['el-upload-list__item', 'is-' + file.status, focusing ? 'focusing' : '']"
:key="index"
tabindex="0"
@keydown.delete="$emit('remove', file)"
@focus="focusing = true"
@blur="focusing = false"
@click="focusing = false"
>
<img
class="el-upload-list__item-thumbnail"
@ -29,13 +34,14 @@
}"></i>
</label>
<i class="el-icon-close" v-if="!disabled" @click="$emit('remove', file)"></i>
<i class="el-icon-close-tip" v-if="!disabled">delete</i> <!--closeli:focus display, li blur focus close-->
<el-progress
v-if="file.status === 'uploading'"
:type="listType === 'picture-card' ? 'circle' : 'line'"
:stroke-width="listType === 'picture-card' ? 6 : 2"
:percentage="parsePercentage(file.percentage)">
</el-progress>
<span class="el-upload-list__item-actions" v-if="listType === 'picture-card'">
<span class="el-upload-list__item-actions" v-if="listType === 'picture-card'">
<span
class="el-upload-list__item-preview"
v-if="handlePreview && listType === 'picture-card'"
@ -61,6 +67,11 @@
export default {
mixins: [Locale],
data() {
return {
focusing: false
};
},
components: { ElProgress },
props: {

View File

@ -145,6 +145,11 @@ export default {
this.$refs.input.value = null;
this.$refs.input.click();
}
},
handleKeydown(e) {
if (e.keyCode === 13 || e.keyCode === 32) {
this.handleClick();
}
}
},
@ -158,19 +163,21 @@ export default {
accept,
listType,
uploadFiles,
disabled
disabled,
handleKeydown
} = this;
const data = {
class: {
'el-upload': true
},
on: {
click: handleClick
click: handleClick,
keydown: handleKeydown
}
};
data.class[`el-upload--${listType}`] = true;
return (
<div {...data}>
<div {...data} tabindex="0" >
{
drag
? <upload-dragger disabled={disabled} on-file={uploadFiles}>{this.$slots.default}</upload-dragger>

View File

@ -37,3 +37,8 @@ export const getValueByPath = function(object, prop) {
}
return result;
};
export const generateId = function() {
return Math.floor(Math.random() * 10000);
};