Skip to content

Commit

Permalink
feat: make aria-labels localizable (#2282)
Browse files Browse the repository at this point in the history
### 🎯 Goal

We had several requests related to customization of `aria-label`
attributes. The best approach is to rely on existing
internationalization support for customization and overrides.

Fixes #1931, fixes #1994.

### 🛠 Implementation details

1. Wrapped all ARIA labels in `t(...)` translation function. The keys
used for ARIA labels are prefixed with `aria/...` to distinguish them
from other texts.
2. Added default translations for supported languages.

### 🎨 UI Changes

No visible changes.

### To-Do

- [x] Update docs
  • Loading branch information
myandrienko committed Feb 21, 2024
1 parent 8f48b52 commit 7867677
Show file tree
Hide file tree
Showing 40 changed files with 434 additions and 98 deletions.
56 changes: 45 additions & 11 deletions docusaurus/docs/React/guides/theming/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,38 @@ JSON objects can be imported from the library.
`import { esTranslations } from 'stream-chat-react';`
:::

### Overriding ARIA labels

ARIA labels that are used for interactive elements are also subject to localization. Translation keys for ARIA labels are prefixed by `aria/`:

```jsx
import { useTranslationContext } from 'stream-chat-react';

const Component = () => {
const { t } = useTranslationContext();
return (
<button type='button' aria-label={t('aria/Send')}>
📨
</button>
);
};
```

To override the default translations, add an `aria`-prefixed key to the `translationsForLanguage` object:

```jsx
const i18nInstance = new Streami18n({
language: 'en',
translationsForLanguage: {
'aria/Send': 'Send Message',
},
});

<Chat client={client} i18nInstance={i18nInstance}>
{/* ... */}
</Chat>;
```

## Add a Language

In the following example, we will demonstrate how to add translation support for an additional language not currently supported
Expand Down Expand Up @@ -293,7 +325,7 @@ To display date and time in different than machine's local timezone, provide the
```ts
import { Streami18n } from 'stream-chat-react';

const streamI18n = new Streami18n({ timezone: 'Europe/Prague'});
const streamI18n = new Streami18n({ timezone: 'Europe/Prague' });
```
If you are using `moment` as your datetime parser engine and want to start using timezone-located datetime strings, then we recommend to use `moment-timezone` instead of `moment` package. Moment Timezone will automatically load and extend the moment module, then return the modified instance. This will also prevent multiple versions of `moment` being installed in a project.
Expand All @@ -305,7 +337,7 @@ import { Streami18n } from 'stream-chat-react';
const i18n = new Streami18n({
DateTimeParser: momentTimezone,
timezone: 'Europe/Prague',
})
});
```
### Translating Messages
Expand All @@ -331,7 +363,7 @@ The `Streami18n` class wraps [`i18next`](https://www.npmjs.com/package/i18next)
### Class Constructor Options
| Option | Description | Type | Default |
|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------------------------|
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------- |
| DateTimeParser | custom date time parser | function | Day.js |
| dayjsLocaleConfigForLanguage | internal Day.js [config object](https://github.com/iamkun/dayjs/tree/dev/src/locale) and [calendar locale config object](https://day.js.org/docs/en/plugin/calendar) | object | 'enConfig' |
| debug | enables i18n debug mode | boolean | false |
Expand All @@ -350,21 +382,23 @@ The default implementation returns the default value provided to the translator
import { useTranslationContext } from 'stream-chat-react';

const Component = () => {
const { t } = useTranslationContext('useCommandTrigger');
const { t } = useTranslationContext('useCommandTrigger');

return (
<div>{t('some-key', {defaultValue: 'hello'})}</div>
);
}
return <div>{t('some-key', { defaultValue: 'hello' })}</div>;
};
```
The custom handler may log missing key warnings to the console in the development environment:
```ts
import { Streami18n, Streami18nOptions } from 'stream-chat-react';

const parseMissingKeyHandler: Streami18nOptions['parseMissingKeyHandler'] = (key: string, defaultValue?: string) => {
console.warn(`Streami18n: Missing translation for key: ${key}`);
return defaultValue ?? key;
const parseMissingKeyHandler: Streami18nOptions['parseMissingKeyHandler'] = (
key: string,
defaultValue?: string,
) => {
console.warn(`Streami18n: Missing translation for key: ${key}`);
return defaultValue ?? key;
};

const i18nInstance = new Streami18n({ parseMissingKeyHandler });
Expand Down
14 changes: 9 additions & 5 deletions src/components/Attachment/__tests__/Card.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
generateUser,
getOrCreateChannelApi,
getTestClientWithUser,
mockTranslationContext,
useMockedApis,
} from '../../../mock-builders';
import { TranslationContext } from '../../../context';

let chatClient;
let channel;
Expand All @@ -31,11 +33,13 @@ const mockedChannel = generateChannel({
const renderCard = ({ cardProps, chatContext, theRenderer = render }) =>
theRenderer(
<ChatProvider value={{ themeVersion: '1', ...chatContext }}>
<ChannelStateProvider value={{}}>
<ComponentProvider value={{}}>
<Card {...cardProps} />
</ComponentProvider>
</ChannelStateProvider>
<TranslationContext.Provider value={mockTranslationContext}>
<ChannelStateProvider value={{}}>
<ComponentProvider value={{}}>
<Card {...cardProps} />
</ComponentProvider>
</ChannelStateProvider>
</TranslationContext.Provider>
</ChatProvider>,
);

Expand Down
6 changes: 5 additions & 1 deletion src/components/Attachment/__tests__/File.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import renderer from 'react-test-renderer';
import { FileAttachment } from '../FileAttachment';

import { ChatContext } from '../../../context/ChatContext';
import { TranslationContext } from '../../../context';
import { mockTranslationContext } from '../../../mock-builders';

const getComponent = ({ attachment, chatContext }) => (
<ChatContext.Provider value={chatContext}>
<FileAttachment attachment={attachment} />
<TranslationContext.Provider value={mockTranslationContext}>
<FileAttachment attachment={attachment} />
</TranslationContext.Provider>
</ChatContext.Provider>
);

Expand Down
6 changes: 5 additions & 1 deletion src/components/ChannelHeader/ChannelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ const UnMemoizedChannelHeader = <

return (
<div className='str-chat__header-livestream str-chat__channel-header'>
<button aria-label='Menu' className='str-chat__header-hamburger' onClick={openMobileNav}>
<button
aria-label={t('aria/Menu')}
className='str-chat__header-hamburger'
onClick={openMobileNav}
>
<MenuIcon />
</button>
<Avatar
Expand Down
4 changes: 3 additions & 1 deletion src/components/ChannelList/ChannelListMessenger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LoadingChannels } from '../Loading/LoadingChannels';
import type { APIErrorResponse, Channel, ErrorFromResponse } from 'stream-chat';

import type { DefaultStreamChatGenerics } from '../../types/types';
import { useTranslationContext } from '../../context';

export type ChannelListMessengerProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
Expand Down Expand Up @@ -39,6 +40,7 @@ export const ChannelListMessenger = <
LoadingErrorIndicator = ChatDown,
LoadingIndicator = LoadingChannels,
} = props;
const { t } = useTranslationContext('ChannelListMessenger');

if (error) {
return <LoadingErrorIndicator type='Connection Error' />;
Expand All @@ -51,7 +53,7 @@ export const ChannelListMessenger = <
return (
<div className='str-chat__channel-list-messenger str-chat__channel-list-messenger-react'>
<div
aria-label='Channel list'
aria-label={t('aria/Channel list')}
className='str-chat__channel-list-messenger__main str-chat__channel-list-messenger-react__main'
role='listbox'
>
Expand Down
23 changes: 15 additions & 8 deletions src/components/ChannelList/__tests__/ChannelList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,14 @@ import {
ChannelPreviewMessenger,
} from '../../ChannelPreview';

import { ChatContext, useChannelListContext, useChatContext } from '../../../context';
import {
ChatContext,
TranslationContext,
useChannelListContext,
useChatContext,
} from '../../../context';
import { ChannelListMessenger } from '../ChannelListMessenger';
import { initClientWithChannels } from '../../../mock-builders';
import { initClientWithChannels, mockTranslationContext } from '../../../mock-builders';

expect.extend(toHaveNoViolations);

Expand Down Expand Up @@ -603,12 +608,14 @@ describe('ChannelList', () => {
...chatContext,
}}
>
<ChannelList
filters={{}}
options={{ presence: true, state: true }}
showChannelSearch
{...channeListProps}
/>
<TranslationContext.Provider value={mockTranslationContext}>
<ChannelList
filters={{}}
options={{ presence: true, state: true }}
showChannelSearch
{...channeListProps}
/>
</TranslationContext.Provider>
</ChatContext.Provider>,
);

Expand Down
22 changes: 13 additions & 9 deletions src/components/ChannelList/__tests__/ChannelListMessenger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,25 @@ import '@testing-library/jest-dom';
import renderer from 'react-test-renderer';

import { ChannelListMessenger } from '../ChannelListMessenger';
import { TranslationProvider } from '../../../context';
import { mockTranslationContext } from '../../../mock-builders';

// Weird hack to avoid big warnings
// Maybe better to find a better solution for it.
console.warn = () => null;

const Component = ({ error = false, loading = false }) => (
<ChannelListMessenger
error={error}
loading={loading}
LoadingErrorIndicator={() => <div>Loading Error Indicator</div>}
LoadingIndicator={() => <div>Loading Indicator</div>}
>
<div>children 1</div>
<div>children 2</div>
</ChannelListMessenger>
<TranslationProvider value={mockTranslationContext}>
<ChannelListMessenger
error={error}
loading={loading}
LoadingErrorIndicator={() => <div>Loading Error Indicator</div>}
LoadingIndicator={() => <div>Loading Indicator</div>}
>
<div>children 1</div>
<div>children 2</div>
</ChannelListMessenger>
</TranslationProvider>
);

describe('ChannelListMessenger', () => {
Expand Down
26 changes: 15 additions & 11 deletions src/components/ChannelSearch/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,21 @@ const DefaultSearchResultItem = <
const ResultsContainer = ({
children,
popupResults,
}: PropsWithChildren<{ popupResults?: boolean }>) => (
<div
aria-label='Channel search results'
className={clsx(
`str-chat__channel-search-container str-chat__channel-search-result-list`,
popupResults ? 'popup' : 'inline',
)}
>
{children}
</div>
);
}: PropsWithChildren<{ popupResults?: boolean }>) => {
const { t } = useTranslationContext('ResultsContainer');

return (
<div
aria-label={t('aria/Channel search results')}
className={clsx(
`str-chat__channel-search-container str-chat__channel-search-result-list`,
popupResults ? 'popup' : 'inline',
)}
>
{children}
</div>
);
};

export type SearchResultsController<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
Expand Down
2 changes: 1 addition & 1 deletion src/components/Emojis/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => {
)}
<button
aria-expanded={displayPicker}
aria-label='Emoji picker'
aria-label={t('aria/Emoji picker')}
className={props.buttonClassName ?? buttonClassName}
onClick={() => setDisplayPicker((cv) => !cv)}
ref={setReferenceElement}
Expand Down
6 changes: 3 additions & 3 deletions src/components/Gallery/__tests__/BaseImage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import '@testing-library/jest-dom';

import { BaseImage } from '../BaseImage';
import { TranslationProvider } from '../../../context';
import { mockTranslationContext } from '../../../mock-builders';

const props = {
alt: 'alt',
src: 'src',
};
const t = (val) => val;
const BASE_IMAGE_TEST_ID = 'str-chat__base-image';
const getImage = () => screen.queryByTestId(BASE_IMAGE_TEST_ID);

const renderComponent = (props = {}) =>
render(
<TranslationProvider value={{ t }}>
<TranslationProvider value={mockTranslationContext}>
<BaseImage {...props} />
</TranslationProvider>,
);
Expand Down Expand Up @@ -92,7 +92,7 @@ describe('BaseImage', () => {
fireEvent.error(getImage());

rerender(
<TranslationProvider value={{ t }}>
<TranslationProvider value={mockTranslationContext}>
<BaseImage src={'new-src'} />
</TranslationProvider>,
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/LoadMore/LoadMoreButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const UnMemoizedLoadMoreButton = ({
return (
<div className='str-chat__load-more-button'>
<button
aria-label='Load More Channels'
aria-label={t('aria/Load More Channels')}
className='str-chat__load-more-button__button str-chat__cta-button'
data-testid='load-more-button'
disabled={loading}
Expand Down
8 changes: 7 additions & 1 deletion src/components/LoadMore/__tests__/LoadMoreButton.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ import renderer from 'react-test-renderer';
import '@testing-library/jest-dom';

import { LoadMoreButton } from '../LoadMoreButton';
import { TranslationProvider } from '../../../context';
import { mockTranslationContext } from '../../../mock-builders';

describe('LoadMoreButton', () => {
afterEach(cleanup);

it('should render component with default props', () => {
const tree = renderer
.create(<LoadMoreButton isLoading={false} onClick={() => null} />)
.create(
<TranslationProvider value={mockTranslationContext}>
<LoadMoreButton isLoading={false} onClick={() => null} />
</TranslationProvider>,
)
.toJSON();
expect(tree).toMatchInlineSnapshot(`
<div
Expand Down
7 changes: 5 additions & 2 deletions src/components/Message/MessageOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MessageActions } from '../MessageActions';
import { MessageContextValue, useMessageContext } from '../../context/MessageContext';

import type { DefaultStreamChatGenerics, IconProps } from '../../types/types';
import { useTranslationContext } from '../../context';

export type MessageOptionsProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
Expand Down Expand Up @@ -56,6 +57,8 @@ const UnMemoizedMessageOptions = <
threadList,
} = useMessageContext<StreamChatGenerics>('MessageOptions');

const { t } = useTranslationContext('MessageOptions');

const handleOpenThread = propHandleOpenThread || contextHandleOpenThread;

const messageActions = getMessageActions();
Expand Down Expand Up @@ -87,7 +90,7 @@ const UnMemoizedMessageOptions = <
)}
{shouldShowReplies && (
<button
aria-label='Open Thread'
aria-label={t('aria/Open Thread')}
className={`str-chat__message-${theme}__actions__action str-chat__message-${theme}__actions__action--thread str-chat__message-reply-in-thread-button`}
data-testid='thread-action'
onClick={handleOpenThread}
Expand All @@ -98,7 +101,7 @@ const UnMemoizedMessageOptions = <
{shouldShowReactions && (
<button
aria-expanded={showDetailedReactions}
aria-label='Open Reaction Selector'
aria-label={t('aria/Open Reaction Selector')}
className={`str-chat__message-${theme}__actions__action str-chat__message-${theme}__actions__action--reactions str-chat__message-reactions-button`}
data-testid='message-reaction-action'
onClick={onReactionListClick}
Expand Down
Loading

0 comments on commit 7867677

Please sign in to comment.