Skip to content

Commit 829d2f8

Browse files
committed
feat: add language selector component to header
1 parent 8ef3a27 commit 829d2f8

File tree

8 files changed

+568
-1
lines changed

8 files changed

+568
-1
lines changed

src/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ $rounded-pill: 50rem !default;
77

88
@import './Menu/menu.scss';
99
@import './studio-header/StudioHeader.scss';
10+
@import './language-selector/LanguageSelector.scss';
1011

1112
.dropdown-item a {
1213
text-decoration: none;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import PropTypes from 'prop-types';
2+
import React, { useContext } from 'react';
3+
4+
import { changeUserSessionLanguage, getPrimaryLanguageSubtag, injectIntl } from '@edx/frontend-platform/i18n';
5+
import { getCookies } from '@edx/frontend-platform/i18n/lib';
6+
import { AppContext } from '@edx/frontend-platform/react';
7+
import { Dropdown } from '@openedx/paragon';
8+
import { Language } from '@openedx/paragon/icons';
9+
10+
/**
11+
* Gets the localized display name of a language in its own language.
12+
*
13+
* @function getDisplayName
14+
* @param {string} locale - The locale code (e.g., 'en', 'es', 'ar')
15+
* @returns {string} The capitalized display name of the language in its native form
16+
* @example
17+
*/
18+
const getDisplayName = (locale) => {
19+
const langName = new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' }).of(locale);
20+
return langName.charAt(0).toUpperCase() + langName.slice(1);
21+
};
22+
23+
/**
24+
* Language Selector component that displays a dropdown allowing users to change the site language.
25+
*
26+
* The component is responsive and adapts to different screen sizes:
27+
* - On large screens: Shows the full language name (e.g., "English")
28+
* - On medium screens: Shows the language code (e.g., "EN")
29+
* - On small screens: Shows only the language icon
30+
*
31+
* @component
32+
* @param {Object} props - Component props
33+
* @param {string} [props.className=''] - Additional CSS class names to apply to the component
34+
* @returns {React.Element|null} The rendered component or null if disabled or no supported languages
35+
*
36+
* @requires config.SITE_SUPPORTED_LANGUAGES - Must be a non-empty array of locale codes
37+
* @requires config.LANGUAGE_PREFERENCE_COOKIE_NAME - Cookie name for storing language preference
38+
*/
39+
const LanguageSelector = ({ className }) => {
40+
const { config } = useContext(AppContext);
41+
const cookies = getCookies();
42+
43+
const languageOptions = config.SITE_SUPPORTED_LANGUAGES;
44+
const langCookieName = config.LANGUAGE_PREFERENCE_COOKIE_NAME;
45+
const currentLocale = cookies.get(langCookieName) || 'en';
46+
47+
/**
48+
* Handles the selection of a language from the dropdown.
49+
* Only triggers language change if the selected language is different from the current one.
50+
*
51+
* @param {string} selectedLocale - The locale code selected by the user
52+
*/
53+
const handleSelect = (selectedLocale) => {
54+
if (currentLocale !== selectedLocale) {
55+
changeUserSessionLanguage(selectedLocale);
56+
}
57+
};
58+
59+
const currentLangCode = getPrimaryLanguageSubtag(currentLocale).toUpperCase();
60+
const currentlangDisplayName = getDisplayName(currentLocale);
61+
62+
// Don't render the component if there are no language options
63+
if (!Array.isArray(languageOptions)
64+
|| languageOptions.length === 0) {
65+
return null;
66+
}
67+
68+
return (
69+
<div className={`${className} language-selector`} id="language-selector">
70+
<Dropdown onSelect={handleSelect}>
71+
<Dropdown.Toggle
72+
id="lang-selector-dropdown"
73+
iconBefore={Language}
74+
variant="outline-primary"
75+
size="sm"
76+
>
77+
<span className="lang-label-medium">{currentLangCode}</span>
78+
<span className="lang-label-large">{currentlangDisplayName}</span>
79+
</Dropdown.Toggle>
80+
<Dropdown.Menu>
81+
{languageOptions.map((locale) => (
82+
<Dropdown.Item key={`lang-selector-${locale}`} eventKey={locale}>
83+
{getDisplayName(locale)}
84+
</Dropdown.Item>
85+
))}
86+
</Dropdown.Menu>
87+
</Dropdown>
88+
</div>
89+
);
90+
};
91+
92+
LanguageSelector.propTypes = {
93+
className: PropTypes.string,
94+
};
95+
96+
LanguageSelector.defaultProps = {
97+
className: '',
98+
};
99+
100+
export default injectIntl(LanguageSelector);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.language-selector {
2+
padding: .75rem;
3+
4+
.dropdown-toggle {
5+
.lang-label-medium,
6+
.lang-label-large {
7+
display: none;
8+
}
9+
10+
@media (min-width: 576px) and (max-width: 767px) {
11+
.lang-label-medium {
12+
display: inline;
13+
}
14+
}
15+
16+
@media (min-width: 768px) {
17+
.lang-label-large {
18+
display: inline;
19+
}
20+
}
21+
}
22+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React from 'react';
2+
import { mergeConfig } from '@edx/frontend-platform';
3+
import { getCookies } from '@edx/frontend-platform/i18n/lib';
4+
import { changeUserSessionLanguage } from '@edx/frontend-platform/i18n';
5+
import {
6+
act, fireEvent, initializeMockApp, render, screen,
7+
} from '../setupTest';
8+
import LanguageSelector from './LanguageSelector';
9+
10+
jest.mock('@edx/frontend-platform/i18n', () => ({
11+
...jest.requireActual('@edx/frontend-platform/i18n'),
12+
changeUserSessionLanguage: jest.fn().mockResolvedValue({}),
13+
}));
14+
15+
jest.mock('@openedx/paragon/icons', () => ({
16+
Language: () => <div>LanguageIcon</div>,
17+
}));
18+
19+
jest.mock('@openedx/paragon', () => ({
20+
...jest.requireActual('@openedx/paragon'),
21+
useWindowSize: () => ({ width: global.innerWidth }),
22+
}));
23+
24+
const LANGUAGE_PREFERENCE_COOKIE_NAME = 'language-preference';
25+
26+
describe('LanguageSelector', () => {
27+
let mockReload;
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
32+
mergeConfig({
33+
ENABLE_HEADER_LANG_SELECTOR: true,
34+
LANGUAGE_PREFERENCE_COOKIE_NAME,
35+
SITE_SUPPORTED_LANGUAGES: ['es', 'en'],
36+
});
37+
38+
initializeMockApp();
39+
40+
mockReload = jest.fn();
41+
Object.defineProperty(window, 'location', {
42+
configurable: true,
43+
writable: true,
44+
value: { reload: mockReload },
45+
});
46+
47+
global.innerWidth = 1200;
48+
});
49+
50+
it('should not render when no supported languages are available', () => {
51+
mergeConfig({
52+
SITE_SUPPORTED_LANGUAGES: [],
53+
});
54+
55+
const { container } = render(<LanguageSelector />);
56+
expect(container).toMatchSnapshot('no-supported-languages');
57+
expect(container.querySelector('#language-selector')).toBeNull();
58+
});
59+
60+
it('should change the language when different language is selected', async () => {
61+
jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en');
62+
63+
const { container } = render(<LanguageSelector />);
64+
expect(container).toMatchSnapshot('before-language-change');
65+
66+
const langDropdown = screen.getByRole('button', { id: 'lang-selector-dropdown' });
67+
fireEvent.click(langDropdown);
68+
69+
const spanishOption = screen.getByRole('button', { name: 'Español' });
70+
71+
await act(async () => {
72+
fireEvent.click(spanishOption);
73+
});
74+
75+
expect(container).toMatchSnapshot('after-language-change');
76+
expect(changeUserSessionLanguage).toHaveBeenCalledWith('es');
77+
});
78+
79+
it('should not change language if the same language is selected', async () => {
80+
jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en');
81+
82+
const { container } = render(<LanguageSelector />);
83+
expect(container).toMatchSnapshot('before-same-language-selection');
84+
85+
const langDropdown = screen.getByRole('button', { id: 'lang-selector-dropdown' });
86+
fireEvent.click(langDropdown);
87+
88+
const englishOption = screen.getByRole('button', { name: 'English' });
89+
await act(async () => {
90+
fireEvent.click(englishOption);
91+
});
92+
93+
expect(container).toMatchSnapshot('after-same-language-selection');
94+
expect(changeUserSessionLanguage).not.toHaveBeenCalled();
95+
});
96+
97+
it('should display full language name on large screens', () => {
98+
jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en');
99+
100+
global.innerWidth = 1200;
101+
render(<LanguageSelector />);
102+
103+
const button = screen.getByRole('button', { id: 'lang-selector-dropdown' });
104+
expect(button).toMatchSnapshot('large-screen-button');
105+
});
106+
107+
it('should display language code on medium screens', () => {
108+
jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en');
109+
110+
global.innerWidth = 700;
111+
render(<LanguageSelector />);
112+
113+
const button = screen.getByRole('button', { id: 'lang-selector-dropdown' });
114+
expect(button).toMatchSnapshot('medium-screen-button');
115+
});
116+
117+
it('should display only icon on small screens', () => {
118+
jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en');
119+
120+
global.innerWidth = 500;
121+
render(<LanguageSelector />);
122+
123+
const button = screen.getByRole('button', { id: 'lang-selector-dropdown' });
124+
expect(button).toMatchSnapshot('small-screen-button');
125+
});
126+
});

0 commit comments

Comments
 (0)