如何使用 Vue.js 创建无障碍自动完成组件

记住我们已经构建了这个自动完成组件(https://alligator.io/vuejs/vue-autocomplete-component/)吗?虽然大多数用户能够使用它,但需要辅助技术浏览网络的残疾人不会使用它。

在本文中,我们将学习如何使用ARIA属性来使我们的自动完成成为一个完全可访问的属性。

可访问丰富的互联网应用程序(ARIA)

您是否曾尝试过使用辅助技术浏览网络? 大多数操作系统都配备了集成解决方案,在 MacOS 中,您可以打开 VoiceOver,按一下cmd + F5,在 Windows 中,您可以通过按一下Windows 标志键 + Ctrl + Enter来启动 Narrator

当我们使用上述其中一个与 此自动完成组件时,它会告诉我们自动完成是一个文本字段,不会告诉我们选项列表。

ARIA 规范定义了如何使网页内容能够被残疾人使用,通过提供一组属性,使辅助技术软件能够理解内容的语义。

标签 主题

你会惊讶于一个简单的标签可以提高可用性。

让我们快速将我们的组件设置为 应用程序并使用 VoiceOver与它进行交互。

 1[label app.vue]
 2<template>
 3<div id="app">
 4  <div>
 5    <label>Choose a fruit:</label>
 6
 7    <autocomplete
 8      :items="[ 'Apple', 'Banana', 'Orange', 'Mango', 'Pear', 'Peach', 'Grape', 'Tangerine', 'Pineapple']"
 9    />
10  </div>
11</div>
12</template>

当我们允许VoiceOver与我们的组件进行交互时,我们只知道文本字段的存在,但我们不知道标签的用途是什么,因为该标签没有被辅助技术收集。

VoiceOver edit text

通过添加aria-labelaria-labelledby属性,我们将允许用户知道这个输入的用途。

请注意,您可以选择提供aria-label,但由于大多数自动完成的组件附近有标签元素,所以我会利用这一点:

 1[label components/autocomplete.vue]
 2<script>
 3export default {
 4  ...
 5  props {
 6    ...
 7    ariaLabelledBy: {
 8      type: String,
 9      required: true,
10    },
11  };
12};
13</script>
14<template>
15  ...
16  <input
17    type="text"
18    v-model="search"
19    @input="onChange"
20    :aria-labelledby="ariaLabelledBy"
21  />
22  ...
23</template>

我已经使它成为一个必要的属性,以确保没有人忘记添加它. 如果你的应用程序永远不会有围绕组件的标签元素,那么可以更明智地使用aria-label属性。

我们只需要在我们的标签中添加一个ID,并提供它作为一个标签:

 1[label app.vue]
 2<template>
 3<div id="app">
 4  <div>
 5    <label id="fruitLabel">Choose a fruit:</label>
 6
 7    <autocomplete
 8      :items="[ 'Apple', 'Banana', 'Orange', 'Mango', 'Pear', 'Peach', 'Grape', 'Tangerine', 'Pineapple']"
 9      aria-labelled-by="fruitlabel"
10    />
11  </div>
12</div>
13</template>

现在辅助技术能够告诉我们,输入文本的意图是选择一个水果:

Voice Over Choose a Fruit

阿里亚特征

虽然标签可以极大地提高可用性,但它们还不够,用户仍然不知道这是一个自动完成元素。

让我们先了解角色属性是如何工作的。

角色定义元素的元素类型. 在 这里中,您可以检查所有不同类型的角色。

更适合我们的自动装配是combobox之一:

包含单行文本框和其他元素(如列表框或网格)的复合小工具,可动态出现,以帮助用户设置文本框的值。

由于我们组件中的文本输入会显示预期值的结果列表,我们还需要在文本框元素中设置 aria-autocomplete属性。

aria-autocomplete属性允许三个不同的值,一个inline值,定义值完成将发生在文本输入中和一个列表值,这意味着值将在一个单独的元素中存在,而文本输入旁边出现或一个两者值,这意味着将显示一个值列表,当显示时,列表中的一个值将自动选择,并在文本输入中可见。

由于我们的选项列表位于一个单独的元素中,我们需要使用列表值。

仅此属性本身就不知道我们的值列表在文档中的位置,所以我们需要通过使用 aria-controls属性来指定。

我们还需要确保我们的自动完成被识别为 aria-haspopup属性,并且在结果列表可见时,我们的容器有一个 aria-expanded属性集。

最后但不是最重要的是,我们还需要将角色属性添加到我们的输入搜索框值,到ul元素与列表框和每个li角色值。

借助这些属性,辅助技术软件现在可以理解我们正在向用户呈现一个 combobox,它将显示建议值的列表。

 1[label components/autocomplete.vue]
 2<template>
 3  <div
 4    class="autocomplete"
 5    role="combobox"
 6    aria-haspopup="listbox"
 7    aria-owns="autocomplete-results"
 8    :aria-expanded="isOpen"
 9  >
10    <input
11      type="text"
12      @input="onChange"
13      v-model="search"
14      @keyup.down="onArrowDown" @keyup.up="onArrowUp" @keyup.enter="onEnter" aria-multiline="false"
15      role="searchbox"
16      aria-autocomplete="list"
17      aria-controls="autocomplete-results"
18      aria-activedescendant=""
19      :aria-labelledby="ariaLabelledBy"
20    />
21    <ul
22      id="autocomplete-results"
23      v-show="isOpen"
24      class="autocomplete-results"
25      role="listbox"
26    >
27      <li class="loading" v-if="isLoading">
28        Loading results...
29      </li>
30      <li
31        v-else
32        v-for="(result, i) in results"
33        :key="i"
34        @click="setResult(result)" class="autocomplete-result"
35        :class="{ 'is-active': i === arrowCounter }"
36        role="option"
37      >
38        {{ result }}
39      </li>
40    </ul>
41  </div>
42</template>

Voice Over Combobox

箭头支持

记得我们在我们的自动完成组件中添加了键盘支持吗?我们需要使用ARIA属性来管理它。

为了让辅助技术知道当我们使用箭头键时选择哪个选项,我们需要设置两个属性:

需要在输入字段中设置 aria-activedescendant,它将持有视觉识别为具有键盘焦点的选项的ID。

aria-selected则需要在li属性中设置为视觉上作为选定的选项。

我们需要在我们的组件中更新的一件重要事情是倾听器,为了帮助辅助技术正确识别哪个选项是活跃的,我们需要倾听关闭事件而不是关闭事件。


你可以看到完整的源代码在下面的片段或在这个 codepen

  1[label components/autocomplete.vue]
  2<script>
  3  export default {
  4    name: 'autocomplete',
  5    props: {
  6      items: {
  7        type: Array,
  8        required: false,
  9        default: () => [],
 10      },
 11      isAsync: {
 12        type: Boolean,
 13        required: false,
 14        default: false,
 15      },
 16      ariaLabelledBy: {
 17        type: String,
 18        required: true
 19      }
 20    },
 21
 22    data() {
 23      return {
 24        isOpen: false,
 25        results: [],
 26        search: '',
 27        isLoading: false,
 28        arrowCounter: 0,
 29        activedescendant: ''
 30      };
 31    },
 32
 33    methods: {
 34      onChange() {
 35        this.$emit('input', this.search);
 36        if (this.isAsync) {
 37          this.isLoading = true;
 38        } else {
 39          this.filterResults();
 40        }
 41      },
 42
 43      filterResults() {
 44        this.results = this.items.filter((item) => {
 45          return item.toLowerCase().indexOf(this.search.toLowerCase()) > -1;
 46        });
 47      },
 48      setResult(result) {
 49        this.search = result;
 50        this.isOpen = false;
 51      },
 52      onArrowDown(evt) {
 53        if (this.isOpen) {
 54          if (this.arrowCounter < this.results.length) {
 55            this.arrowCounter = this.arrowCounter + 1;
 56            this.setActiveDescendent();
 57          }
 58        }
 59      },
 60      onArrowUp() {
 61        if (this.isOpen) {
 62          if (this.arrowCounter > 0) {
 63            this.arrowCounter = this.arrowCounter -1;
 64            this.setActiveDescendent();
 65          }
 66        }
 67      },
 68      onEnter() {
 69        this.search = this.results[this.arrowCounter];
 70        this.arrowCounter = -1;
 71      },
 72      handleClickOutside(evt) {
 73        if (!this.$el.contains(evt.target)) {
 74          this.isOpen = false;
 75          this.arrowCounter = -1;
 76        }
 77      },
 78      setActiveDescendant() {
 79        this.activedescendant = this.getId(this.arrowCounter);
 80      },
 81      getId(index) {
 82        return `result-item-${index}`;
 83      },
 84      isSelected(i) {
 85        return i === this.arrowCounter;
 86      },
 87    },
 88    watch: {
 89      items: function (val, oldValue) {
 90        // actually compare them
 91        if (val.length !== oldValue.length) {
 92          this.results = val;
 93          this.isLoading = false;
 94        }
 95      },
 96    },
 97    mounted() {
 98      document.addEventListener('click', this.handleClickOutside)
 99    },
100    destroyed() {
101      document.removeEventListener('click', this.handleClickOutside)
102    }
103  };
104</script>
105</script>
106<template>
107  <div
108    class="autocomplete"
109    role="combobox"
110    aria-haspopup="listbox"
111    aria-owns="autocomplete-results"
112    :aria-expanded="isOpen"
113  >
114    <input
115      type="text"
116      @input="onChange"
117      @focus="onFocus"
118      v-model="search"
119      @keydown.down="onArrowDown"
120      @keydown.up="onArrowUp"
121      @keydown.enter="onEnter"
122      role="searchbox"
123      aria-autocomplete="list"
124      aria-controls="autocomplete-results"
125      :aria-labelledby="ariaLabelledBy"
126      :aria-activedescendant="activedescendant"
127    />
128    <ul
129      id="autocomplete-results"
130      v-show="isOpen"
131      class="autocomplete-results"
132      role="listbox"
133    >
134      <li
135        class="loading"
136        v-if="isLoading"
137      >
138        Loading results...
139      </li>
140      <li
141        v-else
142        v-for="(result, i) in results"
143        :key="i"
144        @click="setResult(result)"
145        class="autocomplete-result"
146        :class="{ 'is-active': isSelected(i) }"
147        role="option"
148        :id="getId(i)"
149        :aria-selected="isSelected(i)"
150      >
151        {{ result }}
152      </li>
153    </ul>
154  </div>
155</template>

自动完成可用性Cheatsheet

在这里,您可以找到一个 cheatsheet,其中包含您需要实现自动完成的所有ARIA属性。

QQ 元素 QQ 属性 QQ 值 QQ 使用量 QQ QQ:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-: `--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

<$>[注] IDREF:引用元素的ID属性 <$>

Published At
Categories with 技术
Tagged with
comments powered by Disqus