Skip to content

Commit

Permalink
WIP: improvement: a11y: arrow-key navigation for messages
Browse files Browse the repository at this point in the history
Closes #2141

Basically what this commit comes down to:
1. Apply `useRovingTabindex` for message items
2. Set `tabindex="-1"` on all the interactive items
    inside every message that is currently not the active one,
    so that they do no have tab stops.

TODO:
- [ ] Address the TODOs in the code
- [ ] Manage what's gonna be the initially active message,
    because initially they're all active, so
    tabbing to the messages list from the top selects
    the first rendered one as the active one.
    #4292
    could help with this.
    This is also not great for performance: changing `tabindex`
    on a bunch of messages makes them all re-render.
    And otherwise, we probably want to update which one is
    the active one as new messages arrive.
- [ ] The interactive items with `onClick` must be actual semantic
    `<button>`s.
    See #4210
    for reference.
  • Loading branch information
WofWca committed Oct 30, 2024
1 parent 621fdd0 commit 6db255c
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 87 deletions.
14 changes: 12 additions & 2 deletions packages/frontend/src/components/message/EmptyChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useRef } from 'react'
import { C } from '@deltachat/jsonrpc-client'

import useTranslationFunction from '../../hooks/useTranslationFunction'

import type { T } from '@deltachat/jsonrpc-client'
import { useRovingTabindex } from '../../contexts/RovingTabindex'

type Props = {
chat: T.FullChat
Expand All @@ -12,6 +13,9 @@ type Props = {
export default function EmptyChatMessage({ chat }: Props) {
const tx = useTranslationFunction()

const ref = useRef<HTMLLIElement>(null)
const rovingTabindex = useRovingTabindex(ref)

let emptyChatMessage = tx('chat_new_one_to_one_hint', [chat.name, chat.name])

if (chat.chatType === C.DC_CHAT_TYPE_BROADCAST) {
Expand All @@ -29,7 +33,13 @@ export default function EmptyChatMessage({ chat }: Props) {
}

return (
<li>
<li
ref={ref}
className={rovingTabindex.className}
tabIndex={rovingTabindex.tabIndex}
onKeyDown={rovingTabindex.onKeydown}
onFocus={rovingTabindex.setAsActiveElement}
>
<div className='info-message big'>
<div className='bubble'>{emptyChatMessage}</div>
</div>
Expand Down
19 changes: 17 additions & 2 deletions packages/frontend/src/components/message/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ function isDomainTrusted(domain: string): boolean {
export const LabeledLink = ({
label,
destination,
tabIndex,
}: {
label: string | JSX.Element | JSX.Element[]
destination: LinkDestination
tabIndex: -1 | 0
}) => {
const { openDialog } = useDialog()
const openLinkSafely = useOpenLinkSafely()
Expand Down Expand Up @@ -84,6 +86,7 @@ export const LabeledLink = ({
x-target-url={target}
title={realUrl}
onClick={onClick}
tabIndex={tabIndex}
onContextMenu={ev => ((ev as any).t = ev.currentTarget)}
>
{label}
Expand Down Expand Up @@ -164,7 +167,13 @@ function LabeledLinkConfirmationDialog(
)
}

export const Link = ({ destination }: { destination: LinkDestination }) => {
export const Link = ({
destination,
tabIndex,
}: {
destination: LinkDestination
tabIndex: -1 | 0
}) => {
const { openDialog } = useDialog()
const openLinkSafely = useOpenLinkSafely()
const accountId = selectedAccountId()
Expand Down Expand Up @@ -193,7 +202,13 @@ export const Link = ({ destination }: { destination: LinkDestination }) => {
}

return (
<a href='#' x-target-url={asciiUrl} title={asciiUrl} onClick={onClick}>
<a
href='#'
x-target-url={asciiUrl}
title={asciiUrl}
onClick={onClick}
tabIndex={tabIndex}
>
{target}
</a>
)
Expand Down
74 changes: 64 additions & 10 deletions packages/frontend/src/components/message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,19 @@ import type { PrivateReply } from '../../hooks/chat/usePrivateReply'
const Avatar = ({
contact,
onContactClick,
tabIndex,
}: {
contact: T.Contact
onContactClick: (contact: T.Contact) => void
tabIndex: -1 | 0
}) => {
const { profileImage, color, displayName } = contact

const onClick = () => onContactClick(contact)

if (profileImage) {
return (
<div className='author-avatar' onClick={onClick}>
<div className='author-avatar' onClick={onClick} tabIndex={tabIndex}>
<img alt={displayName} src={runtime.transformBlobURL(profileImage)} />
</div>
)
Expand All @@ -78,6 +80,7 @@ const Avatar = ({
className='author-avatar default'
aria-label={displayName}
onClick={onClick}
tabIndex={tabIndex}
>
<div style={{ backgroundColor: color }} className='label'>
{initial}
Expand All @@ -91,10 +94,12 @@ const AuthorName = ({
contact,
onContactClick,
overrideSenderName,
tabIndex,
}: {
contact: T.Contact
onContactClick: (contact: T.Contact) => void
overrideSenderName: string | null
tabIndex: -1 | 0
}) => {
const accountId = selectedAccountId()
const { color, id } = contact
Expand All @@ -120,6 +125,7 @@ const AuthorName = ({
className='author'
style={{ color }}
onClick={() => onContactClick(contact)}
tabIndex={tabIndex}
>
{getAuthorName(displayName, overrideSenderName)}
</span>
Expand All @@ -132,12 +138,14 @@ const ForwardedTitle = ({
direction,
conversationType,
overrideSenderName,
tabIndex,
}: {
contact: T.Contact
onContactClick: (contact: T.Contact) => void
direction: 'incoming' | 'outgoing'
conversationType: ConversationType
overrideSenderName: string | null
tabIndex: -1 | 0
}) => {
const tx = useTranslationFunction()

Expand All @@ -152,6 +160,7 @@ const ForwardedTitle = ({
() => (
<span
onClick={() => onContactClick(contact)}
tabIndex={tabIndex}
key='displayname'
style={{ color: color }}
>
Expand All @@ -160,7 +169,7 @@ const ForwardedTitle = ({
)
)
) : (
<span onClick={() => onContactClick(contact)}>
<span onClick={() => onContactClick(contact)} tabIndex={tabIndex}>
{tx('forwarded_message')}
</span>
)}
Expand Down Expand Up @@ -343,8 +352,9 @@ export default function Message(props: {
chat: T.FullChat
message: T.Message
conversationType: ConversationType
tabindexForInteractiveContents: -1 | 0
}) {
const { message, conversationType } = props
const { message, conversationType, tabindexForInteractiveContents } = props
const { id, viewType, text, hasLocation, isSetupmessage, hasHtml } = message
const direction = getDirection(message)
const status = mapCoreMsgStatus2String(message.state)
Expand Down Expand Up @@ -480,6 +490,9 @@ export default function Message(props: {
id={String(message.id)}
onContextMenu={showContextMenu}
onClick={onClick}
// Using tabindex even when `!isInteractive` because it has
// a context menu.
tabIndex={tabindexForInteractiveContents}
>
{(isProtectionBrokenMsg || isProtectionEnabledMsg) && (
<img
Expand Down Expand Up @@ -530,12 +543,17 @@ export default function Message(props: {
<div className='videochat-icon'>
<span className='icon videocamera' />
</div>
<AvatarFromContact contact={message.sender} onClick={onContactClick} />
<AvatarFromContact
contact={message.sender}
onClick={onContactClick}
// TODO tabIndex={tabindexForInteractiveContents}
/>
<div className='break' />
<div
className='info-button'
onContextMenu={showContextMenu}
onClick={() => joinVideoChat(accountId, id)}
tabIndex={tabindexForInteractiveContents}
>
{direction === 'incoming'
? tx('videochat_contact_invited_hint', message.sender.displayName)
Expand All @@ -558,6 +576,7 @@ export default function Message(props: {
padlock={message.showPadlock}
onClickError={openMessageInfo.bind(null, openDialog, message)}
viewType={'VideochatInvitation'}
// TODO tabIndex={tabindexForInteractiveContents}
/>
</div>
</div>
Expand All @@ -568,7 +587,10 @@ export default function Message(props: {
{message.isSetupmessage ? (
tx('autocrypt_asm_click_body')
) : text !== null ? (
<MessageBody text={text} />
<MessageBody
text={text}
tabindexForInteractiveContents={tabindexForInteractiveContents}
/>
) : null}
</div>
)
Expand All @@ -589,7 +611,10 @@ export default function Message(props: {
<span key='downloading'>{tx('downloading')}</span>
)}
{(downloadState == 'Failure' || downloadState === 'Available') && (
<button onClick={downloadFullMessage.bind(null, message.id)}>
<button
onClick={downloadFullMessage.bind(null, message.id)}
tabIndex={tabindexForInteractiveContents}
>
{tx('download')}
</button>
)}
Expand Down Expand Up @@ -623,7 +648,11 @@ export default function Message(props: {
id={message.id.toString()}
>
{showAuthor && direction === 'incoming' && (
<Avatar contact={message.sender} onContactClick={onContactClick} />
<Avatar
contact={message.sender}
onContactClick={onContactClick}
tabIndex={tabindexForInteractiveContents}
/>
)}
<div
onContextMenu={showContextMenu}
Expand All @@ -637,6 +666,7 @@ export default function Message(props: {
direction={direction}
conversationType={conversationType}
overrideSenderName={message.overrideSenderName}
tabIndex={tabindexForInteractiveContents}
/>
)}
{!message.isForwarded && (
Expand All @@ -651,6 +681,7 @@ export default function Message(props: {
contact={message.sender}
onContactClick={onContactClick}
overrideSenderName={message.overrideSenderName}
tabIndex={tabindexForInteractiveContents}
/>
</div>
)}
Expand All @@ -659,9 +690,14 @@ export default function Message(props: {
'msg-body--clickable': onClickMessageBody,
})}
onClick={onClickMessageBody}
tabIndex={onClickMessageBody ? tabindexForInteractiveContents : -1}
>
{message.quote !== null && (
<Quote quote={message.quote} msgParentId={message.id} />
<Quote
quote={message.quote}
msgParentId={message.id}
tabIndex={tabindexForInteractiveContents}
/>
)}
{showAttachment(message) && (
<Attachment
Expand All @@ -672,7 +708,10 @@ export default function Message(props: {
/>
)}
{message.viewType === 'Webxdc' && (
<WebxdcMessageContent message={message}></WebxdcMessageContent>
<WebxdcMessageContent
tabindexForInteractiveContents={tabindexForInteractiveContents}
message={message}
></WebxdcMessageContent>
)}
{message.viewType === 'Vcard' && (
<VCardComponent message={message}></VCardComponent>
Expand All @@ -682,6 +721,7 @@ export default function Message(props: {
<div
onClick={openMessageHTML.bind(null, message.id)}
className='show-html'
tabIndex={tabindexForInteractiveContents}
>
{tx('show_full_message')}
</div>
Expand All @@ -702,6 +742,7 @@ export default function Message(props: {
padlock={message.showPadlock}
onClickError={openMessageInfo.bind(null, openDialog, message)}
viewType={message.viewType}
// TODO tabIndex={tabindexForInteractiveContents}
/>
{message.reactions && !isSetupmessage && (
<Reactions reactions={message.reactions} />
Expand All @@ -722,9 +763,11 @@ export default function Message(props: {
export const Quote = ({
quote,
msgParentId,
tabIndex,
}: {
quote: T.MessageQuote
msgParentId?: number
tabIndex: -1 | 0
}) => {
const tx = useTranslationFunction()
const accountId = selectedAccountId()
Expand All @@ -751,6 +794,7 @@ export const Quote = ({
msgParentId
)
}}
tabIndex={tabIndex}
>
<div
className={`quote ${hasMessage && 'has-message'}`}
Expand Down Expand Up @@ -790,6 +834,7 @@ export const Quote = ({
quote.text.slice(0, 3000 /* limit quoted message size */) || ''
}
disableJumbomoji
tabindexForInteractiveContents={-1}
/>
</div>
</div>
Expand Down Expand Up @@ -817,7 +862,13 @@ export function getAuthorName(
return overrideSenderName ? `~${overrideSenderName}` : displayName
}

function WebxdcMessageContent({ message }: { message: T.Message }) {
function WebxdcMessageContent({
message,
tabindexForInteractiveContents,
}: {
message: T.Message
tabindexForInteractiveContents: -1 | 0
}) {
const tx = useTranslationFunction()
if (message.viewType !== 'Webxdc') {
return null
Expand All @@ -835,6 +886,8 @@ function WebxdcMessageContent({ message }: { message: T.Message }) {
src={runtime.getWebxdcIconURL(selectedAccountId(), message.id)}
alt={`icon of ${info.name}`}
onClick={() => openWebxdc(message.id)}
// Not setting `tabIndex={tabindexForInteractiveContents}` here
// because there is a button below that does the same
/>
<div
className='name'
Expand All @@ -854,6 +907,7 @@ function WebxdcMessageContent({ message }: { message: T.Message }) {
className={styles.startWebxdcButton}
styling='primary'
onClick={() => openWebxdc(message.id)}
tabIndex={tabindexForInteractiveContents}
>
{tx('start_app')}
</Button>
Expand Down
8 changes: 7 additions & 1 deletion packages/frontend/src/components/message/MessageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ const UPPER_LIMIT_FOR_PARSED_MESSAGES = 20_000
function MessageBody({
text,
disableJumbomoji,
tabindexForInteractiveContents,
}: {
text: string
disableJumbomoji?: boolean
tabindexForInteractiveContents?: -1 | 0
}): JSX.Element {
if (text.length >= UPPER_LIMIT_FOR_PARSED_MESSAGES) {
return <>{text}</>
Expand All @@ -28,7 +30,11 @@ function MessageBody({
)
}
}
return message2React(emojifiedText, false)
return message2React(
emojifiedText,
false,
tabindexForInteractiveContents ?? 0
)
}
const trimRegex = /^[\s\uFEFF\xA0\n\t]+|[\s\uFEFF\xA0\n\t]+$/g

Expand Down
Loading

0 comments on commit 6db255c

Please sign in to comment.