Skip to content

Commit

Permalink
Maintenance: Add delegate focus composable.
Browse files Browse the repository at this point in the history
  • Loading branch information
dvuckovic committed Feb 5, 2024
1 parent 938a0b6 commit 0a37e13
Show file tree
Hide file tree
Showing 4 changed files with 62 additions and 13 deletions.
Expand Up @@ -6,6 +6,7 @@ import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
import type { TreeSelectOption } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
import useValue from '#shared/components/Form/composables/useValue.ts'
import { useDelegateFocus } from '#shared/composables/useDelegateFocus.ts'
import useFlatSelectOptions from '../FieldTreeSelect/useFlatSelectOptions.ts'
import type {
GroupAccessLookup,
Expand Down Expand Up @@ -135,28 +136,23 @@ const hasNoMoreGroups = computed(
) === flatOptions.value.length,
)
const delegateFocus = () => {
requestAnimationFrame(() => {
const firstGroupSelection: HTMLOutputElement | null =
document.querySelector(`#${contextReactive.value.id}_first_element`)
if (firstGroupSelection) firstGroupSelection.focus()
})
}
const { delegateFocus } = useDelegateFocus(
contextReactive.value.id,
`${contextReactive.value.id}_first_element`,
)
</script>

<template>
<output
:id="context.id"
class="w-full flex flex-col p-2 space-y-2"
class="w-full flex flex-col p-2 space-y-2 focus:outline-none"
:class="context.classes.input"
:name="context.node.name"
role="list"
:tabindex="context.disabled ? '-1' : '0'"
:aria-disabled="context.disabled"
v-bind="context.attrs"
@focus="delegateFocus"
@blur="context.handlers.blur"
>
<div
v-for="(groupPermission, index) in groupPermissions"
Expand All @@ -178,6 +174,7 @@ const delegateFocus = () => {
:multiple="true"
:disabled="context.disabled"
:alternative-background="true"
@blur="index === 0 ? context.handlers.blur : undefined"
/>
<FormKit
v-for="groupAccess in context.groupAccesses"
Expand Down
Expand Up @@ -4,6 +4,7 @@
import { computed, toRef } from 'vue'
import useValue from '#shared/components/Form/composables/useValue.ts'
import { i18n } from '#shared/i18n.ts'
import { useDelegateFocus } from '#shared/composables/useDelegateFocus.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import type { ToggleListOption, ToggleListOptionValue } from './types.ts'
Expand Down Expand Up @@ -40,22 +41,27 @@ const updateValue = (
localValue.value = values.filter((value) => value !== key)
}
}
const { delegateFocus } = useDelegateFocus(
context.value.id,
`toggle_list_toggle_${context.value.id}_${context.value.options[0]?.value}`,
)
</script>

<template>
<output
:id="context.id"
class="block bg-blue-200 dark:bg-gray-700 rounded-lg"
class="block bg-blue-200 dark:bg-gray-700 rounded-lg focus:outline-none"
role="list"
:class="context.classes.input"
:name="context.node.name"
:aria-disabled="context.disabled"
:tabindex="context.disabled ? '-1' : '0'"
v-bind="context.attrs"
@blur="context.handlers.blur"
@focus="delegateFocus"
>
<div
v-for="option in context.options"
v-for="(option, index) in context.options"
:key="`option-${option.value}`"
class="flex gap-2.5 items-center px-3 py-2.5"
role="option"
Expand Down Expand Up @@ -95,6 +101,7 @@ const updateValue = (
},
}"
@update:model-value="updateValue(option.value, $event)"
@blur="index === 0 ? context.handlers.blur : undefined"
/>
</div>
</output>
Expand Down
35 changes: 35 additions & 0 deletions app/frontend/shared/composables/useDelegateFocus.ts
@@ -0,0 +1,35 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import { getPreviousFocusableElement } from '#shared/utils/getFocusableElements.ts'

export const useDelegateFocus = (containerId: string, firstChildId: string) => {
const delegateFocus = (event: FocusEvent) => {
const containerElement: Maybe<HTMLElement> = document.querySelector(
`#${containerId}`,
)

const firstChildElement: Maybe<HTMLElement> = document.querySelector(
`#${firstChildId}`,
)

// Check if the element that just lost focus is the first child element of the container.
if (event.relatedTarget && event.relatedTarget === firstChildElement) {
getPreviousFocusableElement(containerElement)?.focus()

return
}

// Check if the element that just lost focus is another child element of the container.
if (
event.relatedTarget &&
containerElement?.contains(event.relatedTarget as Node)
)
return

firstChildElement?.focus()
}

return {
delegateFocus,
}
}
10 changes: 10 additions & 0 deletions app/frontend/shared/utils/getFocusableElements.ts
Expand Up @@ -37,3 +37,13 @@ export const getFocusableElements = (
export const getFirstFocusableElement = (container?: Maybe<HTMLElement>) => {
return getFocusableElements(container)[0]
}

export const getPreviousFocusableElement = (
currentElement?: Maybe<HTMLElement>,
) => {
if (!currentElement) return null

const focusableElements = getFocusableElements(document.body)

return focusableElements[focusableElements.indexOf(currentElement) - 1]
}

0 comments on commit 0a37e13

Please sign in to comment.