Просмотр исходного кода

Feature: Mobile - Improve file preview accessibility

Vladimir Sheremet 1 год назад
Родитель
Сommit
5ef83f3abb

+ 18 - 7
app/frontend/apps/mobile/components/CommonFilePreview/CommonFilePreview.vue

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
   sizeClass: 'text-white/80',
 })
 
-defineEmits<{
+const emit = defineEmits<{
   (e: 'remove'): void
   (e: 'preview', $event: Event): void
 }>()
@@ -64,23 +64,33 @@ const ariaLabel = computed(() => {
     return i18n.t('Open %s', props.file.name) // opens file in another tab
   return props.file.name // cannot download and preview, probably just uploaded pdf
 })
+
+const onFileClick = (event: Event) => {
+  if (canPreview.value) {
+    event.preventDefault()
+    emit('preview', event)
+  }
+}
 </script>
 
 <template>
   <div
-    class="mb-2 flex w-full items-center gap-2 rounded-2xl border-[0.5px] p-3 last:mb-0"
+    class="mb-2 flex w-full items-center gap-2 rounded-2xl border-[0.5px] p-3 outline-none last:mb-0 focus-within:bg-blue-highlight"
     :class="wrapperClass"
   >
     <Component
       :is="componentType"
-      class="flex w-full select-none items-center gap-2 overflow-hidden text-left"
-      :type="componentType === 'button' && 'button'"
+      class="flex w-full select-none items-center gap-2 overflow-hidden text-left outline-none"
+      :type="componentType === 'button' ? 'button' : undefined"
       :class="{ 'cursor-pointer': componentType !== 'div' }"
       :aria-label="ariaLabel"
+      tabindex="0"
       :link="downloadUrl"
-      :download="canDownload ? true : undefined"
-      :target="!canDownload ? '_blank' : ''"
-      @click="canPreview && $emit('preview', $event)"
+      :download="canDownload ? file.name : undefined"
+      :target="!canDownload ? '_blank' : undefined"
+      @click="onFileClick"
+      @keydown.delete.prevent="$emit('remove')"
+      @keydown.backspace.prevent="$emit('remove')"
     >
       <div
         v-if="!noPreview"
@@ -109,6 +119,7 @@ const ariaLabel = computed(() => {
     <button
       v-if="!noRemove"
       type="button"
+      tabindex="-1"
       :aria-label="i18n.t('Remove %s', file.name)"
       @click.stop.prevent="$emit('remove')"
       @keypress.space.prevent="$emit('remove')"

+ 1 - 1
app/frontend/apps/mobile/components/CommonFilePreview/__tests__/CommonFilePreview.spec.ts

@@ -87,7 +87,7 @@ describe('preview file component', () => {
     const view = renderFilePreview({
       file: {
         name: 'name.pdf',
-        type: 'application/pdf',
+        type: 'text/html',
         size: 1025,
       },
       downloadUrl: '#/api/url',

+ 1 - 1
app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorActionBar.vue

@@ -84,7 +84,7 @@ const hideAfterLeaving = () => {
   emit('hide')
 }
 
-useTraverseOptions(actionBar, { direction: 'horizontal' })
+useTraverseOptions(actionBar, { direction: 'horizontal', ignoreTabindex: true })
 </script>
 
 <template>

+ 4 - 0
app/frontend/shared/components/Form/fields/FieldEditor/__tests__/FieldEditorActionBar.spec.ts

@@ -45,6 +45,7 @@ describe('keyboard interactions', () => {
     await view.events.keyboard('{ArrowRight}')
     expect(actions[0]).toHaveFocus()
   })
+
   it('can use home and end to traverse toolbar', async () => {
     const view = renderComponent(FieldEditorActionBar, {
       props: {
@@ -64,6 +65,7 @@ describe('keyboard interactions', () => {
     await view.events.keyboard('{End}')
     expect(actions.at(-1)).toHaveFocus()
   })
+
   it('hides on blur', async () => {
     const view = renderComponent(FieldEditorActionBar, {
       props: {
@@ -78,6 +80,7 @@ describe('keyboard interactions', () => {
 
     expect(view.emitted().hide).toBeTruthy()
   })
+
   it('hides on escape', async () => {
     const view = renderComponent(FieldEditorActionBar, {
       props: {
@@ -93,6 +96,7 @@ describe('keyboard interactions', () => {
     // emits blur, because toolbar is not hidden, but focus is shifted to the editor instead
     expect(view.emitted().blur).toBeTruthy()
   })
+
   it('hides on click outside', async () => {
     const view = renderComponent(FieldEditorActionBar, {
       props: {

+ 19 - 4
app/frontend/shared/components/Form/fields/FieldFile/FieldFileInput.vue

@@ -7,6 +7,7 @@ import useImageViewer from '@shared/composables/useImageViewer'
 import { convertFileList } from '@shared/utils/files'
 import useConfirmation from '@mobile/components/CommonConfirmation/composable'
 import CommonFilePreview from '@mobile/components/CommonFilePreview/CommonFilePreview.vue'
+import { useTraverseOptions } from '@shared/composables/useTraverseOptions'
 import { useFormUploadCacheAddMutation } from './graphql/mutations/uploadCache/add.api'
 import { useFormUploadCacheRemoveMutation } from './graphql/mutations/uploadCache/remove.api'
 import type { FieldFileProps, FileUploaded } from './types'
@@ -76,10 +77,14 @@ const { waitForConfirmation } = useConfirmation()
 const removeFile = async (file: FileUploaded) => {
   const fileId = file.id
 
-  const confirmed = await waitForConfirmation(__('Are you sure?'), {
-    buttonTitle: 'Delete',
-    buttonVariant: 'danger',
-  })
+  const confirmed = await waitForConfirmation(
+    __('Are you sure you want to delete "%s"?'),
+    {
+      headingPlaceholder: [file.name],
+      buttonTitle: __('Delete'),
+      buttonVariant: 'danger',
+    },
+  )
 
   if (!confirmed) return
 
@@ -123,6 +128,12 @@ const onFilesScroll = (event: UIEvent) => {
 }
 
 const { showImage } = useImageViewer(uploadFiles)
+
+const filesContainer = ref<HTMLDivElement>()
+
+useTraverseOptions(filesContainer, {
+  direction: 'vertical',
+})
 </script>
 
 <template>
@@ -131,6 +142,7 @@ const { showImage } = useImageViewer(uploadFiles)
   </div>
   <div
     v-if="uploadFiles.length"
+    ref="filesContainer"
     class="max-h-48 overflow-auto px-4 pt-4"
     :class="{
       'opacity-60': !canInteract,
@@ -142,6 +154,7 @@ const { showImage } = useImageViewer(uploadFiles)
       :key="uploadFile.id || `${uploadFile.name}${idx}`"
       :file="uploadFile"
       :preview-url="uploadFile.preview || uploadFile.content"
+      :download-url="uploadFile.content"
       @preview="canInteract && showImage(uploadFile)"
       @remove="canInteract && removeFile(uploadFile)"
     />
@@ -155,6 +168,7 @@ const { showImage } = useImageViewer(uploadFiles)
   <button
     class="flex w-full items-center justify-center gap-1 p-4 text-blue"
     type="button"
+    tabindex="0"
     :class="{ 'text-blue/60': !canInteract }"
     :disabled="!canInteract"
     @click="canInteract && fileInput?.click()"
@@ -170,6 +184,7 @@ const { showImage } = useImageViewer(uploadFiles)
     type="file"
     :name="context.node.name"
     class="hidden"
+    tabindex="-1"
     aria-hidden="true"
     :accept="context.accept"
     :capture="context.capture"

+ 8 - 5
app/frontend/shared/components/Form/fields/FieldFile/__tests__/FieldFile.spec.ts

@@ -24,6 +24,7 @@ const renderFileInput = (props: Record<string, unknown> = {}) => {
     },
     form: true,
     confirmation: true,
+    router: true,
   })
 }
 
@@ -91,7 +92,7 @@ describe('Fields - FieldFile', () => {
       'Attach another file',
     )
 
-    const filePreview = view.getByRole('button', { name: 'Preview foo.png' })
+    const filePreview = view.getByRole('link', { name: 'Preview foo.png' })
     expect(filePreview).toBeInTheDocument()
 
     await view.events.click(filePreview)
@@ -124,13 +125,15 @@ describe('Fields - FieldFile', () => {
       },
     ])
 
-    const filePreview = await view.findByRole('button', {
+    const filePreview = await view.findByRole('link', {
       name: 'Preview bar.png',
     })
     expect(filePreview).toBeInTheDocument()
   })
 
-  it('renders non-images', async () => {
+  it('renders non-images', async (ctx) => {
+    ctx.skipConsole = true
+
     const file = new File([], 'foo.txt', { type: 'text/plain' })
     const { view } = await uploadFiles([file])
 
@@ -158,11 +161,11 @@ describe('Fields - FieldFile', () => {
       `data:image/png;base64,${base64('image2')}`,
     ]
 
-    const elementImage1 = view.getByRole('button', {
+    const elementImage1 = view.getByRole('link', {
       name: 'Preview image1.png',
     })
     const elementPdf = view.getByText('pdf.pdf')
-    const elementImage2 = view.getByRole('button', {
+    const elementImage2 = view.getByRole('link', {
       name: 'Preview image2.png',
     })
 

+ 6 - 1
app/frontend/shared/composables/useTraverseOptions.ts

@@ -2,18 +2,22 @@
 
 import stopEvent from '@shared/utils/events'
 import { getFocusableElements } from '@shared/utils/getFocusableElements'
+import type { FocusableOptions } from '@shared/utils/getFocusableElements'
 import { onKeyStroke, unrefElement } from '@vueuse/core'
 import type { MaybeComputedRef } from '@vueuse/shared'
 
 type TraverseDirection = 'horizontal' | 'vertical' | 'mixed'
 
-interface TraverseOptions {
+interface TraverseOptions extends FocusableOptions {
   onNext?(key: string, element: HTMLElement): boolean | null | void
   onPrevious?(key: string, element: HTMLElement): boolean | null | void
   /**
    * @default true
    */
   scrollIntoView?: boolean
+  /**
+   * @default 'vertical'
+   */
   direction?: TraverseDirection
   filterOption?: (element: HTMLElement, index: number) => boolean
   onArrowLeft?(): boolean | null | void
@@ -97,6 +101,7 @@ export const useTraverseOptions = (
 
       let elements = getFocusableElements(
         unrefElement(container) as HTMLElement,
+        options,
       )
 
       if (options.filterOption) {

+ 4 - 2
app/frontend/shared/utils/files.ts

@@ -30,7 +30,9 @@ export const convertFileList = async (
     }
   })
 
-  return Promise.all(promises)
+  const readFiles = await Promise.all(promises)
+
+  return readFiles.filter((file) => file.content)
 }
 
 export const loadImageIntoBase64 = async (
@@ -64,7 +66,7 @@ export const loadImageIntoBase64 = async (
 }
 
 export const canDownloadFile = (type?: Maybe<string>) => {
-  return Boolean(type && type !== 'application/pdf' && type !== 'text/html')
+  return Boolean(type && type !== 'text/html')
 }
 
 export const canPreviewFile = (type?: Maybe<string>) => {

+ 14 - 1
app/frontend/shared/utils/getFocusableElements.ts

@@ -1,5 +1,9 @@
 // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
 
+export interface FocusableOptions {
+  ignoreTabindex?: boolean
+}
+
 const FOCUSABLE_QUERY =
   'button, a[href]:not([href=""]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
 
@@ -10,12 +14,21 @@ export const isElementVisible = (el: HTMLElement) => {
   return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) // from jQuery
 }
 
-export const getFocusableElements = (container?: Maybe<HTMLElement>) => {
+const isNegativeTabIndex = (el: HTMLElement) => {
+  const tabIndex = el.getAttribute('tabindex')
+  return tabIndex && parseInt(tabIndex, 10) < 0
+}
+
+export const getFocusableElements = (
+  container?: Maybe<HTMLElement>,
+  options: FocusableOptions = {},
+) => {
   return Array.from<HTMLElement>(
     container?.querySelectorAll(FOCUSABLE_QUERY) || [],
   ).filter(
     (el) =>
       isElementVisible(el) &&
+      (options.ignoreTabindex || !isNegativeTabIndex(el)) &&
       !el.hasAttribute('disabled') &&
       el.getAttribute('aria-disabled') !== 'true',
   )

+ 5 - 1
i18n/zammad.pot

@@ -1200,6 +1200,10 @@ msgstr ""
 msgid "Are you sure to remove this article?"
 msgstr ""
 
+#: app/frontend/shared/components/Form/fields/FieldFile/FieldFileInput.vue
+msgid "Are you sure you want to delete \"%s\"?"
+msgstr ""
+
 #: app/frontend/apps/mobile/pages/ticket/composable/useTicketsMerge.ts
 msgid "Are you sure you want to merge this ticket (#%s) into #%s?"
 msgstr ""
@@ -1222,7 +1226,6 @@ msgstr ""
 #: app/assets/javascripts/app/controllers/maintenance.coffee
 #: app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee
 #: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
-#: app/frontend/shared/components/Form/fields/FieldFile/FieldFileInput.vue
 msgid "Are you sure?"
 msgstr ""
 
@@ -3653,6 +3656,7 @@ msgstr ""
 #: app/assets/javascripts/app/views/twitter/list.jst.eco
 #: app/assets/javascripts/app/views/widget/text_module.jst.eco
 #: app/frontend/apps/mobile/pages/account/views/AccountAvatar.vue
+#: app/frontend/shared/components/Form/fields/FieldFile/FieldFileInput.vue
 msgid "Delete"
 msgstr ""
 

Некоторые файлы не были показаны из-за большого количества измененных файлов