Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(arrowNavigation) - update way to listen for focus/blur events #10326

Merged
merged 3 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
:title="item.displayName"
class="conversation-item"
:class="{'unread-mention-conversation': item.unreadMention}"
:anchor-id="`conversation_${item.token}`"
:data-nav-id="`conversation_${item.token}`"
:actions-aria-label="t('spreed', 'Conversation actions')"
:to="to"
:bold="!!item.unreadMessages"
Expand Down
16 changes: 8 additions & 8 deletions src/components/LeftSidebar/LeftSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
-
- @author Marco Ambrosini <marcoambrosini@icloud.com>
-
- @license GNU AGPL version 3 or any later version
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -167,6 +167,7 @@
<NcAppNavigationCaption :title="t('spreed', 'Users')" />
<NcListItem v-for="item of searchResultsUsers"
:key="`user_${item.id}`"
:data-nav-id="`user_${item.id}`"
:title="item.label"
@click="createAndJoinConversation(item)">
<template #icon>
Expand All @@ -182,6 +183,7 @@
<NcAppNavigationCaption :title="t('spreed', 'Groups')" />
<NcListItem v-for="item of searchResultsGroups"
:key="`group_${item.id}`"
:data-nav-id="`group_${item.id}`"
:title="item.label"
@click="createAndJoinConversation(item)">
<template #icon>
Expand All @@ -195,6 +197,7 @@
<NcAppNavigationCaption :title="t('spreed', 'Circles')" />
<NcListItem v-for="item of searchResultsCircles"
:key="`circle_${item.id}`"
:data-nav-id="`circle_${item.id}`"
:title="item.label"
@click="createAndJoinConversation(item)">
<template #icon>
Expand Down Expand Up @@ -309,10 +312,11 @@ export default {
const leftSidebar = ref(null)
const searchBox = ref(null)

const { initializeNavigation } = useArrowNavigation(leftSidebar, searchBox)
const { initializeNavigation, resetNavigation } = useArrowNavigation(leftSidebar, searchBox, '.list-item')

return {
initializeNavigation,
resetNavigation,
leftSidebar,
searchBox,
}
Expand Down Expand Up @@ -510,6 +514,7 @@ export default {
}, 500)
},
debounceFetchSearchResults: debounce(function() {
this.resetNavigation()
if (this.isSearching) {
this.fetchSearchResults()
}
Expand Down Expand Up @@ -539,9 +544,6 @@ export default {
this.searchResultsGroups = this.searchResults.filter((match) => match.source === 'groups')
this.searchResultsCircles = this.searchResults.filter((match) => match.source === 'circles')
this.contactsLoading = false
this.$nextTick(() => {
this.initializeNavigation('.list-item')
})
} catch (exception) {
if (CancelableRequest.isCancel(exception)) {
return
Expand All @@ -563,9 +565,6 @@ export default {
const response = await request({ searchText: this.searchText })
this.searchResultsListedConversations = response.data.ocs.data
this.listedConversationsLoading = false
this.$nextTick(() => {
this.initializeNavigation('.list-item')
})
} catch (exception) {
if (CancelableRequest.isCancel(exception)) {
return
Expand All @@ -577,6 +576,7 @@ export default {

async fetchSearchResults() {
await Promise.all([this.fetchPossibleConversations(), this.fetchListedConversations()])
this.initializeNavigation()
},

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
-
- @author Marco Ambrosini <marcoambrosini@icloud.com>
-
- @license GNU AGPL version 3 or any later version
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -97,10 +97,11 @@ export default {
const wrapper = ref(null)
const setContacts = ref(null)

const { initializeNavigation } = useArrowNavigation(wrapper, setContacts)
const { initializeNavigation, resetNavigation } = useArrowNavigation(wrapper, setContacts, '.participant-row')

return {
initializeNavigation,
resetNavigation,
wrapper,
setContacts,
}
Expand Down Expand Up @@ -176,6 +177,7 @@ export default {
},

debounceFetchSearchResults: debounce(function() {
this.resetNavigation()
this.fetchSearchResults()
}, 250),

Expand All @@ -196,7 +198,7 @@ export default {
this.cachedFullSearchResults = this.searchResults
}
this.$nextTick(() => {
this.initializeNavigation('.participant-row')
this.initializeNavigation()
})
} catch (exception) {
if (CancelableRequest.isCancel(exception)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
-->

<template>
<li class="participant-row"
<li :data-nav-id="`${participant.source}_${participant.id}`"
class="participant-row"
:class="{
'offline': isOffline,
'currentUser': isSelf,
Expand Down
92 changes: 66 additions & 26 deletions src/composables/useArrowNavigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,64 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { onMounted, ref, unref } from 'vue'
import { computed, onMounted, ref, unref } from 'vue'

/**
* Mount navigation according to https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
* ArrowDown or ArrowUp keys - to move through the itemElements list
* Enter key - to focus first element and click it
* (if confirmEnter = true) first Enter keydown to focus first element, second - to click selected
* Escape key - to return focus to the default element, if one of the items is focused already
* Backspace key - to return focus to the default element, if one of the items is focused already
* Item elements should have:
* - specific valid CSS selector (tag, class or another attribute)
* - unique "data-nav-id" attribute (on element or its parent, if it's not possible to pass it through the wrapper)
*
* Controls:
* - ArrowDown or ArrowUp keys - to move through the itemElements list
* - Enter key - to focus first element and click it
* (if confirmEnter = true) first Enter keydown to focus first element, second - to click selected
* - Escape key - to return focus to the default element, if one of the items is focused already
* - Backspace key - to return focus to the default element, if one of the items is focused already
*
* @param {import('vue').Ref | HTMLElement} listElementRef component ref to mount navigation
* @param {import('vue').Ref} defaultElementRef component ref to return focus to // Vue component
* @param {string} selector native selector of elements to look for
* @param {object} options navigation options
* @param {boolean} [options.confirmEnter=false] flag to confirm Enter click
*/
export function useArrowNavigation(listElementRef, defaultElementRef, options = { confirmEnter: false }) {
export function useArrowNavigation(listElementRef, defaultElementRef, selector, options = { confirmEnter: false }) {
const listRef = ref(null)
const defaultRef = ref(null)

/**
* @constant
* @type {import('vue').Ref<HTMLElement[]>}
*/
const itemElements = ref([])
const itemElementsIdMap = computed(() => itemElements.value.map(item => {
return item.getAttribute('data-nav-id') || item.parentElement.getAttribute('data-nav-id')
}))
const itemSelector = ref(selector)

const focusedIndex = ref(null)
const isConfirmationEnabled = ref(null)

// Set focused index according to selected element
const handleFocusEvent = (event) => {
const newIndex = itemElementsIdMap.value.indexOf(event.target?.getAttribute('data-nav-id'))

// Quit if triggered by arrow navigation as already handled
// or if using Tab key to navigate, and going through NcActions
if (focusedIndex.value !== newIndex && newIndex !== -1) {
focusedIndex.value = newIndex
}
}

// Reset focused index if focus moved out of navigation area or moved to the defaultRef
const handleBlurEvent = (event) => {
if (!listRef.value.contains(event.relatedTarget)
|| defaultRef.value?.$el.contains(event.relatedTarget)
|| defaultRef.value.contains?.(event.relatedTarget)) {
DorraJaouad marked this conversation as resolved.
Show resolved Hide resolved
focusedIndex.value = null
}
}

// Add event listeners for navigation list and set a default focus element
onMounted(() => {
// depending on ref, listElementRef could be either a component or a DOM element
Expand All @@ -49,7 +85,7 @@ export function useArrowNavigation(listElementRef, defaultElementRef, options =
isConfirmationEnabled.value = options.confirmEnter

listRef.value.addEventListener('keydown', (event) => {
if (itemElements.value?.length) {
if (itemElementsIdMap.value?.length) {
if (event.key === 'ArrowDown') {
focusNextElement(event)
} else if (event.key === 'ArrowUp') {
Expand All @@ -64,25 +100,26 @@ export function useArrowNavigation(listElementRef, defaultElementRef, options =
})

/**
* Collect all DOM elements specified by selector
*
* @param {string} selector selector to look for
* Update list of navigate-able elements specified by selector.
* Put a listener for focus/blur events on navigation area
*/
function initializeNavigation(selector) {
itemElements.value = Array.from(listRef.value.querySelectorAll(selector))
function initializeNavigation() {
itemElements.value = Array.from(listRef.value.querySelectorAll(itemSelector.value))
focusedIndex.value = null

itemElements.value.forEach((item, index) => {
item.addEventListener('focus', (event) => {
focusedIndex.value = index
})
listRef.value.addEventListener('focus', handleFocusEvent, true)
listRef.value.addEventListener('blur', handleBlurEvent, true)
}

/**
* Remove listeners from navigation area, reset list of elements
* (to made navigation unavailable during fetching results)
*/
function resetNavigation() {
itemElements.value = []

item.addEventListener('blur', (event) => {
if (!itemElements.value.includes(event.relatedTarget)) {
focusedIndex.value = null
}
})
})
listRef.value.removeEventListener('focus', handleFocusEvent, true)
listRef.value.removeEventListener('blur', handleBlurEvent, true)
}

/**
Expand Down Expand Up @@ -127,7 +164,9 @@ export function useArrowNavigation(listElementRef, defaultElementRef, options =
nativelyFocusElement(0)

// if confirmEnter = false, first Enter keydown clicks on item, otherwise only focuses it
if (!isConfirmationEnabled.value && event?.key === 'Enter') {
// Additionally check whether the Element is still in the DOM
if (!isConfirmationEnabled.value && event?.key === 'Enter'
&& listRef.value.contains(itemElements.value[0])) {
itemElements.value[0].click()
}
}
Expand All @@ -145,7 +184,7 @@ export function useArrowNavigation(listElementRef, defaultElementRef, options =
return
}

if (focusedIndex.value < itemElements.value.length - 1) {
if (focusedIndex.value < itemElementsIdMap.value.length - 1) {
nativelyFocusElement(focusedIndex.value + 1)
} else {
nativelyFocusElement(0)
Expand All @@ -166,11 +205,12 @@ export function useArrowNavigation(listElementRef, defaultElementRef, options =
if (focusedIndex.value > 0) {
nativelyFocusElement(focusedIndex.value - 1)
} else {
nativelyFocusElement(itemElements.value.length - 1)
nativelyFocusElement(itemElementsIdMap.value.length - 1)
}
}

return {
initializeNavigation,
resetNavigation,
}
}
Loading