Browse Source

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

Autocomplete accessibility

Closes #1219

See merge request pleroma/pleroma-fe!1771
HJ 1 year ago
parent
commit
f229c4a106

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

+ 20 - 1
src/components/emoji_input/emoji_input.vue

@@ -4,12 +4,19 @@
     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"
       class="hidden-overlay"
       :style="overlayStyle"
+      :aria-hidden="true"
     >
       <span>{{ preText }}</span>
       <span
@@ -18,11 +25,16 @@
       >x</span>
       <span>{{ postText }}</span>
     </div>
+    <screen-reader-notice
+      ref="screenReaderNotice"
+      aria-live="assertive"
+    />
     <template v-if="enableEmojiPicker">
       <button
         v-if="!hideEmojiButton"
         class="button-unstyled emoji-picker-icon"
         type="button"
+        :title="$t('emoji.add_emoji')"
         @click.prevent="togglePicker"
       >
         <FAIcon :icon="['far', 'smile-beam']" />
@@ -43,17 +55,24 @@
       ref="suggestorPopover"
       class="autocomplete-panel"
       placement="bottom"
+      :trigger-attrs="{ 'aria-hidden': true }"
     >
       <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="option"
             :class="{ highlighted: index === highlighted }"
+            :aria-label="autoCompleteItemLabel(suggestion)"
+            :aria-selected="index === highlighted"
             @click.stop.prevent="onClick($event, suggestion)"
           >
             <span class="image">

+ 1 - 0
src/components/emoji_picker/emoji_picker.vue

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

+ 4 - 0
src/components/post_status_form/post_status_form.js

@@ -8,6 +8,7 @@ import Gallery from 'src/components/gallery/gallery.vue'
 import StatusContent from '../status_content/status_content.vue'
 import fileTypeService from '../../services/file_type/file_type.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 suggestor from '../emoji_input/suggestor.js'
 import { mapGetters, mapState } from 'vuex'
@@ -629,6 +630,9 @@ const PostStatusForm = {
     },
     openProfileTab () {
       this.$store.dispatch('openSettingsModalTab', 'profile')
+    },
+    propsToNative (props) {
+      return propsToNative(props)
     }
   }
 }

+ 47 - 31
src/components/post_status_form/post_status_form.vue

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

+ 21 - 0
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

+ 21 - 0
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>

+ 2 - 1
src/components/select/select.js

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

+ 1 - 0
src/components/select/select.vue

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

+ 4 - 0
src/components/settings_modal/tabs/profile_tab.js

@@ -12,6 +12,7 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
 import BooleanSetting from '../helpers/boolean_setting.vue'
 import SharedComputedObject from '../helpers/shared_computed_object.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 {
@@ -261,6 +262,9 @@ const ProfileTab = {
         messageArgs: [error.message],
         level: 'error'
       })
+    },
+    propsToNative (props) {
+      return propsToNative(props)
     }
   }
 }

+ 29 - 17
src/components/settings_modal/tabs/profile_tab.vue

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

+ 4 - 1
src/i18n/en.json

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

+ 8 - 0
src/services/attributes_helper/attributes_helper.service.js

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

+ 2 - 1
test/unit/specs/components/emoji_input.spec.js

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