Skip to content

Commit

Permalink
improvement: arrow-key navigation for chat list
Browse files Browse the repository at this point in the history
Implements the "roving tabindex" approach:
https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#technique_1_roving_tabindex.

Supersedes #4211.

This improves things, but the UX as a whole is not great yet:
- The tab order is such that the chat list does not
  immediately follow the search field,
  so you have to tab through the other navbar items
  before you get to the chat list.
  Same for going back from the chat list to the search bar.
- The initially "active" element is just the first chat item,
  and not the currently selected chat.
- Since the chat list is "virtualized", the currently active element
  might get removed from DOM when the user scrolls,
  thus we lose track of the item that was last selected.

Related: #2784
  • Loading branch information
WofWca committed Oct 21, 2024
1 parent 1329bed commit 17803a1
Show file tree
Hide file tree
Showing 3 changed files with 373 additions and 92 deletions.
188 changes: 101 additions & 87 deletions packages/frontend/src/components/chat/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
MessageChatListItemData,
} from './ChatListItemRow'
import { isInviteLink } from '../../../../shared/util'
import { RovingTabindexProvider } from '../../contexts/RovingTabindex'

const enum LoadStatus {
FETCHING = 1,
Expand Down Expand Up @@ -163,6 +164,8 @@ export default function ChatList(props: {
const createChatByContactId = useCreateChatByContactId()
const { selectChat } = useChat()

const tabindexWrapperElement = useRef<HTMLDivElement>(null)

const addContactOnClick = async () => {
if (!queryStrIsValidEmail || !queryStr) return

Expand Down Expand Up @@ -344,27 +347,31 @@ export default function ChatList(props: {
<div className='chat-list'>
<AutoSizer>
{({ width, height }) => (
<div>
<div ref={tabindexWrapperElement}>
<div className='search-result-divider' style={{ width: width }}>
{tx('search_in', searchChatInfo.name)}
{messageResultIds.length !== 0 &&
': ' + translate_n('n_messages', messageResultIds.length)}
</div>
<ChatListPart
isRowLoaded={isMessageLoaded}
loadMoreRows={loadMessages}
rowCount={messageResultIds.length}
width={width}
height={
/* take remaining space */
height - DIVIDER_HEIGHT
}
itemKey={index => 'key' + messageResultIds[index]}
itemData={messagelistData}
itemHeight={CHATLISTITEM_MESSAGE_HEIGHT}
<RovingTabindexProvider
wrapperElementRef={tabindexWrapperElement}
>
{ChatListItemRowMessage}
</ChatListPart>
<ChatListPart
isRowLoaded={isMessageLoaded}
loadMoreRows={loadMessages}
rowCount={messageResultIds.length}
width={width}
height={
/* take remaining space */
height - DIVIDER_HEIGHT
}
itemKey={index => 'key' + messageResultIds[index]}
itemData={messagelistData}
itemHeight={CHATLISTITEM_MESSAGE_HEIGHT}
>
{ChatListItemRowMessage}
</ChatListPart>
</RovingTabindexProvider>
</div>
)}
</AutoSizer>
Expand All @@ -378,90 +385,97 @@ export default function ChatList(props: {
<div className='chat-list'>
<AutoSizer>
{({ width, height }) => (
<div>
<div ref={tabindexWrapperElement}>
{isSearchActive && (
<div className='search-result-divider' style={{ width: width }}>
{translate_n('n_chats', chatListIds.length)}
</div>
)}
<ChatListPart
isRowLoaded={isChatLoaded}
loadMoreRows={loadChats}
rowCount={chatListIds.length}
width={width}
height={chatsHeight(height)}
setListRef={(ref: List<any> | null) =>
((listRefRef.current as any) = ref)
}
itemKey={index => 'key' + chatListIds[index]}
itemData={chatlistData}
itemHeight={CHATLISTITEM_CHAT_HEIGHT}
{/* TODO RovingTabindex doesn't work well with virtualized
lists, because the currently active element might get removed
from DOM if scrolled out of view. */}
<RovingTabindexProvider
wrapperElementRef={tabindexWrapperElement}
>
{ChatListItemRowChat}
</ChatListPart>
{isSearchActive && (
<>
<div
className='search-result-divider'
style={{ width: width }}
>
{translate_n('n_contacts', contactIds.length)}
</div>
<ChatListPart
isRowLoaded={isContactLoaded}
loadMoreRows={loadContact}
rowCount={contactIds.length}
width={width}
height={contactsHeight(height)}
itemKey={index => 'key' + contactIds[index]}
itemData={contactlistData}
itemHeight={CHATLISTITEM_CONTACT_HEIGHT}
>
{ChatListItemRowContact}
</ChatListPart>
{contactIds.length === 0 &&
chatListIds.length === 0 &&
queryStrIsValidEmail && (
<ChatListPart
isRowLoaded={isChatLoaded}
loadMoreRows={loadChats}
rowCount={chatListIds.length}
width={width}
height={chatsHeight(height)}
setListRef={(ref: List<any> | null) =>
((listRefRef.current as any) = ref)
}
itemKey={index => 'key' + chatListIds[index]}
itemData={chatlistData}
itemHeight={CHATLISTITEM_CHAT_HEIGHT}
>
{ChatListItemRowChat}
</ChatListPart>
{isSearchActive && (
<>
<div
className='search-result-divider'
style={{ width: width }}
>
{translate_n('n_contacts', contactIds.length)}
</div>
<ChatListPart
isRowLoaded={isContactLoaded}
loadMoreRows={loadContact}
rowCount={contactIds.length}
width={width}
height={contactsHeight(height)}
itemKey={index => 'key' + contactIds[index]}
itemData={contactlistData}
itemHeight={CHATLISTITEM_CONTACT_HEIGHT}
>
{ChatListItemRowContact}
</ChatListPart>
{contactIds.length === 0 &&
chatListIds.length === 0 &&
queryStrIsValidEmail && (
<div style={{ width: width }}>
<PseudoListItemAddContact
queryStr={queryStr?.trim() || ''}
queryStrIsEmail={queryStrIsValidEmail}
onClick={addContactOnClick}
/>
</div>
)}
{showPseudoListItemAddContactFromInviteLink && (
<div style={{ width: width }}>
<PseudoListItemAddContact
queryStr={queryStr?.trim() || ''}
queryStrIsEmail={queryStrIsValidEmail}
onClick={addContactOnClick}
<PseudoListItemAddContactOrGroupFromInviteLink
inviteLink={queryStr!}
accountId={accountId}
/>
</div>
)}
{showPseudoListItemAddContactFromInviteLink && (
<div style={{ width: width }}>
<PseudoListItemAddContactOrGroupFromInviteLink
inviteLink={queryStr!}
accountId={accountId}
/>
<div
className='search-result-divider'
style={{ width: width }}
>
{translated_messages_label(messageResultIds.length)}
</div>
)}
<div
className='search-result-divider'
style={{ width: width }}
>
{translated_messages_label(messageResultIds.length)}
</div>

<ChatListPart
isRowLoaded={isMessageLoaded}
loadMoreRows={loadMessages}
rowCount={messageResultIds.length}
width={width}
height={
// take remaining space
messagesHeight(height)
}
itemKey={index => 'key' + messageResultIds[index]}
itemData={messagelistData}
itemHeight={CHATLISTITEM_MESSAGE_HEIGHT}
>
{ChatListItemRowMessage}
</ChatListPart>
</>
)}
<ChatListPart
isRowLoaded={isMessageLoaded}
loadMoreRows={loadMessages}
rowCount={messageResultIds.length}
width={width}
height={
// take remaining space
messagesHeight(height)
}
itemKey={index => 'key' + messageResultIds[index]}
itemData={messagelistData}
itemHeight={CHATLISTITEM_MESSAGE_HEIGHT}
>
{ChatListItemRowMessage}
</ChatListPart>
</>
)}
</RovingTabindexProvider>
<div
className='floating-action-button'
onClick={onCreateChat}
Expand Down
63 changes: 58 additions & 5 deletions packages/frontend/src/components/chat/ChatListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useRef } from 'react'
import classNames from 'classnames'
import { T, C } from '@deltachat/jsonrpc-client'

Expand All @@ -12,6 +12,7 @@ import { selectedAccountId } from '../../ScreenController'
import { InlineVerifiedIcon } from '../VerifiedIcon'
import { runtime } from '@deltachat-desktop/runtime-interface'
import { message2React } from '../message/MessageMarkdown'
import { useRovingTabindex } from '../../contexts/RovingTabindex'

const log = getLogger('renderer/chatlist/item')

Expand Down Expand Up @@ -171,11 +172,23 @@ function ChatListItemArchiveLink({
},
])

const ref = useRef<HTMLButtonElement>(null)

const {
tabIndex,
onKeydown: tabindexOnKeydown,
setAsActiveElement: tabindexSetAsActiveElement,
} = useRovingTabindex(ref)

return (
<button
ref={ref}
tabIndex={tabIndex}
onClick={onClick}
onKeyDown={tabindexOnKeydown}
onFocus={tabindexSetAsActiveElement}
onContextMenu={onContextMenu}
className={`chat-list-item archive-link-item ${
className={`chat-list-item archive-link-item roving-tabindex ${
isContextMenuActive ? 'context-menu-active' : ''
}`}
>
Expand Down Expand Up @@ -206,11 +219,24 @@ function ChatListItemError({
isSelected?: boolean
}) {
log.info('Error Loading Chatlistitem ' + chatListItem.id, chatListItem.error)

const ref = useRef<HTMLButtonElement>(null)

const {
tabIndex,
onKeydown: tabindexOnKeydown,
setAsActiveElement: tabindexSetAsActiveElement,
} = useRovingTabindex(ref)

return (
<button
ref={ref}
tabIndex={tabIndex}
onClick={onClick}
onKeyDown={tabindexOnKeydown}
onFocus={tabindexSetAsActiveElement}
onContextMenu={onContextMenu}
className={classNames('chat-list-item', {
className={classNames('chat-list-item roving-tabindex', {
isError: true,
selected: isSelected,
})}
Expand Down Expand Up @@ -256,11 +282,24 @@ function ChatListItemNormal({
isSelected?: boolean
hover?: boolean
}) {
const ref = useRef<HTMLButtonElement>(null)

const {
tabIndex,
onKeydown: tabindexOnKeydown,
setAsActiveElement: tabindexSetAsActiveElement,
} = useRovingTabindex(ref)
// TODO `setAsActiveElement` if `isSelected` and `activeElement === null`

return (
<button
ref={ref}
tabIndex={tabIndex}
onClick={onClick}
onKeyDown={tabindexOnKeydown}
onFocus={tabindexSetAsActiveElement}
onContextMenu={onContextMenu}
className={classNames('chat-list-item', {
className={classNames('chat-list-item roving-tabindex', {
'has-unread': chatListItem.freshMessageCounter > 0,
'is-contact-request': chatListItem.isContactRequest,
pinned: chatListItem.isPinned,
Expand Down Expand Up @@ -368,11 +407,25 @@ export const ChatListItemMessageResult = React.memo<{
queryStr: string
}>(props => {
const { msr, onClick, queryStr } = props

const ref = useRef<HTMLButtonElement>(null)

const {
tabIndex,
onKeydown: tabindexOnKeydown,
setAsActiveElement: tabindexSetAsActiveElement,
} = useRovingTabindex(ref)

if (typeof msr === 'undefined') return <PlaceholderChatListItem />

return (
<button
ref={ref}
tabIndex={tabIndex}
onClick={onClick}
className='pseudo-chat-list-item message-search-result'
onKeyDown={tabindexOnKeydown}
onFocus={tabindexSetAsActiveElement}
className='pseudo-chat-list-item message-search-result roving-tabindex'
>
<div className='avatars'>
<Avatar
Expand Down
Loading

0 comments on commit 17803a1

Please sign in to comment.