Merge pull request #481 from SamR1/improve-dropdowns

Handle keyboard navigation on dropdowns
This commit is contained in:
Sam 2024-01-17 14:27:50 +01:00 committed by GitHub
commit e3f5701b9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 244 additions and 98 deletions

View File

@ -7,11 +7,11 @@
<link rel="stylesheet" href="/static/css/fork-awesome.min.css"/> <link rel="stylesheet" href="/static/css/fork-awesome.min.css"/>
<link rel="stylesheet" href="/static/css/leaflet.css"/> <link rel="stylesheet" href="/static/css/leaflet.css"/>
<title>FitTrackee</title> <title>FitTrackee</title>
<script type="module" crossorigin src="/static/index-omYyfw-i.js"></script> <script type="module" crossorigin src="/static/index-GyLsc4kt.js"></script>
<link rel="modulepreload" crossorigin href="/static/charts-_RwsDDkL.js"> <link rel="modulepreload" crossorigin href="/static/charts-_RwsDDkL.js">
<link rel="modulepreload" crossorigin href="/static/maps-ZyuCPqes.js"> <link rel="modulepreload" crossorigin href="/static/maps-ZyuCPqes.js">
<link rel="stylesheet" crossorigin href="/static/css/maps-B7qTrBCW.css"> <link rel="stylesheet" crossorigin href="/static/css/maps-B7qTrBCW.css">
<link rel="stylesheet" crossorigin href="/static/css/index-sAkBlxb8.css"> <link rel="stylesheet" crossorigin href="/static/css/index-PPVfBuiJ.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

@ -7,19 +7,35 @@
:value="timezone" :value="timezone"
:disabled="disabled" :disabled="disabled"
required required
@keydown.esc="onUpdateTimezone(input)" role="combobox"
aria-autocomplete="list"
aria-controls="tz-dropdown-list"
:aria-expanded="isOpen"
@keydown.esc="cancelUpdate()"
@keydown.enter="onEnter" @keydown.enter="onEnter"
@input="openDropdown" @input="openDropdown"
@blur="closeDropdown()"
@keydown.down="onKeyDown()"
@keydown.up="onKeyUp()"
/> />
<ul class="tz-dropdown-list" v-if="isOpen" ref="tzList"> <ul
class="tz-dropdown-list"
id="tz-dropdown-list"
v-if="isOpen"
role="listbox"
tabindex="-1"
:aria-label="$t('user.PROFILE.TIMEZONE', 0)"
>
<li <li
v-for="(tz, index) in timeZones.filter((t) => matchTimezone(t))" v-for="(tz, index) in filteredTimezones"
:key="tz" :key="tz"
:id="`tz-dropdown-item-${index}`"
class="tz-dropdown-item" class="tz-dropdown-item"
:class="{ focus: index === focusItemIndex }" :class="{ focus: index === focusItemIndex }"
@click="onUpdateTimezone(tz)" @click="onUpdateTimezone(index)"
@mouseover="onMouseOver(index)" @mouseover="onMouseOver(index)"
:autofocus="index === focusItemIndex" :autofocus="index === focusItemIndex"
role="option"
> >
{{ tz }} {{ tz }}
</li> </li>
@ -28,8 +44,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, toRefs, watch } from 'vue' import { computed, ref, toRefs, watch } from 'vue'
import type { Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { timeZones } from '@/utils/timezone' import { timeZones } from '@/utils/timezone'
@ -46,8 +62,10 @@
const { input, disabled } = toRefs(props) const { input, disabled } = toRefs(props)
const timezone: Ref<string> = ref(input.value) const timezone: Ref<string> = ref(input.value)
const isOpen: Ref<boolean> = ref(false) const isOpen: Ref<boolean> = ref(false)
const tzList: Ref<HTMLInputElement | null> = ref(null)
const focusItemIndex: Ref<number> = ref(0) const focusItemIndex: Ref<number> = ref(0)
const filteredTimezones: ComputedRef<string[]> = computed(() =>
input.value ? timeZones.filter((t) => matchTimezone(t)) : timeZones
)
function matchTimezone(t: string): RegExpMatchArray | null { function matchTimezone(t: string): RegExpMatchArray | null {
return t.toLowerCase().match(timezone.value.toLowerCase()) return t.toLowerCase().match(timezone.value.toLowerCase())
@ -55,15 +73,17 @@
function onMouseOver(index: number) { function onMouseOver(index: number) {
focusItemIndex.value = index focusItemIndex.value = index
} }
function onUpdateTimezone(value: string) { function onUpdateTimezone(index: number) {
timezone.value = value if (filteredTimezones.value.length > index) {
isOpen.value = false timezone.value = filteredTimezones.value[index]
emit('updateTimezone', value) emit('updateTimezone', timezone.value)
isOpen.value = false
}
} }
function onEnter(event: Event) { function onEnter(event: Event) {
event.preventDefault() event.preventDefault()
if (tzList.value?.firstElementChild?.innerHTML) { if (filteredTimezones.value.length > 0) {
onUpdateTimezone(tzList.value?.firstElementChild?.innerHTML) onUpdateTimezone(focusItemIndex.value)
} }
} }
function openDropdown(event: Event) { function openDropdown(event: Event) {
@ -71,6 +91,42 @@
isOpen.value = true isOpen.value = true
timezone.value = (event.target as HTMLInputElement).value.trim() timezone.value = (event.target as HTMLInputElement).value.trim()
} }
function closeDropdown() {
onUpdateTimezone(focusItemIndex.value)
}
function scrollIntoOption(index: number) {
const option = document.getElementById(`tz-dropdown-item-${index}`)
if (option) {
option.focus()
option.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
function onKeyDown() {
isOpen.value = true
focusItemIndex.value =
focusItemIndex.value === null ? 0 : (focusItemIndex.value += 1)
if (focusItemIndex.value >= filteredTimezones.value.length) {
focusItemIndex.value = 0
}
scrollIntoOption(focusItemIndex.value)
}
function onKeyUp() {
isOpen.value = true
focusItemIndex.value =
focusItemIndex.value === null
? filteredTimezones.value.length - 1
: (focusItemIndex.value -= 1)
if (focusItemIndex.value <= -1) {
focusItemIndex.value = filteredTimezones.value.length - 1
}
scrollIntoOption(focusItemIndex.value)
}
function cancelUpdate() {
if (isOpen.value) {
isOpen.value = false
timezone.value = input.value
}
}
watch( watch(
() => props.input, () => props.input,

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "Passwort verbergen", "HIDE_PASSWORD": "Passwort verbergen",
"INVALID_TOKEN": "Ungültiges Token, bitte fordere ein neues Passworts an.", "INVALID_TOKEN": "Ungültiges Token, bitte fordere ein neues Passworts an.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Ich möchte meinen Account löschen", "I_WANT_TO_DELETE_MY_ACCOUNT": "Ich möchte meinen Account löschen",
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache | Sprachen",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "Die Datenschutzrichtlinie wurde aktualisiert, bitte {0} sie vor dem Fortfahren.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "Die Datenschutzrichtlinie wurde aktualisiert, bitte {0} sie vor dem Fortfahren.",
"LOGIN": "Anmeldung", "LOGIN": "Anmeldung",
"LOGOUT": "Abmelden", "LOGOUT": "Abmelden",

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",
@ -123,7 +123,7 @@
"LIGHT": "Light" "LIGHT": "Light"
} }
}, },
"TIMEZONE": "Timezone", "TIMEZONE": "Timezone | Timezones",
"UNITS": { "UNITS": {
"IMPERIAL": "Imperial system (ft, mi, mph, °F)", "IMPERIAL": "Imperial system (ft, mi, mph, °F)",
"LABEL": "Units for distance", "LABEL": "Units for distance",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "ocultar contraseña", "HIDE_PASSWORD": "ocultar contraseña",
"INVALID_TOKEN": "Clave secreta no válida, solicita un nuevo restablecimiento de contraseña.", "INVALID_TOKEN": "Clave secreta no válida, solicita un nuevo restablecimiento de contraseña.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Quiero eliminar mi cuenta", "I_WANT_TO_DELETE_MY_ACCOUNT": "Quiero eliminar mi cuenta",
"LANGUAGE": "Idioma", "LANGUAGE": "Idioma | Idiomas",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "La política de privacidad ha sido actualizada, {0} antes de continuar.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "La política de privacidad ha sido actualizada, {0} antes de continuar.",
"LOGIN": "Acceder", "LOGIN": "Acceder",
"LOGOUT": "Cerrar sesión", "LOGOUT": "Cerrar sesión",

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",
@ -123,7 +123,7 @@
"LIGHT": "Clair" "LIGHT": "Clair"
} }
}, },
"TIMEZONE": "Fuseau horaire", "TIMEZONE": "Fuseau horaire | Fuseaux horaires",
"UNITS": { "UNITS": {
"IMPERIAL": "Système impérial (ft, mi, mph, °F)", "IMPERIAL": "Système impérial (ft, mi, mph, °F)",
"LABEL": "Unités pour les distances", "LABEL": "Unités pour les distances",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "agochar contrasinal", "HIDE_PASSWORD": "agochar contrasinal",
"INVALID_TOKEN": "Token non válido, solicita un novo restablecemento de contrasinal.", "INVALID_TOKEN": "Token non válido, solicita un novo restablecemento de contrasinal.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Quero eliminar a miña conta", "I_WANT_TO_DELETE_MY_ACCOUNT": "Quero eliminar a miña conta",
"LANGUAGE": "Idioma", "LANGUAGE": "Idioma | Idiomas",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "Actualizouse a política de privacidade, podes {0} antes de continuar.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "Actualizouse a política de privacidade, podes {0} antes de continuar.",
"LOGIN": "Acceso", "LOGIN": "Acceso",
"LOGOUT": "Pechar sesión", "LOGOUT": "Pechar sesión",

View File

@ -11,7 +11,7 @@
"FILTER_ON_USERNAME": "Filtra per username", "FILTER_ON_USERNAME": "Filtra per username",
"HIDE_PASSWORD": "nascondi password", "HIDE_PASSWORD": "nascondi password",
"INVALID_TOKEN": "Token invalido, per favore richiedi un nuovo reset della password.", "INVALID_TOKEN": "Token invalido, per favore richiedi un nuovo reset della password.",
"LANGUAGE": "Lingua", "LANGUAGE": "Lingua | Le lingue",
"LOGIN": "Login", "LOGIN": "Login",
"LOGOUT": "Logout", "LOGOUT": "Logout",
"LOG_IN": "log in", "LOG_IN": "log in",

View File

@ -19,7 +19,7 @@
"HIDE_PASSWORD": "skjul passord", "HIDE_PASSWORD": "skjul passord",
"INVALID_TOKEN": "Ugyldig symbol. Forespør ny tilbakestilling av passord.", "INVALID_TOKEN": "Ugyldig symbol. Forespør ny tilbakestilling av passord.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Jeg vil slette kontoen min", "I_WANT_TO_DELETE_MY_ACCOUNT": "Jeg vil slette kontoen min",
"LANGUAGE": "Språk", "LANGUAGE": "Språk | Språk",
"LOGIN": "Logg inn", "LOGIN": "Logg inn",
"LOGOUT": "Logg ut", "LOGOUT": "Logg ut",
"LOGOUT_CONFIRMATION": "Vil du logge ut?", "LOGOUT_CONFIRMATION": "Vil du logge ut?",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "verberg wachtwoord", "HIDE_PASSWORD": "verberg wachtwoord",
"INVALID_TOKEN": "Ongeldig token, vraag een nieuwe wachtwoord reset aan.", "INVALID_TOKEN": "Ongeldig token, vraag een nieuwe wachtwoord reset aan.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Ik wil mijn account verwijderen", "I_WANT_TO_DELETE_MY_ACCOUNT": "Ik wil mijn account verwijderen",
"LANGUAGE": "Taal", "LANGUAGE": "Taal | Talen",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "Het privacybeleid werd aangepast, gelieve te {0} voor verdergaan.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "Het privacybeleid werd aangepast, gelieve te {0} voor verdergaan.",
"LOGIN": "Inloggen", "LOGIN": "Inloggen",
"LOGOUT": "Uitloggen", "LOGOUT": "Uitloggen",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "ukryj hasło", "HIDE_PASSWORD": "ukryj hasło",
"INVALID_TOKEN": "Niepoprawny token, proszę zlecić nowy reset hasła.", "INVALID_TOKEN": "Niepoprawny token, proszę zlecić nowy reset hasła.",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Chcę usunąć swoje konto", "I_WANT_TO_DELETE_MY_ACCOUNT": "Chcę usunąć swoje konto",
"LANGUAGE": "Język", "LANGUAGE": "Język | Języki",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "Polityka prywatności uległa zmianie, proszę {0} ją przed przejściem dalej.", "LAST_PRIVACY_POLICY_TO_VALIDATE": "Polityka prywatności uległa zmianie, proszę {0} ją przed przejściem dalej.",
"LOGIN": "Zaloguj", "LOGIN": "Zaloguj",
"LOGOUT": "Wyloguj", "LOGOUT": "Wyloguj",