Make screenreaders read out autocomplete results

このコミットが含まれているのは:
tusooa 2023-01-21 01:07:07 -05:00
コミット 6235af4592
この署名に対応する既知のキーがデータベースに存在しません
GPGキーID: 7B467EDE43A08224
6個のファイルの変更126行の追加44行の削除

ファイルの表示

@ -1,6 +1,7 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue' import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import Popover from 'src/components/popover/popover.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 UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -109,9 +110,10 @@ const EmojiInput = {
}, },
data () { data () {
return { return {
randomSeed: `${Math.random()}`.replace('.', '-'),
input: undefined, input: undefined,
caretEl: undefined, caretEl: undefined,
highlighted: 0, highlighted: -1,
caret: 0, caret: 0,
focused: false, focused: false,
blurTimeout: null, blurTimeout: null,
@ -125,7 +127,8 @@ const EmojiInput = {
components: { components: {
Popover, Popover,
EmojiPicker, EmojiPicker,
UnicodeDomainIndicator UnicodeDomainIndicator,
ScreenReaderNotice
}, },
computed: { computed: {
padEmoji () { padEmoji () {
@ -203,6 +206,12 @@ const EmojiInput = {
top: this.input.scrollTop, top: this.input.scrollTop,
left: this.input.scrollLeft left: this.input.scrollLeft
}) })
},
suggestionListId () {
return `suggestions-${this.randomSeed}`
},
suggestionItemId () {
return (index) => `suggestion-item-${index}-${this.randomSeed}`
} }
}, },
mounted () { mounted () {
@ -278,6 +287,10 @@ const EmojiInput = {
...rest, ...rest,
img: imageUrl || '' img: imageUrl || ''
})) }))
this.$refs.screenReaderNotice.announce(
this.$tc('tool_tip.autocomplete_available',
this.suggestions.length,
{ number: this.suggestions.length }))
} }
}, },
methods: { methods: {
@ -374,27 +387,24 @@ const EmojiInput = {
}, },
cycleBackward (e) { cycleBackward (e) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (len > 1) {
this.highlighted -= 1 this.highlighted -= 1
if (this.highlighted < 0) { if (this.highlighted === -1) {
this.highlighted = this.suggestions.length - 1 this.input.focus()
} } else if (this.highlighted < -1) {
e.preventDefault() this.highlighted = len - 1
} else {
this.highlighted = 0
} }
e.preventDefault()
}, },
cycleForward (e) { cycleForward (e) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (len > 1) {
this.highlighted += 1 this.highlighted += 1
if (this.highlighted >= len) { if (this.highlighted >= len) {
this.highlighted = 0 this.highlighted = -1
} this.input.focus()
e.preventDefault()
} else {
this.highlighted = 0
} }
e.preventDefault()
}, },
scrollIntoView () { scrollIntoView () {
const rootRef = this.$refs.picker.$el const rootRef = this.$refs.picker.$el
@ -540,6 +550,13 @@ const EmojiInput = {
}) })
}, },
resize () { resize () {
},
autoCompleteItemLabel (suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
} }
} }
} }

ファイルの表示

@ -4,7 +4,13 @@
class="emoji-input" class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }" :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? --> <!-- TODO: make the 'x' disappear if at the end maybe? -->
<div <div
ref="hiddenOverlay" ref="hiddenOverlay"
@ -18,6 +24,10 @@
>x</span> >x</span>
<span>{{ postText }}</span> <span>{{ postText }}</span>
</div> </div>
<screen-reader-notice
ref="screenReaderNotice"
aria-live="assertive"
/>
<template v-if="enableEmojiPicker"> <template v-if="enableEmojiPicker">
<button <button
v-if="!hideEmojiButton" v-if="!hideEmojiButton"
@ -46,15 +56,20 @@
> >
<template #content> <template #content>
<div <div
:id="suggestionListId"
ref="panel-body" ref="panel-body"
class="autocomplete-panel-body" class="autocomplete-panel-body"
role="listbox"
> >
<div <div
v-for="(suggestion, index) in suggestions" v-for="(suggestion, index) in suggestions"
:id="suggestionItemId(index)"
:key="index" :key="index"
class="autocomplete-item" class="autocomplete-item"
role="button" role="option"
:class="{ highlighted: index === highlighted }" :class="{ highlighted: index === highlighted }"
:aria-label="autoCompleteItemLabel(suggestion)"
:aria-selected="index === highlighted"
@click.stop.prevent="onClick($event, suggestion)" @click.stop.prevent="onClick($event, suggestion)"
> >
<span class="image"> <span class="image">

ファイルの表示

@ -148,29 +148,36 @@
@sticker-upload-failed="uploadFailed" @sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow" @shown="handleEmojiInputShow"
> >
<textarea <template #default="inputProps">
ref="textarea" <textarea
v-model="newStatus.status" ref="textarea"
:placeholder="placeholder || $t('post_status.default')" v-model="newStatus.status"
rows="1" :placeholder="placeholder || $t('post_status.default')"
cols="1" rows="1"
:disabled="posting && !optimisticPosting" cols="1"
class="form-post-body" :disabled="posting && !optimisticPosting"
:class="{ 'scrollable-form': !!maxHeight }" class="form-post-body"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.meta.enter="postStatus($event, newStatus)" v-bind="inputProps"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" :aria-owns="inputProps.ariaOwns"
@input="resize" :aria-autocomplete="inputProps.ariaAutocomplete"
@compositionupdate="resize" :aria-activedescendant="inputProps.ariaActiveDescendant"
@paste="paste" :aria-expanded="inputProps.ariaExpanded"
/> @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
<p @keydown.meta.enter="postStatus($event, newStatus)"
v-if="hasStatusLengthLimit" @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
class="character-counter faint" @input="resize"
:class="{ error: isOverLengthLimit }" @compositionupdate="resize"
> @paste="paste"
{{ charactersLeft }} />
</p> <p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput> </EmojiInput>
<div <div
v-if="!disableScopeSelector" v-if="!disableScopeSelector"

ファイルの表示

@ -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

ファイルの表示

@ -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>

ファイルの表示

@ -996,7 +996,8 @@
"reject_follow_request": "Reject follow request", "reject_follow_request": "Reject follow request",
"bookmark": "Bookmark", "bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full", "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": { "upload": {
"error": { "error": {