diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index 9da42fb2e..4fdf0b21d 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx'; -import React, { useMemo } from 'react'; +import React, { useId, useMemo } from 'react'; import { messageHasAttachments, messageTextHasEmojisOnly } from './utils'; import type { MessageContextValue } from '../../context'; import { useMessageContext, useTranslationContext } from '../../context'; +import { VisuallyHidden } from '../VisuallyHidden'; import { renderText as defaultRenderText } from './renderText'; import type { LocalMessage } from 'stream-chat'; @@ -37,9 +38,11 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText; - const { userLanguage } = useTranslationContext('MessageText'); + const { t, userLanguage } = useTranslationContext('MessageText'); const message = propMessage || contextMessage; const hasAttachment = messageHasAttachments(message); + const messageContextId = useId(); + const messageTextId = useId(); const messageTextToRender = translationView === 'original' @@ -56,6 +59,13 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { const hasMentionedUsers = Boolean(message.mentioned_users?.length); const isMentionsInteractionEnabled = hasMentionedUsers && typeof onMentionsClickMessage === 'function'; + const senderName = message.user?.name; + const messageContext = senderName + ? t('aria/Message from {{ user }},', { user: senderName }) + : t('aria/Message,'); + // `aria-labelledby` accepts a space-separated list of element ids. We point to the + // hidden message context and the rendered message text so screen readers announce both. + const messageLabelledBy = `${messageContextId} ${messageTextId}`; const handleMentionsKeyDown = (event: React.KeyboardEvent) => { if (!isMentionsInteractionEnabled || (event.key !== 'Enter' && event.key !== ' ')) { @@ -68,9 +78,27 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { if (!messageTextToRender) return null; + /** + * The component has two mutually exclusive focus models. The reason is this bit of behavior: + * + * if mentions are not interactive: + * - the whole message text block is just a readable focus stop + * - outer wrapper gets tabIndex={0} + * - inner wrapper is not focusable + * if mentions are interactive: + * - keyboard interaction needs to land on the inner element, because that’s where onClick, onKeyDown, and mention hover/click behavior live + * - inner wrapper gets tabIndex={0} + * - outer wrapper must stop being focusable, otherwise you create an extra dead focus stop before the actual interactive target + */ return ( -
+
+ {messageContext}
{ tabIndex={isMentionsInteractionEnabled ? 0 : undefined} > {unsafeHTML && message.html ? ( -
+
) : ( -
{messageText}
+
{messageText}
)}
diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index 6d7b6e881..8cea758c1 100644 --- a/src/components/Message/__tests__/MessageText.test.tsx +++ b/src/components/Message/__tests__/MessageText.test.tsx @@ -64,6 +64,9 @@ const defaultProps = { threadList: false, }; +const translate = (key: string, options?: Record) => + key.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, token: string) => options?.[token] ?? ''); + function generateAliceMessage(messageOptions) { return generateMessage({ user: alice, @@ -100,7 +103,8 @@ async function renderMessageText({ > key) as TranslationContextValue['t'], + t: ((key: string, options?: Record) => + translate(key, options)) as TranslationContextValue['t'], tDateTimeParser: customDateTimeParser as TranslationContextValue['tDateTimeParser'], userLanguage: 'en', @@ -254,6 +258,45 @@ describe('', () => { expect(results).toHaveNoViolations(); }); + it('should expose sender context on the focusable message wrapper', async () => { + const text = 'Hello, world!'; + const message = generateAliceMessage({ text }); + const { getByTestId } = await renderMessageText({ + customProps: { message }, + }); + + const focusableWrapper = getByTestId(messageTextTestId).parentElement; + + expect(focusableWrapper).toHaveAccessibleName(`aria/Message from alice, ${text}`); + }); + + it('should expose sender context on the mention-interactive text wrapper', async () => { + const text = 'Hello @bob'; + const message = generateAliceMessage({ mentioned_users: [bob], text }); + const { getByTestId } = await renderMessageText({ + customProps: { message }, + }); + + expect(getByTestId(messageTextTestId)).toHaveAccessibleName( + `aria/Message from alice, ${text}`, + ); + }); + + it('should not expose message user id in the accessible name fallback', async () => { + const text = 'Hello, world!'; + const message = generateMessage({ + text, + user: generateUser({ id: 'alice-id', name: undefined }), + }); + const { getByTestId } = await renderMessageText({ + customProps: { message }, + }); + + const focusableWrapper = getByTestId(messageTextTestId).parentElement; + + expect(focusableWrapper).toHaveAccessibleName(`aria/Message, ${text}`); + }); + it('should inform that message was not sent when message is has type "error"', async () => { const message = generateAliceMessage({ type: 'error' }); const { container } = await renderMessageText({ diff --git a/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap b/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap index 0242acd61..3a6196af7 100644 --- a/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap +++ b/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap @@ -16,14 +16,23 @@ exports[` > should render with a custom inner class when one is s class="str-chat__message-bubble" >
+ + aria/Message, +
-
+

hi mate

@@ -80,14 +89,23 @@ exports[` > should render with a custom wrapper class when one is class="str-chat__message-bubble" >
+ + aria/Message, +
-
+

hello world

@@ -144,14 +162,23 @@ exports[` > should render with custom theme identifier in generat class="str-chat__message-bubble" >
+ + aria/Message, +
-
+

whatup?!

diff --git a/src/components/MessageComposer/WithDragAndDropUpload.tsx b/src/components/MessageComposer/WithDragAndDropUpload.tsx index 7c3afa529..f78d807e5 100644 --- a/src/components/MessageComposer/WithDragAndDropUpload.tsx +++ b/src/components/MessageComposer/WithDragAndDropUpload.tsx @@ -120,6 +120,7 @@ export const WithDragAndDropUpload = ({ : false, multiple: multipleUploads, noClick: true, + noKeyboard: true, onDrop: isWithinMessageComposerContext ? messageComposer.attachmentManager.uploadFiles : handleDrop, diff --git a/src/i18n/de.json b/src/i18n/de.json index dcdbb0c57..2103fe44f 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -96,7 +96,9 @@ "aria/Mark Message Unread": "Als ungelesen markieren", "aria/Mark messages as read": "Nachrichten als gelesen markieren", "aria/Menu": "Menü", + "aria/Message,": "Nachricht,", "aria/Message Actions": "Nachrichtenaktionen", + "aria/Message from {{ user }},": "Nachricht von {{ user }},", "aria/Message Options": "Nachrichtenoptionen", "aria/Mute User": "Benutzer stummschalten", "aria/Notifications": "Benachrichtigungen", diff --git a/src/i18n/en.json b/src/i18n/en.json index a633d9d57..5ceae9c9f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -96,7 +96,9 @@ "aria/Mark Message Unread": "Mark Message Unread", "aria/Mark messages as read": "Mark messages as read", "aria/Menu": "Menu", + "aria/Message,": "Message,", "aria/Message Actions": "Message Actions", + "aria/Message from {{ user }},": "Message from {{ user }},", "aria/Message Options": "Message Options", "aria/Mute User": "Mute User", "aria/Notifications": "Notifications", diff --git a/src/i18n/es.json b/src/i18n/es.json index c13c395f1..c676e9a37 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -104,7 +104,9 @@ "aria/Mark Message Unread": "Marcar como no leído", "aria/Mark messages as read": "Marcar mensajes como leídos", "aria/Menu": "Menú", + "aria/Message,": "Mensaje,", "aria/Message Actions": "Acciones del mensaje", + "aria/Message from {{ user }},": "Mensaje de {{ user }},", "aria/Message Options": "Opciones de mensaje", "aria/Mute User": "Silenciar usuario", "aria/Notifications": "Notificaciones", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 8a72663fa..bc3054f71 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -104,7 +104,9 @@ "aria/Mark Message Unread": "Marquer comme non lu", "aria/Mark messages as read": "Marquer les messages comme lus", "aria/Menu": "Menu", + "aria/Message,": "Message,", "aria/Message Actions": "Actions du message", + "aria/Message from {{ user }},": "Message de {{ user }},", "aria/Message Options": "Options du message", "aria/Mute User": "Mettre en sourdine", "aria/Notifications": "Notifications", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 155411a0d..ca0ce2dc3 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -96,7 +96,9 @@ "aria/Mark Message Unread": "अपठित चिह्नित करें", "aria/Mark messages as read": "संदेशों को पढ़ा हुआ चिह्नित करें", "aria/Menu": "मेन्यू", + "aria/Message,": "संदेश,", "aria/Message Actions": "संदेश कार्रवाइयाँ", + "aria/Message from {{ user }},": "{{ user }} का संदेश,", "aria/Message Options": "संदेश विकल्प", "aria/Mute User": "उपयोगकर्ता म्यूट करें", "aria/Notifications": "सूचनाएं", diff --git a/src/i18n/it.json b/src/i18n/it.json index 95df4f192..4d73d13b9 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -104,7 +104,9 @@ "aria/Mark Message Unread": "Contrassegna come non letto", "aria/Mark messages as read": "Segna i messaggi come letti", "aria/Menu": "Menu", + "aria/Message,": "Messaggio,", "aria/Message Actions": "Azioni del messaggio", + "aria/Message from {{ user }},": "Messaggio di {{ user }},", "aria/Message Options": "Opzioni di messaggio", "aria/Mute User": "Mute utente", "aria/Notifications": "Notifiche", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index ab25b1ad1..ba0e86060 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -95,7 +95,9 @@ "aria/Mark Message Unread": "未読としてマーク", "aria/Mark messages as read": "メッセージを既読にする", "aria/Menu": "メニュー", + "aria/Message,": "メッセージ,", "aria/Message Actions": "メッセージ操作", + "aria/Message from {{ user }},": "{{ user }}さんからのメッセージ,", "aria/Message Options": "メッセージオプション", "aria/Mute User": "ユーザーをミュート", "aria/Notifications": "通知", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 86ad2c373..ee5251c02 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -95,7 +95,9 @@ "aria/Mark Message Unread": "읽지 않음으로 표시", "aria/Mark messages as read": "메시지를 읽음으로 표시", "aria/Menu": "메뉴", + "aria/Message,": "메시지,", "aria/Message Actions": "메시지 작업", + "aria/Message from {{ user }},": "{{ user }}의 메시지,", "aria/Message Options": "메시지 옵션", "aria/Mute User": "사용자 음소거", "aria/Notifications": "알림", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index d3469e9a6..4720d97e4 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -96,7 +96,9 @@ "aria/Mark Message Unread": "Markeren als ongelezen", "aria/Mark messages as read": "Markeer berichten als gelezen", "aria/Menu": "Menu", + "aria/Message,": "Bericht,", "aria/Message Actions": "Berichtacties", + "aria/Message from {{ user }},": "Bericht van {{ user }},", "aria/Message Options": "Berichtopties", "aria/Mute User": "Gebruiker dempen", "aria/Notifications": "Meldingen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index baedc52a6..a2aae6513 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -104,7 +104,9 @@ "aria/Mark Message Unread": "Marcar como não lida", "aria/Mark messages as read": "Marcar mensagens como lidas", "aria/Menu": "Menu", + "aria/Message,": "Mensagem,", "aria/Message Actions": "Ações da mensagem", + "aria/Message from {{ user }},": "Mensagem de {{ user }},", "aria/Message Options": "Opções de mensagem", "aria/Mute User": "Silenciar usuário", "aria/Notifications": "Notificações", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 48c7a842f..7adacc95d 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -113,7 +113,9 @@ "aria/Mark Message Unread": "Отметить как непрочитанное", "aria/Mark messages as read": "Отметить сообщения как прочитанные", "aria/Menu": "Меню", + "aria/Message,": "Сообщение,", "aria/Message Actions": "Действия с сообщением", + "aria/Message from {{ user }},": "Сообщение от {{ user }},", "aria/Message Options": "Параметры сообщения", "aria/Mute User": "Отключить уведомления", "aria/Notifications": "Уведомления", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 913a12500..3463969b0 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -96,7 +96,9 @@ "aria/Mark Message Unread": "Okunmamış olarak işaretle", "aria/Mark messages as read": "Mesajları okundu olarak işaretle", "aria/Menu": "Menü", + "aria/Message,": "Mesaj,", "aria/Message Actions": "Mesaj eylemleri", + "aria/Message from {{ user }},": "{{ user }} adlı kullanıcıdan mesaj,", "aria/Message Options": "Mesaj Seçenekleri", "aria/Mute User": "Kullanıcıyı sustur", "aria/Notifications": "Bildirimler",