Client - handle keyboard navigation on language selection in navbar
This commit is contained in:
parent
07c8611304
commit
e6bc345bd1
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user