Client - handle keyboard navigation on language selection in navbar

This commit is contained in:
Sam 2024-01-17 13:57:31 +01:00
parent 07c8611304
commit e6bc345bd1
4 changed files with 105 additions and 15 deletions

View File

@ -1,23 +1,37 @@
<template> <template>
<div class="dropdown-wrapper"> <div class="dropdown-wrapper">
<button <button
:aria-label="buttonLabel" aria-controls="dropdown-list"
:aria-expanded="isOpen" :aria-expanded="isOpen"
aria-haspopup="true"
:aria-label="buttonLabel"
class="dropdown-selector transparent" class="dropdown-selector transparent"
@click="toggleDropdown" @click="toggleDropdown()"
ref="dropdownButton"
> >
<slot></slot> <slot></slot>
</button> </button>
<ul class="dropdown-list" v-if="isOpen"> <ul
v-if="isOpen"
:aria-labelledby="listLabel"
class="dropdown-list"
id="dropdown-list"
role="menu"
>
<li <li
class="dropdown-item" class="dropdown-item"
:class="{ selected: option.value === selected }" :class="{
v-for="(option, index) in dropdownOptions" selected: option.value === selected,
focused: index === focusOptionIndex,
}"
v-for="(option, index) in options"
:key="index" :key="index"
tabindex="0" :id="`dropdown-item-${index}`"
tabindex="-1"
@click="updateSelected(option)" @click="updateSelected(option)"
@keydown.enter="updateSelected(option)" @keydown.enter="updateSelected(option)"
role="button" @mouseover="onMouseOver(index)"
role="menuitem"
> >
{{ option.label }} {{ option.label }}
</li> </li>
@ -26,7 +40,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, toRefs, watch } from 'vue' import { type Ref, ref, onMounted, onUnmounted, toRefs, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import type { IDropdownOption, TDropdownOptions } from '@/types/forms' import type { IDropdownOption, TDropdownOptions } from '@/types/forms'
@ -34,9 +48,10 @@
options: TDropdownOptions options: TDropdownOptions
selected: string selected: string
buttonLabel: string buttonLabel: string
listLabel: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const { options } = toRefs(props) const { options, selected } = toRefs(props)
const emit = defineEmits({ const emit = defineEmits({
selected: (option: IDropdownOption) => option, selected: (option: IDropdownOption) => option,
@ -44,20 +59,93 @@
const route = useRoute() const route = useRoute()
const isOpen = ref(false) const isOpen = ref(false)
const dropdownOptions = options.value.map((option) => option) const dropdownButton: Ref<HTMLButtonElement | null> = ref(null)
const focusOptionIndex: Ref<number> = ref(
getIndexFromOptionValue(selected.value)
)
function toggleDropdown() { function toggleDropdown() {
isOpen.value = !isOpen.value if (isOpen.value) {
closeDropdown()
} else {
isOpen.value = true
const option = document.getElementById(
`dropdown-item-${focusOptionIndex.value}`
)
option?.focus()
}
}
function closeDropdown() {
isOpen.value = false
focusOptionIndex.value = getIndexFromOptionValue(selected.value)
dropdownButton.value?.focus()
} }
function updateSelected(option: IDropdownOption) { function updateSelected(option: IDropdownOption) {
emit('selected', option) emit('selected', option)
isOpen.value = false isOpen.value = false
} }
function getIndexFromOptionValue(value: string) {
const index = options.value.findIndex((o) => o.value === value)
return index >= 0 ? index : 0
}
function handleKey(e: KeyboardEvent) {
let prevent = false
if (isOpen.value) {
if (e.key === 'ArrowDown') {
prevent = true
focusOptionIndex.value += 1
if (focusOptionIndex.value > options.value.length) {
focusOptionIndex.value = 0
}
}
if (e.key === 'ArrowUp') {
prevent = true
focusOptionIndex.value -= 1
if (focusOptionIndex.value < 0) {
focusOptionIndex.value = options.value.length - 1
}
}
if (e.key === 'Home') {
prevent = true
focusOptionIndex.value = 0
}
if (e.key === 'End') {
prevent = true
focusOptionIndex.value = options.value.length - 1
}
if (e.key === 'Enter') {
prevent = true
updateSelected(options.value[focusOptionIndex.value])
}
if (e.key === 'Escape' || e.key === 'Tab') {
prevent = e.key === 'Escape'
closeDropdown()
}
}
if (prevent) {
e.stopPropagation()
e.preventDefault()
}
}
function onMouseOver(index: number) {
focusOptionIndex.value = index
}
watch( watch(
() => route.path, () => route.path,
() => (isOpen.value = false) () => (isOpen.value = false)
) )
watch(
() => selected.value,
(value) => (focusOptionIndex.value = getIndexFromOptionValue(value))
)
onMounted(() => {
document.addEventListener('keydown', handleKey)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKey)
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -90,7 +178,8 @@
content: ' ✔'; content: ' ✔';
} }
&:hover { &:hover,
&.focused {
background-color: var(--dropdown-hover-color); background-color: var(--dropdown-hover-color);
} }
} }

View File

@ -106,8 +106,9 @@
:selected="language" :selected="language"
@selected="updateLanguage" @selected="updateLanguage"
:buttonLabel="$t('user.LANGUAGE')" :buttonLabel="$t('user.LANGUAGE')"
:listLabel="$t('user.LANGUAGE', 0)"
> >
<i class="fa fa-language"></i> <i class="fa fa-language" aria-hidden="true"></i>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "hide password", "HIDE_PASSWORD": "hide password",
"INVALID_TOKEN": "Invalid token, please request a new password reset.", "INVALID_TOKEN": "Invalid token, please request a new password reset.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "I want to delete my account", "I_WANT_TO_DELETE_MY_ACCOUNT": "I want to delete my account",
"LANGUAGE": "Language", "LANGUAGE": "Language | Languages",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "The privacy policy has been updated, please {0} it before proceeding.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "The privacy policy has been updated, please {0} it before proceeding.",
"LOGIN": "Login", "LOGIN": "Login",
"LOGOUT": "Logout", "LOGOUT": "Logout",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "masquer le mot de passe", "HIDE_PASSWORD": "masquer le mot de passe",
"INVALID_TOKEN": "Jeton invalide, veuillez demander une nouvelle réinitialisation de mot de passe.", "INVALID_TOKEN": "Jeton invalide, veuillez demander une nouvelle réinitialisation de mot de passe.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Je souhaite supprimer mon compte", "I_WANT_TO_DELETE_MY_ACCOUNT": "Je souhaite supprimer mon compte",
"LANGUAGE": "Langue", "LANGUAGE": "Langue | Langues",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "La politique de confidentialité a été mise à jour. Veuillez l'{0} avant de poursuivre.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "La politique de confidentialité a été mise à jour. Veuillez l'{0} avant de poursuivre.",
"LOGIN": "Se connecter", "LOGIN": "Se connecter",
"LOGOUT": "Se déconnecter", "LOGOUT": "Se déconnecter",