feat(components): [mention] accessibility enhancement (#17848)

This commit is contained in:
qiang 2024-08-13 17:59:57 +08:00 committed by GitHub
parent aca1b0ac58
commit d59cdc9855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 66 additions and 2 deletions

View File

@ -86,4 +86,35 @@ describe('Mention.vue', () => {
4 4
) )
}) })
test('It should generate accessible attributes', async () => {
const wrapper = mount(Mention, {
attachTo: document.body,
props: { options },
})
const input = wrapper.find('input')
expect(input.attributes('role')).toBe(undefined)
expect(input.attributes('aria-autocomplete')).toBe(undefined)
expect(input.attributes('aria-controls')).toBe(undefined)
expect(input.attributes('aria-expanded')).toBe(undefined)
expect(input.attributes('aria-haspopup')).toBe(undefined)
expect(input.attributes('aria-activedescendant')).toBe(undefined)
wrapper.find('input').trigger('focus')
input.element.value = '@'
wrapper.find('input').trigger('input')
await sleep(150)
const dropdown = wrapper.findComponent({ name: 'ElMentionDropdown' })
const list = dropdown.find('.el-mention-dropdown__list')
const option = dropdown.find('.el-mention-dropdown__item')
expect(list.attributes('id')).toBeTruthy()
expect(list.attributes('role')).toBe('listbox')
expect(list.attributes('aria-orientation')).toBe('vertical')
expect(option.attributes('id')).toBeTruthy()
expect(option.attributes('role')).toBe('option')
expect(option.attributes('aria-disabled')).toBe(undefined)
expect(option.attributes('aria-selected')).toBe('true')
})
}) })

View File

@ -9,6 +9,8 @@ export const mentionDropdownProps = buildProps({
}, },
loading: Boolean, loading: Boolean,
disabled: Boolean, disabled: Boolean,
contentId: String,
ariaLabel: String,
}) })
export const mentionDropdownEmits = { export const mentionDropdownEmits = {

View File

@ -5,16 +5,24 @@
</div> </div>
<el-scrollbar <el-scrollbar
v-show="options.length > 0 && !loading" v-show="options.length > 0 && !loading"
:id="contentId"
ref="scrollbarRef" ref="scrollbarRef"
tag="ul" tag="ul"
:wrap-class="ns.be('dropdown', 'wrap')" :wrap-class="ns.be('dropdown', 'wrap')"
:view-class="ns.be('dropdown', 'list')" :view-class="ns.be('dropdown', 'list')"
role="listbox"
:aria-label="ariaLabel"
aria-orientation="vertical"
> >
<li <li
v-for="(item, index) in options" v-for="(item, index) in options"
:id="`${contentId}-${index}`"
ref="optionRefs" ref="optionRefs"
:key="item.value" :key="item.value"
:class="optionkls(item, index)" :class="optionkls(item, index)"
role="option"
:aria-disabled="item.disabled || disabled || undefined"
:aria-selected="hoveringIndex === index"
@mouseenter="handleMouseEnter(index)" @mouseenter="handleMouseEnter(index)"
@click.stop="handleSelect(item)" @click.stop="handleSelect(item)"
> >
@ -134,6 +142,7 @@ watch(() => props.options, resetHoveringIndex, {
}) })
defineExpose({ defineExpose({
hoveringIndex,
navigateOptions, navigateOptions,
selectHoverOption, selectHoverOption,
hoverOption, hoverOption,

View File

@ -4,6 +4,13 @@
v-bind="mergeProps(passInputProps, $attrs)" v-bind="mergeProps(passInputProps, $attrs)"
ref="elInputRef" ref="elInputRef"
:model-value="modelValue" :model-value="modelValue"
:role="dropdownVisible ? 'combobox' : undefined"
:aria-activedescendant="dropdownVisible ? hoveringId || '' : undefined"
:aria-controls="dropdownVisible ? contentId : undefined"
:aria-expanded="dropdownVisible || undefined"
:aria-label="ariaLabel"
:aria-autocomplete="dropdownVisible ? 'none' : undefined"
:aria-haspopup="dropdownVisible ? 'listbox' : undefined"
@input="handleInputChange" @input="handleInputChange"
@keydown="handleInputKeyDown" @keydown="handleInputKeyDown"
@mousedown="handleInputMouseDown" @mousedown="handleInputMouseDown"
@ -14,7 +21,7 @@
</el-input> </el-input>
<el-tooltip <el-tooltip
ref="tooltipRef" ref="tooltipRef"
:visible="visible && (!!filteredOptions.length || loading)" :visible="dropdownVisible"
:popper-class="[ns.e('popper'), popperClass]" :popper-class="[ns.e('popper'), popperClass]"
:popper-options="popperOptions" :popper-options="popperOptions"
:placement="computedPlacement" :placement="computedPlacement"
@ -33,6 +40,8 @@
:options="filteredOptions" :options="filteredOptions"
:disabled="disabled" :disabled="disabled"
:loading="loading" :loading="loading"
:content-id="contentId"
:aria-label="ariaLabel"
@select="handleSelect" @select="handleSelect"
@click.stop="elInputRef?.focus" @click.stop="elInputRef?.focus"
> >
@ -48,7 +57,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, mergeProps, nextTick, ref } from 'vue' import { computed, mergeProps, nextTick, ref } from 'vue'
import { pick } from 'lodash-unified' import { pick } from 'lodash-unified'
import { useFocusController, useNamespace } from '@element-plus/hooks' import { useFocusController, useId, useNamespace } from '@element-plus/hooks'
import ElInput, { inputProps } from '@element-plus/components/input' import ElInput, { inputProps } from '@element-plus/components/input'
import ElTooltip from '@element-plus/components/tooltip' import ElTooltip from '@element-plus/components/tooltip'
import { UPDATE_MODEL_EVENT } from '@element-plus/constants' import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
@ -73,6 +82,7 @@ const emit = defineEmits(mentionEmits)
const passInputProps = computed(() => pick(props, Object.keys(inputProps))) const passInputProps = computed(() => pick(props, Object.keys(inputProps)))
const ns = useNamespace('mention') const ns = useNamespace('mention')
const contentId = useId()
const elInputRef = ref<InputInstance>() const elInputRef = ref<InputInstance>()
const tooltipRef = ref<TooltipInstance>() const tooltipRef = ref<TooltipInstance>()
@ -98,6 +108,14 @@ const filteredOptions = computed(() => {
) )
}) })
const dropdownVisible = computed(() => {
return visible.value && (!!filteredOptions.value.length || props.loading)
})
const hoveringId = computed(() => {
return `${contentId.value}-${dropdownRef.value?.hoveringIndex}`
})
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
emit('update:modelValue', value) emit('update:modelValue', value)
syncAfterCursorMove() syncAfterCursorMove()
@ -121,6 +139,10 @@ const handleInputKeyDown = (e: KeyboardEvent | Event) => {
} else { } else {
visible.value = false visible.value = false
} }
} else if (['Escape'].includes(e.key)) {
if (!visible.value) return
e.preventDefault()
visible.value = false
} else if (['Backspace'].includes(e.key)) { } else if (['Backspace'].includes(e.key)) {
if (props.whole && mentionCtx.value) { if (props.whole && mentionCtx.value) {
const { splitIndex, selectionEnd, pattern, prefixIndex, prefix } = const { splitIndex, selectionEnd, pattern, prefixIndex, prefix } =