Skip to content

Commit

Permalink
Feature: Mobile - Improve file preview accessibility
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Apr 17, 2023
1 parent 6e154ad commit 5ef83f3
Show file tree
Hide file tree
Showing 11 changed files with 83 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}>()
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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')"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const hideAfterLeaving = () => {
emit('hide')
}
useTraverseOptions(actionBar, { direction: 'horizontal' })
useTraverseOptions(actionBar, { direction: 'horizontal', ignoreTabindex: true })
</script>

<template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -78,6 +80,7 @@ describe('keyboard interactions', () => {

expect(view.emitted().hide).toBeTruthy()
})

it('hides on escape', async () => {
const view = renderComponent(FieldEditorActionBar, {
props: {
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +128,12 @@ const onFilesScroll = (event: UIEvent) => {
}
const { showImage } = useImageViewer(uploadFiles)
const filesContainer = ref<HTMLDivElement>()
useTraverseOptions(filesContainer, {
direction: 'vertical',
})
</script>

<template>
Expand All @@ -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,
Expand All @@ -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)"
/>
Expand All @@ -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()"
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const renderFileInput = (props: Record<string, unknown> = {}) => {
},
form: true,
confirmation: true,
router: true,
})
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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',
})

Expand Down
7 changes: 6 additions & 1 deletion app/frontend/shared/composables/useTraverseOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +101,7 @@ export const useTraverseOptions = (

let elements = getFocusableElements(
unrefElement(container) as HTMLElement,
options,
)

if (options.filterOption) {
Expand Down
6 changes: 4 additions & 2 deletions app/frontend/shared/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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>) => {
Expand Down
15 changes: 14 additions & 1 deletion app/frontend/shared/utils/getFocusableElements.ts
Original file line number Diff line number Diff line change
@@ -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"])'

Expand All @@ -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',
)
Expand Down
6 changes: 5 additions & 1 deletion i18n/zammad.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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 ""

Expand Down Expand Up @@ -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 ""

Expand Down
3 changes: 3 additions & 0 deletions vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export default defineConfig(({ mode, command }) => {
// TODO remove after https://github.com/ueberdosis/tiptap/pull/3521 is merged
inline: ['@tiptap/extension-mention'],
},
onConsoleLog(log) {
if (log.includes('Not implemented: navigation')) return false
},
},
build: {
minify: false,
Expand Down

0 comments on commit 5ef83f3

Please sign in to comment.