commit: 6235af4592c52a657415ffae772bd83ec106bc13
parent 4db7f07421a6dc49f07dee9140a024ef5195a934
Author: tusooa <tusooa@kazv.moe>
Date: Sat, 21 Jan 2023 01:07:07 -0500
Make screenreaders read out autocomplete results
Diffstat:
6 files changed, 126 insertions(+), 44 deletions(-)
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
@@ -1,6 +1,7 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import Popover from 'src/components/popover/popover.vue'
+import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@@ -109,9 +110,10 @@ const EmojiInput = {
},
data () {
return {
+ randomSeed: `${Math.random()}`.replace('.', '-'),
input: undefined,
caretEl: undefined,
- highlighted: 0,
+ highlighted: -1,
caret: 0,
focused: false,
blurTimeout: null,
@@ -125,7 +127,8 @@ const EmojiInput = {
components: {
Popover,
EmojiPicker,
- UnicodeDomainIndicator
+ UnicodeDomainIndicator,
+ ScreenReaderNotice
},
computed: {
padEmoji () {
@@ -203,6 +206,12 @@ const EmojiInput = {
top: this.input.scrollTop,
left: this.input.scrollLeft
})
+ },
+ suggestionListId () {
+ return `suggestions-${this.randomSeed}`
+ },
+ suggestionItemId () {
+ return (index) => `suggestion-item-${index}-${this.randomSeed}`
}
},
mounted () {
@@ -278,6 +287,10 @@ const EmojiInput = {
...rest,
img: imageUrl || ''
}))
+ this.$refs.screenReaderNotice.announce(
+ this.$tc('tool_tip.autocomplete_available',
+ this.suggestions.length,
+ { number: this.suggestions.length }))
}
},
methods: {
@@ -374,27 +387,24 @@ const EmojiInput = {
},
cycleBackward (e) {
const len = this.suggestions.length || 0
- if (len > 1) {
- this.highlighted -= 1
- if (this.highlighted < 0) {
- this.highlighted = this.suggestions.length - 1
- }
- e.preventDefault()
- } else {
- this.highlighted = 0
+
+ this.highlighted -= 1
+ if (this.highlighted === -1) {
+ this.input.focus()
+ } else if (this.highlighted < -1) {
+ this.highlighted = len - 1
}
+ e.preventDefault()
},
cycleForward (e) {
const len = this.suggestions.length || 0
- if (len > 1) {
- this.highlighted += 1
- if (this.highlighted >= len) {
- this.highlighted = 0
- }
- e.preventDefault()
- } else {
- this.highlighted = 0
+
+ this.highlighted += 1
+ if (this.highlighted >= len) {
+ this.highlighted = -1
+ this.input.focus()
}
+ e.preventDefault()
},
scrollIntoView () {
const rootRef = this.$refs.picker.$el
@@ -540,6 +550,13 @@ const EmojiInput = {
})
},
resize () {
+ },
+ autoCompleteItemLabel (suggestion) {
+ if (suggestion.user) {
+ return suggestion.displayText + ' ' + suggestion.detailText
+ } else {
+ return this.maybeLocalizedEmojiName(suggestion)
+ }
}
}
}
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
@@ -4,7 +4,13 @@
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
- <slot />
+ <slot
+ :id="'textbox-' + randomSeed"
+ :aria-owns="suggestionListId"
+ aria-autocomplete="both"
+ :aria-expanded="showSuggestions"
+ :aria-activedescendant="(!showSuggestions || highlighted === -1) ? '' : suggestionItemId(highlighted)"
+ />
<!-- TODO: make the 'x' disappear if at the end maybe? -->
<div
ref="hiddenOverlay"
@@ -18,6 +24,10 @@
>x</span>
<span>{{ postText }}</span>
</div>
+ <screen-reader-notice
+ ref="screenReaderNotice"
+ aria-live="assertive"
+ />
<template v-if="enableEmojiPicker">
<button
v-if="!hideEmojiButton"
@@ -46,15 +56,20 @@
>
<template #content>
<div
+ :id="suggestionListId"
ref="panel-body"
class="autocomplete-panel-body"
+ role="listbox"
>
<div
v-for="(suggestion, index) in suggestions"
+ :id="suggestionItemId(index)"
:key="index"
class="autocomplete-item"
- role="button"
+ role="option"
:class="{ highlighted: index === highlighted }"
+ :aria-label="autoCompleteItemLabel(suggestion)"
+ :aria-selected="index === highlighted"
@click.stop.prevent="onClick($event, suggestion)"
>
<span class="image">
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
@@ -148,29 +148,36 @@
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
- <textarea
- ref="textarea"
- v-model="newStatus.status"
- :placeholder="placeholder || $t('post_status.default')"
- rows="1"
- cols="1"
- :disabled="posting && !optimisticPosting"
- class="form-post-body"
- :class="{ 'scrollable-form': !!maxHeight }"
- @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
- @keydown.meta.enter="postStatus($event, newStatus)"
- @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
- @input="resize"
- @compositionupdate="resize"
- @paste="paste"
- />
- <p
- v-if="hasStatusLengthLimit"
- class="character-counter faint"
- :class="{ error: isOverLengthLimit }"
- >
- {{ charactersLeft }}
- </p>
+ <template #default="inputProps">
+ <textarea
+ ref="textarea"
+ v-model="newStatus.status"
+ :placeholder="placeholder || $t('post_status.default')"
+ rows="1"
+ cols="1"
+ :disabled="posting && !optimisticPosting"
+ class="form-post-body"
+ :class="{ 'scrollable-form': !!maxHeight }"
+ v-bind="inputProps"
+ :aria-owns="inputProps.ariaOwns"
+ :aria-autocomplete="inputProps.ariaAutocomplete"
+ :aria-activedescendant="inputProps.ariaActiveDescendant"
+ :aria-expanded="inputProps.ariaExpanded"
+ @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
+ @keydown.meta.enter="postStatus($event, newStatus)"
+ @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
+ @input="resize"
+ @compositionupdate="resize"
+ @paste="paste"
+ />
+ <p
+ v-if="hasStatusLengthLimit"
+ class="character-counter faint"
+ :class="{ error: isOverLengthLimit }"
+ >
+ {{ charactersLeft }}
+ </p>
+ </template>
</EmojiInput>
<div
v-if="!disableScopeSelector"
diff --git a/src/components/screen_reader_notice/screen_reader_notice.js b/src/components/screen_reader_notice/screen_reader_notice.js
@@ -0,0 +1,21 @@
+const ScreenReaderNotice = {
+ props: {
+ ariaLive: {
+ type: String,
+ defualt: 'assertive'
+ }
+ },
+ data () {
+ return {
+ currentText: ''
+ }
+ },
+ methods: {
+ announce (text) {
+ this.currentText = text
+ setTimeout(() => { this.currentText = '' }, 1000)
+ }
+ }
+}
+
+export default ScreenReaderNotice
diff --git a/src/components/screen_reader_notice/screen_reader_notice.vue b/src/components/screen_reader_notice/screen_reader_notice.vue
@@ -0,0 +1,21 @@
+<template>
+ <div
+ class="screen-reader-text"
+ :aria-live="ariaLive"
+ >
+ {{ currentText }}
+ </div>
+</template>
+
+<script src="./screen_reader_notice.js"></script>
+
+<style lang="scss">
+.screen-reader-text {
+ display: block;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ visibility: visible;
+}
+</style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -996,7 +996,8 @@
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full",
- "toggle_mute": "Expand or collapse notification to reveal muted content"
+ "toggle_mute": "Expand or collapse notification to reveal muted content",
+ "autocomplete_available": "{number} result is available | {number} results are available"
},
"upload": {
"error": {