diff --git a/examples/docs/en-US/input-number.md b/examples/docs/en-US/input-number.md index 90c1cccbf..aa4d9ab36 100644 --- a/examples/docs/en-US/input-number.md +++ b/examples/docs/en-US/input-number.md @@ -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 | diff --git a/examples/docs/en-US/input.md b/examples/docs/en-US/input.md index 1d3c30a7b..a68dd1fa9 100644 --- a/examples/docs/en-US/input.md +++ b/examples/docs/en-US/input.md @@ -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 | | --------- | ----------------- | ------ | ------ | ------ | diff --git a/examples/docs/zh-CN/input-number.md b/examples/docs/zh-CN/input-number.md index f8a252c19..f172efd49 100644 --- a/examples/docs/zh-CN/input-number.md +++ b/examples/docs/zh-CN/input-number.md @@ -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 | 事件名称 | 说明 | 回调参数 | |---------|--------|---------| diff --git a/examples/docs/zh-CN/input.md b/examples/docs/zh-CN/input.md index 0a0d3b448..08b5eee9e 100644 --- a/examples/docs/zh-CN/input.md +++ b/examples/docs/zh-CN/input.md @@ -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 | 参数 | 说明 | 类型 | 可选值 | 默认值 | | -------- | ----------------- | ------ | ------ | ------ | diff --git a/packages/autocomplete/src/autocomplete-suggestions.vue b/packages/autocomplete/src/autocomplete-suggestions.vue index 5aeede378..c202c4d25 100644 --- a/packages/autocomplete/src/autocomplete-suggestions.vue +++ b/packages/autocomplete/src/autocomplete-suggestions.vue @@ -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() { diff --git a/packages/autocomplete/src/autocomplete.vue b/packages/autocomplete/src/autocomplete.vue index fd03770db..11d8d3070 100644 --- a/packages/autocomplete/src/autocomplete.vue +++ b/packages/autocomplete/src/autocomplete.vue @@ -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(); diff --git a/packages/button/src/button.vue b/packages/button/src/button.vue index df454d0cf..b3cd56a3f 100644 --- a/packages/button/src/button.vue +++ b/packages/button/src/button.vue @@ -1,6 +1,5 @@ <template> <button - v-bind="$props" class="el-button" @click="handleClick" :type="nativeType" diff --git a/packages/collapse/src/collapse-item.vue b/packages/collapse/src/collapse-item.vue index a6b8b7172..18ec63e06 100644 --- a/packages/collapse/src/collapse-item.vue +++ b/packages/collapse/src/collapse-item.vue @@ -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); } }, diff --git a/packages/collapse/src/collapse.vue b/packages/collapse/src/collapse.vue index 647c4bfc5..150141300 100644 --- a/packages/collapse/src/collapse.vue +++ b/packages/collapse/src/collapse.vue @@ -1,5 +1,5 @@ <template> - <div class="el-collapse"> + <div class="el-collapse" role="tablist" aria-multiselectable="true"> <slot></slot> </div> </template> diff --git a/packages/input-number/src/input-number.vue b/packages/input-number/src/input-number.vue index cfa3cd697..9794021bf 100644 --- a/packages/input-number/src/input-number.vue +++ b/packages/input-number/src/input-number.vue @@ -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> diff --git a/packages/input/src/input.vue b/packages/input/src/input.vue index be9127ebd..14b50c16e 100644 --- a/packages/input/src/input.vue +++ b/packages/input/src/input.vue @@ -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: { diff --git a/packages/progress/src/progress.vue b/packages/progress/src/progress.vue index 5dac70687..b1c733e10 100644 --- a/packages/progress/src/progress.vue +++ b/packages/progress/src/progress.vue @@ -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'}"> diff --git a/packages/rate/src/main.vue b/packages/rate/src/main.vue index d7158ad32..88c8284b8 100644 --- a/packages/rate/src/main.vue +++ b/packages/rate/src/main.vue @@ -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) { diff --git a/packages/switch/src/component.vue b/packages/switch/src/component.vue index 5f7af1e84..ce7eaf993 100644 --- a/packages/switch/src/component.vue +++ b/packages/switch/src/component.vue @@ -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() { diff --git a/packages/theme-chalk/src/collapse.scss b/packages/theme-chalk/src/collapse.scss index d9a97d44c..4c28cd351 100644 --- a/packages/theme-chalk/src/collapse.scss +++ b/packages/theme-chalk/src/collapse.scss @@ -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); } diff --git a/packages/theme-chalk/src/rate.scss b/packages/theme-chalk/src/rate.scss index a9b3f5a2b..a2b30f556 100644 --- a/packages/theme-chalk/src/rate.scss +++ b/packages/theme-chalk/src/rate.scss @@ -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; diff --git a/packages/theme-chalk/src/switch.scss b/packages/theme-chalk/src/switch.scss index 2385cae74..047c17e71 100644 --- a/packages/theme-chalk/src/switch.scss +++ b/packages/theme-chalk/src/switch.scss @@ -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) { diff --git a/packages/theme-chalk/src/upload.scss b/packages/theme-chalk/src/upload.scss index 407163311..328f7b6a5 100644 --- a/packages/theme-chalk/src/upload.scss +++ b/packages/theme-chalk/src/upload.scss @@ -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; } diff --git a/packages/upload/src/upload-list.vue b/packages/upload/src/upload-list.vue index f29ea3b00..763182b44 100644 --- a/packages/upload/src/upload-list.vue +++ b/packages/upload/src/upload-list.vue @@ -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> <!--因为close按钮只在li: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: { diff --git a/packages/upload/src/upload.vue b/packages/upload/src/upload.vue index 6e3598ed9..7aca59d30 100644 --- a/packages/upload/src/upload.vue +++ b/packages/upload/src/upload.vue @@ -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> diff --git a/src/utils/util.js b/src/utils/util.js index f185dcc54..db433b558 100644 --- a/src/utils/util.js +++ b/src/utils/util.js @@ -37,3 +37,8 @@ export const getValueByPath = function(object, prop) { } return result; }; + +export const generateId = function() { + return Math.floor(Math.random() * 10000); +}; +