Browse Source

Fixes: Mobile - Ensure custom elements follow accessibility rules

Vladimir Sheremet 2 years ago
parent
commit
4a19143212

+ 1 - 1
app/frontend/apps/mobile/App.vue

@@ -78,7 +78,7 @@ onBeforeUnmount(() => {
     v-if="application.loaded"
     class="h-full min-w-full bg-black font-sans text-sm text-white antialiased"
   >
-    <router-view />
+    <RouterView />
   </div>
   <DynamicInitializer
     name="dialog"

+ 2 - 1
app/frontend/apps/mobile/components/CommonBackButton/CommonBackButton.vue

@@ -14,10 +14,11 @@ defineProps<Props>()
 <template>
   <button
     class="flex cursor-pointer items-center"
+    :aria-label="$t('Go back')"
     :class="{ 'gap-2': label }"
     @click="$walker.back(fallback)"
   >
-    <CommonIcon name="mobile-chevron-left" />
+    <CommonIcon decorative name="mobile-chevron-left" />
     <span v-if="label">{{ $t(label) }}</span>
   </button>
 </template>

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

@@ -12,10 +12,15 @@ describe('rendering common back button', () => {
       router: true,
       props: {
         fallback: '/back-url',
-        label: 'Back',
       },
     })
 
+    expect(view.getByRole('button', { name: 'Go back' })).toBeInTheDocument()
+
+    await view.rerender({
+      label: 'Back',
+    })
+
     expect(view.container).toHaveTextContent('Back')
 
     i18n.setTranslationMap(new Map([['Back', 'Zurück']]))
@@ -23,5 +28,7 @@ describe('rendering common back button', () => {
     await flushPromises()
 
     expect(view.container).toHaveTextContent('Zurück')
+
+    expect(view.getByRole('button', { name: 'Go back' })).toBeInTheDocument()
   })
 })

+ 12 - 1
app/frontend/apps/mobile/components/CommonButtonGroup/CommonButtonGroup.vue

@@ -2,16 +2,20 @@
 
 <script setup lang="ts">
 import { type Props as IconProps } from '@shared/components/CommonIcon/CommonIcon.vue'
+import { computed } from 'vue'
 import type { CommonButtonOption } from './types'
 
 export interface Props {
   modelValue?: string | number
   mode?: 'full' | 'compressed'
+  controls?: string
+  as?: 'tabs' | 'buttons'
   options: CommonButtonOption[]
 }
 
-withDefaults(defineProps<Props>(), {
+const props = withDefaults(defineProps<Props>(), {
   mode: 'compressed',
+  as: 'buttons',
 })
 
 const emit = defineEmits<{
@@ -31,25 +35,32 @@ const onButtonClick = (option: CommonButtonOption) => {
   option.onAction?.()
   emit('update:modelValue', option.value)
 }
+
+const isTabs = computed(() => props.as === 'tabs')
 </script>
 
 <template>
   <div
     class="flex max-w-[100vw] overflow-x-auto"
     :class="{ 'w-full': mode === 'full' }"
+    :role="isTabs ? 'tablist' : undefined"
   >
     <Component
       :is="option.link ? 'CommonLink' : 'button'"
       v-for="option of options"
       :key="option.label"
+      :role="isTabs ? 'tab' : undefined"
       :disabled="option.disabled"
       :link="option.link"
       class="flex flex-col items-center justify-center gap-1 rounded-xl bg-gray-500 py-1 px-3 text-base text-white ltr:mr-2 rtl:ml-2"
+      :data-value="option.value"
       :class="{
         'bg-gray-600/50 text-white/30': option.disabled,
         'bg-gray-200': option.value != null && modelValue === option.value,
         'flex-1': mode === 'full',
       }"
+      :aria-controls="isTabs ? controls || option.controls : undefined"
+      :aria-selected="isTabs ? modelValue === option.value : undefined"
       @click="onButtonClick(option)"
     >
       <CommonIcon v-if="option.icon" v-bind="getIconProps(option)" decorative />

+ 1 - 0
app/frontend/apps/mobile/components/CommonButtonGroup/types.ts

@@ -7,6 +7,7 @@ export interface CommonButtonOption {
   value?: string | number
   onAction?(): void | Promise<void>
   label: string
+  controls?: string
   labelPlaceholder?: string[]
   disabled?: boolean
   icon?: string | IconProps

+ 47 - 8
app/frontend/apps/mobile/components/CommonDialog/CommonDialog.vue

@@ -1,22 +1,28 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import { useTrapTab } from '@shared/composables/useTrapTab'
 import type { EventHandlers } from '@shared/types/utils'
-import { usePointerSwipe } from '@vueuse/core'
-import type { Events } from 'vue'
-import { ref } from 'vue'
-import { useDialogState } from './composable'
+import { getFirstFocusableElement } from '@shared/utils/getFocusableElements'
+import { onKeyUp, usePointerSwipe } from '@vueuse/core'
+import { nextTick, onMounted, ref, type Events } from 'vue'
+import type { Ref } from 'vue'
+import { closeDialog } from '@shared/composables/useDialog'
+import stopEvent from '@shared/utils/events'
 
 const props = defineProps<{
   name: string
   label?: string
   content?: string
+  // don't focus the first element inside a Dialog after being mounted
+  // if nothing is focusable, will focus "Done" button
+  noAutofocus?: boolean
   listeners?: {
     done?: EventHandlers<Events>
   }
 }>()
 
-defineEmits<{
+const emit = defineEmits<{
   (e: 'close'): void
 }>()
 
@@ -24,8 +30,22 @@ const PX_SWIPE_CLOSE = -150
 
 const top = ref('0')
 const dialogElement = ref<HTMLElement>()
+const contentElement = ref<HTMLElement>()
+
+const close = async () => {
+  emit('close')
+  await closeDialog(props.name)
+}
+
+onKeyUp(
+  'Escape',
+  (e) => {
+    stopEvent(e)
+    close()
+  },
+  { target: dialogElement as Ref<EventTarget> },
+)
 
-const { close } = useDialogState(props)
 const { distanceY, isSwiping } = usePointerSwipe(dialogElement, {
   onSwipe() {
     if (distanceY.value < 0) {
@@ -44,6 +64,24 @@ const { distanceY, isSwiping } = usePointerSwipe(dialogElement, {
   },
   pointerTypes: ['touch', 'pen'],
 })
+
+useTrapTab(dialogElement)
+
+onMounted(() => {
+  if (props.noAutofocus) return
+
+  // will try to find focusable element inside dialog
+  // if it won't find it, will try to find inside the header
+  // most likely will find "Done" button
+  const firstFocusable =
+    getFirstFocusableElement(contentElement.value) ||
+    getFirstFocusableElement(dialogElement.value)
+
+  nextTick(() => {
+    firstFocusable?.focus()
+    firstFocusable?.scrollIntoView({ block: 'nearest' })
+  })
+})
 </script>
 
 <script lang="ts">
@@ -88,7 +126,7 @@ export default {
               v-bind="listeners?.done"
               @pointerdown.stop
               @click="close()"
-              @keypress.space="close()"
+              @keypress.space.prevent="close()"
             >
               {{ $t('Done') }}
             </button>
@@ -96,8 +134,9 @@ export default {
         </div>
       </div>
       <div
-        class="flex grow flex-col items-start overflow-y-auto bg-black text-white"
+        ref="contentElement"
         v-bind="$attrs"
+        class="flex grow flex-col items-start overflow-y-auto bg-black text-white"
       >
         <slot>{{ content }}</slot>
       </div>

+ 112 - 3
app/frontend/apps/mobile/components/CommonDialog/__tests__/CommonDialog.spec.ts

@@ -1,15 +1,19 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
-import { getDialogMeta } from '@shared/composables/useDialog'
+import { getDialogMeta, openDialog } from '@shared/composables/useDialog'
 import { renderComponent } from '@tests/support/components'
+import { flushPromises } from '@vue/test-utils'
 import CommonDialog from '../CommonDialog.vue'
 
+const html = String.raw
+
 describe('visuals for common dialog', () => {
   beforeEach(() => {
     const { dialogsOptions } = getDialogMeta()
     dialogsOptions.set('dialog', {
       name: 'dialog',
-      component: vi.fn(),
+      component: vi.fn().mockResolvedValue({}),
+      refocus: true,
     })
   })
 
@@ -49,6 +53,8 @@ describe('visuals for common dialog', () => {
       },
     })
 
+    await flushPromises()
+
     await view.events.keyboard('{Escape}')
 
     const emitted = view.emitted()
@@ -76,5 +82,108 @@ describe('visuals for common dialog', () => {
     expect(view.getByRole('dialog')).toHaveAccessibleName('foobar')
   })
 
-  // TODO closing with pulling down is tested inside e2e
+  it('traps focus inside the dialog', async () => {
+    const externalForm = document.createElement('form')
+    externalForm.innerHTML = html`
+      <input data-test-id="form_input" type="text" />
+      <select data-test-id="form_select" type="text" />
+    `
+
+    document.body.appendChild(externalForm)
+
+    const view = renderComponent(CommonDialog, {
+      props: {
+        name: 'dialog',
+      },
+      slots: {
+        default: html`
+          <input data-test-id="input" type="text" />
+          <div data-test-id="div" tabindex="0" />
+          <select data-test-id="select">
+            <option value="1">1</option>
+          </select>
+        `,
+      },
+    })
+
+    view.getByTestId('input').focus()
+
+    expect(view.getByTestId('input')).toHaveFocus()
+
+    await view.events.keyboard('{Tab}')
+
+    expect(view.getByTestId('div')).toHaveFocus()
+
+    await view.events.keyboard('{Tab}')
+
+    expect(view.getByTestId('select')).toHaveFocus()
+
+    await view.events.keyboard('{Tab}')
+
+    expect(view.getByRole('button', { name: 'Done' })).toHaveFocus()
+
+    await view.events.keyboard('{Tab}')
+
+    expect(view.getByTestId('input')).toHaveFocus()
+  })
+
+  it('autofocuses the first focusable element', async () => {
+    const view = renderComponent(CommonDialog, {
+      props: {
+        name: 'dialog',
+      },
+      slots: {
+        default: html`
+          <div data-test-id="div" tabindex="0" />
+          <select data-test-id="select">
+            <option value="1">1</option>
+          </select>
+        `,
+      },
+    })
+
+    await flushPromises()
+
+    expect(view.getByTestId('div')).toHaveFocus()
+  })
+
+  it('focuses "Done", if there is nothing focusable in dialog', async () => {
+    const view = renderComponent(CommonDialog, {
+      props: {
+        name: 'dialog',
+      },
+    })
+
+    await flushPromises()
+
+    expect(view.getByRole('button', { name: 'Done' })).toHaveFocus()
+  })
+
+  it('refocuses element that opened dialog', async () => {
+    const button = document.createElement('button')
+    button.setAttribute('data-test-id', 'button')
+    document.body.appendChild(button)
+
+    button.focus()
+
+    expect(button).toHaveFocus()
+
+    await openDialog('dialog', {})
+
+    const view = renderComponent(CommonDialog, {
+      props: {
+        name: 'dialog',
+      },
+    })
+
+    await flushPromises()
+
+    expect(view.getByRole('button', { name: 'Done' })).toHaveFocus()
+
+    await view.events.keyboard('{Escape}')
+
+    expect(button).toHaveFocus()
+  })
+
+  // closing with pulling down is tested inside e2e
 })

+ 0 - 31
app/frontend/apps/mobile/components/CommonDialog/composable.ts

@@ -1,31 +0,0 @@
-// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
-
-import { closeDialog } from '@shared/composables/useDialog'
-import { useEventListener } from '@vueuse/core'
-import { getCurrentInstance } from 'vue'
-
-interface DialogProps {
-  name: string
-}
-
-/**
- * @private
- */
-export const useDialogState = (props: DialogProps) => {
-  const vm = getCurrentInstance()
-
-  const close = () => {
-    vm?.emit('close')
-    closeDialog(props.name)
-  }
-
-  useEventListener('keydown', (e) => {
-    if (e.key === 'Escape') {
-      close()
-    }
-  })
-
-  return {
-    close,
-  }
-}

+ 4 - 3
app/frontend/apps/mobile/components/CommonDialogObjectForm/CommonDialogObjectForm.vue

@@ -52,7 +52,7 @@ const emit = defineEmits<{
 const updateQuery = new MutationHandler(props.mutation, {
   errorNotificationMessage: props.errorNotificationMessage,
 })
-const { form, isDirty, isDisabled, formSubmit } = useForm()
+const { form, isDirty, isDisabled } = useForm()
 
 const objectAtrributes: Record<string, string> =
   props.object?.objectAttributeValues?.reduce(
@@ -129,7 +129,7 @@ const saveObject = async (formData: FormData) => {
 </script>
 
 <template>
-  <CommonDialog :name="name">
+  <CommonDialog class="w-full" no-autofocus :name="name">
     <template #before-label>
       <button
         class="text-blue"
@@ -142,10 +142,10 @@ const saveObject = async (formData: FormData) => {
     </template>
     <template #after-label>
       <button
+        :form="name"
         class="text-blue"
         :disabled="isDisabled"
         :class="{ 'opacity-50': isDisabled }"
-        @click="formSubmit"
       >
         {{ $t('Save') }}
       </button>
@@ -154,6 +154,7 @@ const saveObject = async (formData: FormData) => {
       :id="name"
       ref="form"
       class="w-full p-4"
+      autofocus
       :schema="schema"
       :initial-entity-object="initialFlatObject"
       :change-fields="formChangeFields"

+ 2 - 0
app/frontend/apps/mobile/components/CommonDialogObjectForm/__tests__/CommonDialogObjectForm.spec.ts

@@ -87,6 +87,8 @@ test('can update default object', async () => {
   const textarea = view.getByLabelText('Textarea Field')
   const test = view.getByLabelText('Test Field')
 
+  expect(name).toHaveFocus()
+
   expect(name).toHaveValue(organization.name)
   expect(shared).toBeChecked()
   expect(domainAssignment).not.toBeChecked()

Some files were not shown because too many files changed in this diff