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/leaflet.css"/>
<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/maps-ZyuCPqes.js">
<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>
<body>
<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>
<div class="dropdown-wrapper">
<button
:aria-label="buttonLabel"
aria-controls="dropdown-list"
:aria-expanded="isOpen"
aria-haspopup="true"
:aria-label="buttonLabel"
class="dropdown-selector transparent"
@click="toggleDropdown"
@click="toggleDropdown()"
ref="dropdownButton"
>
<slot></slot>
</button>
<ul class="dropdown-list" v-if="isOpen">
<ul
v-if="isOpen"
:aria-labelledby="listLabel"
class="dropdown-list"
id="dropdown-list"
role="menu"
>
<li
class="dropdown-item"
:class="{ selected: option.value === selected }"
v-for="(option, index) in dropdownOptions"
:class="{
selected: option.value === selected,
focused: index === focusOptionIndex,
}"
v-for="(option, index) in options"
:key="index"
tabindex="0"
:id="`dropdown-item-${index}`"
tabindex="-1"
@click="updateSelected(option)"
@keydown.enter="updateSelected(option)"
role="button"
@mouseover="onMouseOver(index)"
role="menuitem"
>
{{ option.label }}
</li>
@ -26,7 +40,7 @@
</template>
<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 type { IDropdownOption, TDropdownOptions } from '@/types/forms'
@ -34,9 +48,10 @@
options: TDropdownOptions
selected: string
buttonLabel: string
listLabel: string
}
const props = defineProps<Props>()
const { options } = toRefs(props)
const { options, selected } = toRefs(props)
const emit = defineEmits({
selected: (option: IDropdownOption) => option,
@ -44,20 +59,93 @@
const route = useRoute()
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() {
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) {
emit('selected', option)
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(
() => route.path,
() => (isOpen.value = false)
)
watch(
() => selected.value,
(value) => (focusOptionIndex.value = getIndexFromOptionValue(value))
)
onMounted(() => {
document.addEventListener('keydown', handleKey)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKey)
})
</script>
<style scoped lang="scss">
@ -90,7 +178,8 @@
content: ' ✔';
}
&:hover {
&:hover,
&.focused {
background-color: var(--dropdown-hover-color);
}
}

View File

@ -106,8 +106,9 @@
:selected="language"
@selected="updateLanguage"
: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>
</div>
</div>

View File

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

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "Passwort verbergen",
"INVALID_TOKEN": "Ungültiges Token, bitte fordere ein neues Passworts an.",
"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.",
"LOGIN": "Anmeldung",
"LOGOUT": "Abmelden",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "hide password",
"INVALID_TOKEN": "Invalid token, please request a new password reset.",
"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.",
"LOGIN": "Login",
"LOGOUT": "Logout",
@ -123,7 +123,7 @@
"LIGHT": "Light"
}
},
"TIMEZONE": "Timezone",
"TIMEZONE": "Timezone | Timezones",
"UNITS": {
"IMPERIAL": "Imperial system (ft, mi, mph, °F)",
"LABEL": "Units for distance",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "ocultar 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",
"LANGUAGE": "Idioma",
"LANGUAGE": "Idioma | Idiomas",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "La política de privacidad ha sido actualizada, {0} antes de continuar.",
"LOGIN": "Acceder",
"LOGOUT": "Cerrar sesión",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "masquer le 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",
"LANGUAGE": "Langue",
"LANGUAGE": "Langue | Langues",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "La politique de confidentialité a été mise à jour. Veuillez l'{0} avant de poursuivre.",
"LOGIN": "Se connecter",
"LOGOUT": "Se déconnecter",
@ -123,7 +123,7 @@
"LIGHT": "Clair"
}
},
"TIMEZONE": "Fuseau horaire",
"TIMEZONE": "Fuseau horaire | Fuseaux horaires",
"UNITS": {
"IMPERIAL": "Système impérial (ft, mi, mph, °F)",
"LABEL": "Unités pour les distances",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "agochar 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",
"LANGUAGE": "Idioma",
"LANGUAGE": "Idioma | Idiomas",
"LAST_PRIVACY_POLICY_TO_VALIDATE": "Actualizouse a política de privacidade, podes {0} antes de continuar.",
"LOGIN": "Acceso",
"LOGOUT": "Pechar sesión",

View File

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

View File

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

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "verberg wachtwoord",
"INVALID_TOKEN": "Ongeldig token, vraag een nieuwe wachtwoord reset aan.",
"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.",
"LOGIN": "Inloggen",
"LOGOUT": "Uitloggen",

View File

@ -24,7 +24,7 @@
"HIDE_PASSWORD": "ukryj hasło",
"INVALID_TOKEN": "Niepoprawny token, proszę zlecić nowy reset hasła.",
"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.",
"LOGIN": "Zaloguj",
"LOGOUT": "Wyloguj",