Merge branch 'from/develop/tusooa/autocomplete-accessibility' into 'develop'

Autocomplete accessibility

Closes #1219

See merge request pleroma/pleroma-fe!1771
This commit is contained in:
HJ 2023-01-28 23:04:59 +00:00
commit f229c4a106
14 changed files with 202 additions and 68 deletions

View File

@ -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,11 @@ const EmojiInput = {
...rest, ...rest,
img: imageUrl || '' img: imageUrl || ''
})) }))
this.highlighted = -1
this.$refs.screenReaderNotice.announce(
this.$tc('tool_tip.autocomplete_available',
this.suggestions.length,
{ number: this.suggestions.length }))
} }
}, },
methods: { methods: {
@ -374,26 +388,27 @@ 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) {
this.highlighted = len - 1
}
if (len > 0) {
e.preventDefault() e.preventDefault()
} else {
this.highlighted = 0
} }
}, },
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()
}
if (len > 0) {
e.preventDefault() e.preventDefault()
} else {
this.highlighted = 0
} }
}, },
scrollIntoView () { scrollIntoView () {
@ -540,6 +555,13 @@ const EmojiInput = {
}) })
}, },
resize () { resize () {
},
autoCompleteItemLabel (suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
} }
} }
} }

View File

@ -4,12 +4,19 @@
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"
class="hidden-overlay" class="hidden-overlay"
:style="overlayStyle" :style="overlayStyle"
:aria-hidden="true"
> >
<span>{{ preText }}</span> <span>{{ preText }}</span>
<span <span
@ -18,11 +25,16 @@
>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"
class="button-unstyled emoji-picker-icon" class="button-unstyled emoji-picker-icon"
type="button" type="button"
:title="$t('emoji.add_emoji')"
@click.prevent="togglePicker" @click.prevent="togglePicker"
> >
<FAIcon :icon="['far', 'smile-beam']" /> <FAIcon :icon="['far', 'smile-beam']" />
@ -43,17 +55,24 @@
ref="suggestorPopover" ref="suggestorPopover"
class="autocomplete-panel" class="autocomplete-panel"
placement="bottom" placement="bottom"
:trigger-attrs="{ 'aria-hidden': true }"
> >
<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="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">

View File

@ -3,6 +3,7 @@
ref="popover" ref="popover"
trigger="click" trigger="click"
popover-class="emoji-picker popover-default" popover-class="emoji-picker popover-default"
:trigger-attrs="{ 'aria-hidden': true }"
@show="onPopoverShown" @show="onPopoverShown"
@close="onPopoverClosed" @close="onPopoverClosed"
> >

View File

@ -8,6 +8,7 @@ import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { reject, map, uniqBy, debounce } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
@ -629,6 +630,9 @@ const PostStatusForm = {
}, },
openProfileTab () { openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile') this.$store.dispatch('openSettingsModalTab', 'profile')
},
propsToNative (props) {
return propsToNative(props)
} }
} }
} }

View File

@ -30,6 +30,9 @@
<span>{{ $t('post_status.scope_notice.public') }}</span> <span>{{ $t('post_status.scope_notice.public') }}</span>
<a <a
class="fa-scale-110 fa-old-padding dismiss" class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()" @click.prevent="dismissScopeNotice()"
> >
<FAIcon icon="times" /> <FAIcon icon="times" />
@ -42,6 +45,9 @@
<span>{{ $t('post_status.scope_notice.unlisted') }}</span> <span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a <a
class="fa-scale-110 fa-old-padding dismiss" class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()" @click.prevent="dismissScopeNotice()"
> >
<FAIcon icon="times" /> <FAIcon icon="times" />
@ -54,6 +60,9 @@
<span>{{ $t('post_status.scope_notice.private') }}</span> <span>{{ $t('post_status.scope_notice.private') }}</span>
<a <a
class="fa-scale-110 fa-old-padding dismiss" class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()" @click.prevent="dismissScopeNotice()"
> >
<FAIcon icon="times" /> <FAIcon icon="times" />
@ -124,14 +133,17 @@
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
class="form-control" class="form-control"
> >
<input <template #default="inputProps">
v-model="newStatus.spoilerText" <input
type="text" v-model="newStatus.spoilerText"
:placeholder="$t('post_status.content_warning')" type="text"
:disabled="posting && !optimisticPosting" :placeholder="$t('post_status.content_warning')"
size="1" :disabled="posting && !optimisticPosting"
class="form-post-subject" v-bind="propsToNative(inputProps)"
> size="1"
class="form-post-subject"
>
</template>
</EmojiInput> </EmojiInput>
<EmojiInput <EmojiInput
ref="emoji-input" ref="emoji-input"
@ -148,29 +160,32 @@
@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="propsToNative(inputProps)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@input="resize" @keydown.meta.enter="postStatus($event, newStatus)"
@compositionupdate="resize" @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@paste="paste" @input="resize"
/> @compositionupdate="resize"
<p @paste="paste"
v-if="hasStatusLengthLimit" />
class="character-counter faint" <p
:class="{ error: isOverLengthLimit }" v-if="hasStatusLengthLimit"
> class="character-counter faint"
{{ charactersLeft }} :class="{ error: isOverLengthLimit }"
</p> >
{{ charactersLeft }}
</p>
</template>
</EmojiInput> </EmojiInput>
<div <div
v-if="!disableScopeSelector" v-if="!disableScopeSelector"
@ -193,6 +208,7 @@
id="post-content-type" id="post-content-type"
v-model="newStatus.contentType" v-model="newStatus.contentType"
class="form-control" class="form-control"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
> >
<option <option
v-for="postFormat in postFormats" v-for="postFormat in postFormats"

View File

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

View File

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

View File

@ -13,6 +13,7 @@ export default {
'modelValue', 'modelValue',
'disabled', 'disabled',
'unstyled', 'unstyled',
'kind' 'kind',
'attrs'
] ]
} }

View File

@ -6,6 +6,7 @@
<select <select
:disabled="disabled" :disabled="disabled"
:value="modelValue" :value="modelValue"
v-bind="attrs"
@change="$emit('update:modelValue', $event.target.value)" @change="$emit('update:modelValue', $event.target.value)"
> >
<slot /> <slot />

View File

@ -12,6 +12,7 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
import BooleanSetting from '../helpers/boolean_setting.vue' import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js' import localeService from 'src/services/locale/locale.service.js'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -261,6 +262,9 @@ const ProfileTab = {
messageArgs: [error.message], messageArgs: [error.message],
level: 'error' level: 'error'
}) })
},
propsToNative (props) {
return propsToNative(props)
} }
} }
} }

View File

@ -8,11 +8,14 @@
enable-emoji-picker enable-emoji-picker
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
> >
<input <template #default="inputProps">
id="username" <input
v-model="newName" id="username"
class="name-changer" v-model="newName"
> class="name-changer"
v-bind="propsToNative(inputProps)"
>
</template>
</EmojiInput> </EmojiInput>
<p>{{ $t('settings.bio') }}</p> <p>{{ $t('settings.bio') }}</p>
<EmojiInput <EmojiInput
@ -20,10 +23,13 @@
enable-emoji-picker enable-emoji-picker
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
> >
<textarea <template #default="inputProps">
v-model="newBio" <textarea
class="bio resize-height" v-model="newBio"
/> class="bio resize-height"
v-bind="propsToNative(inputProps)"
/>
</template>
</EmojiInput> </EmojiInput>
<p v-if="role === 'admin' || role === 'moderator'"> <p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole"> <Checkbox v-model="showRole">
@ -60,10 +66,13 @@
hide-emoji-button hide-emoji-button
:suggest="userSuggestor" :suggest="userSuggestor"
> >
<input <template #default="inputProps">
v-model="newFields[i].name" <input
:placeholder="$t('settings.profile_fields.name')" v-model="newFields[i].name"
> :placeholder="$t('settings.profile_fields.name')"
v-bind="propsToNative(inputProps)"
>
</template>
</EmojiInput> </EmojiInput>
<EmojiInput <EmojiInput
v-model="newFields[i].value" v-model="newFields[i].value"
@ -71,10 +80,13 @@
hide-emoji-button hide-emoji-button
:suggest="userSuggestor" :suggest="userSuggestor"
> >
<input <template #default="inputProps">
v-model="newFields[i].value" <input
:placeholder="$t('settings.profile_fields.value')" v-model="newFields[i].value"
> :placeholder="$t('settings.profile_fields.value')"
v-bind="propsToNative(inputProps)"
>
</template>
</EmojiInput> </EmojiInput>
<button <button
class="delete-field button-unstyled -hover-highlight" class="delete-field button-unstyled -hover-highlight"

View File

@ -271,6 +271,7 @@
"text/markdown": "Markdown", "text/markdown": "Markdown",
"text/bbcode": "BBCode" "text/bbcode": "BBCode"
}, },
"content_type_selection": "Post format",
"content_warning": "Subject (optional)", "content_warning": "Subject (optional)",
"default": "Just landed in L.A.", "default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_all": "This post will be visible to all the mentioned users.",
@ -288,6 +289,7 @@
"private": "This post will be visible to your followers only", "private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network" "unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
}, },
"scope_notice_dismiss": "Close this notice",
"scope": { "scope": {
"direct": "Direct - post to mentioned users only", "direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only", "private": "Followers-only - post to followers only",
@ -1056,7 +1058,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. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them."
}, },
"upload": { "upload": {
"error": { "error": {

View File

@ -0,0 +1,8 @@
import { kebabCase } from 'lodash'
const propsToNative = props => Object.keys(props).reduce((acc, cur) => {
acc[kebabCase(cur)] = props[cur]
return acc
}, {})
export { propsToNative }

View File

@ -14,7 +14,8 @@ const generateInput = (value, padEmoji = true) => {
padEmoji padEmoji
} }
} }
} },
$t: (msg) => msg
}, },
stubs: { stubs: {
FAIcon: true FAIcon: true