Merge pull request #481 from SamR1/improve-dropdowns
Handle keyboard navigation on dropdowns
This commit is contained in:
commit
e3f5701b9d
4
fittrackee/dist/index.html
vendored
4
fittrackee/dist/index.html
vendored
@ -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>
|
||||
|
1
fittrackee/dist/static/css/index-PPVfBuiJ.css
vendored
Normal file
1
fittrackee/dist/static/css/index-PPVfBuiJ.css
vendored
Normal file
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
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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?",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user