diff --git a/src/index.scss b/src/index.scss index 1094eb87c..53edbe66a 100644 --- a/src/index.scss +++ b/src/index.scss @@ -7,6 +7,7 @@ $rounded-pill: 50rem !default; @import './Menu/menu.scss'; @import './studio-header/StudioHeader.scss'; +@import './language-selector/LanguageSelector.scss'; .dropdown-item a { text-decoration: none; diff --git a/src/language-selector/LanguageSelector.jsx b/src/language-selector/LanguageSelector.jsx new file mode 100644 index 000000000..451ab8a21 --- /dev/null +++ b/src/language-selector/LanguageSelector.jsx @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { useContext, useState } from 'react'; + +import { changeUserSessionLanguage, getPrimaryLanguageSubtag, injectIntl } from '@edx/frontend-platform/i18n'; +import { getLocale } from '@edx/frontend-platform/i18n/lib'; +import { AppContext } from '@edx/frontend-platform/react'; +import { Dropdown } from '@openedx/paragon'; +import { Language } from '@openedx/paragon/icons'; + +/** + * Gets the localized display name of a language in its own language. + * + * @function getDisplayName + * @param {string} locale - The locale code (e.g., 'en', 'es', 'ar') + * @returns {string} The capitalized display name of the language in its native form + * @example + */ +const getDisplayName = (locale) => { + const langName = new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' }).of(locale); + return langName.charAt(0).toUpperCase() + langName.slice(1); +}; + +/** + * Language Selector component that displays a dropdown allowing users to change the site language. + * + * The component is responsive and adapts to different screen sizes: + * - On large screens: Shows the full language name (e.g., "English") + * - On medium screens: Shows the language code (e.g., "EN") + * - On small screens: Shows only the language icon + * + * @component + * @param {Object} props - Component props + * @param {string} [props.className=''] - Additional CSS class names to apply to the component + * @returns {React.Element|null} The rendered component or null if disabled or no supported languages + * + * @requires config.SITE_SUPPORTED_LANGUAGES - Must be a non-empty array of locale codes + * @requires config.LANGUAGE_PREFERENCE_COOKIE_NAME - Cookie name for storing language preference + */ +const LanguageSelector = ({ className }) => { + const { config } = useContext(AppContext); + + const languageOptions = config.SITE_SUPPORTED_LANGUAGES; + const [currentLocale, setCurrentLocale] = useState(getLocale()); + + /** + * Handles the selection of a language from the dropdown. + * Only triggers language change if the selected language is different from the current one. + * + * @param {string} selectedLocale - The locale code selected by the user + */ + const handleSelect = (selectedLocale) => { + if (currentLocale !== selectedLocale) { + changeUserSessionLanguage(selectedLocale); + setCurrentLocale(selectedLocale); + } + }; + + const currentLangCode = getPrimaryLanguageSubtag(currentLocale).toUpperCase(); + const currentlangDisplayName = getDisplayName(currentLocale); + + // Don't render the component if there are no language options + if (!Array.isArray(languageOptions) + || languageOptions.length === 0) { + return null; + } + + return ( +
+ + + {currentLangCode} + {currentlangDisplayName} + + + {languageOptions.map((locale) => ( + + {getDisplayName(locale)} + + ))} + + +
+ ); +}; + +LanguageSelector.propTypes = { + className: PropTypes.string, +}; + +LanguageSelector.defaultProps = { + className: '', +}; + +export default injectIntl(LanguageSelector); diff --git a/src/language-selector/LanguageSelector.scss b/src/language-selector/LanguageSelector.scss new file mode 100644 index 000000000..7c2b439a5 --- /dev/null +++ b/src/language-selector/LanguageSelector.scss @@ -0,0 +1,22 @@ +.language-selector { + padding: .75rem; + + .dropdown-toggle { + .lang-label-medium, + .lang-label-large { + display: none; + } + + @media (min-width: 576px) and (max-width: 767px) { + .lang-label-medium { + display: inline; + } + } + + @media (min-width: 768px) { + .lang-label-large { + display: inline; + } + } + } +} diff --git a/src/language-selector/LanguageSelector.test.jsx b/src/language-selector/LanguageSelector.test.jsx new file mode 100644 index 000000000..d6377263d --- /dev/null +++ b/src/language-selector/LanguageSelector.test.jsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { mergeConfig } from '@edx/frontend-platform'; +import { getLocale } from '@edx/frontend-platform/i18n/lib'; +import { changeUserSessionLanguage } from '@edx/frontend-platform/i18n'; +import { + act, fireEvent, initializeMockApp, render, screen, +} from '../setupTest'; +import LanguageSelector from './LanguageSelector'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + changeUserSessionLanguage: jest.fn().mockResolvedValue({}), +})); + +jest.mock('@edx/frontend-platform/i18n/lib', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n/lib'), + getLocale: jest.fn(), +})); + +jest.mock('@openedx/paragon/icons', () => ({ + Language: () =>
LanguageIcon
, +})); + +jest.mock('@openedx/paragon', () => ({ + ...jest.requireActual('@openedx/paragon'), + useWindowSize: () => ({ width: global.innerWidth }), +})); + +const LANGUAGE_PREFERENCE_COOKIE_NAME = 'language-preference'; + +describe('LanguageSelector', () => { + let mockReload; + + beforeEach(() => { + jest.clearAllMocks(); + + mergeConfig({ + ENABLE_HEADER_LANG_SELECTOR: true, + LANGUAGE_PREFERENCE_COOKIE_NAME, + SITE_SUPPORTED_LANGUAGES: ['es', 'en'], + }); + + initializeMockApp(); + + mockReload = jest.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { reload: mockReload }, + }); + + global.innerWidth = 1200; + }); + + it('should not render when no supported languages are available', () => { + mergeConfig({ + SITE_SUPPORTED_LANGUAGES: [], + }); + + const { container } = render(); + // expect(container).toMatchSnapshot('no-supported-languages'); + expect(container.querySelector('#language-selector')).toBeNull(); + }); + + it('should change the language when different language is selected', async () => { + getLocale.mockReturnValue('en'); + + const { container } = render(); + expect(container).toMatchSnapshot('before-language-change'); + + const langDropdown = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + fireEvent.click(langDropdown); + + const spanishOption = screen.getByRole('button', { name: 'Español' }); + + await act(async () => { + fireEvent.click(spanishOption); + }); + + expect(container).toMatchSnapshot('after-language-change'); + expect(changeUserSessionLanguage).toHaveBeenCalledWith('es'); + }); + + it('should not change language if the same language is selected', async () => { + getLocale.mockReturnValue('en'); + + const { container } = render(); + expect(container).toMatchSnapshot('before-same-language-selection'); + + const langDropdown = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + fireEvent.click(langDropdown); + + const englishOption = screen.getByRole('button', { name: 'English' }); + await act(async () => { + fireEvent.click(englishOption); + }); + + expect(container).toMatchSnapshot('after-same-language-selection'); + expect(changeUserSessionLanguage).not.toHaveBeenCalled(); + }); + + it('should display full language name on large screens', () => { + getLocale.mockReturnValue('en'); + + global.innerWidth = 1200; + render(); + + const button = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + expect(button).toMatchSnapshot('large-screen-button'); + }); + + it('should display language code on medium screens', () => { + getLocale.mockReturnValue('en'); + + global.innerWidth = 700; + render(); + + const button = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + expect(button).toMatchSnapshot('medium-screen-button'); + }); + + it('should display only icon on small screens', () => { + getLocale.mockReturnValue('en'); + + global.innerWidth = 500; + render(); + + const button = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + expect(button).toMatchSnapshot('small-screen-button'); + }); +}); diff --git a/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap b/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap new file mode 100644 index 000000000..a93c368a6 --- /dev/null +++ b/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap @@ -0,0 +1,303 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LanguageSelector should change the language when different language is selected: after-language-change 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should change the language when different language is selected: before-language-change 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should display full language name on large screens: large-screen-button 1`] = ` + +`; + +exports[`LanguageSelector should display language code on medium screens: medium-screen-button 1`] = ` + +`; + +exports[`LanguageSelector should display only icon on small screens: small-screen-button 1`] = ` + +`; + +exports[`LanguageSelector should not change language if the same language is selected: after-same-language-selection 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should not change language if the same language is selected: before-same-language-selection 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/src/language-selector/index.js b/src/language-selector/index.js new file mode 100644 index 000000000..9e5250568 --- /dev/null +++ b/src/language-selector/index.js @@ -0,0 +1,3 @@ +import LanguageSelector from './LanguageSelector'; + +export default LanguageSelector; diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index 723b5927d..bc323b37d 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -11,6 +11,7 @@ import CourseInfoSlot from '../plugin-slots/CourseInfoSlot'; import { courseInfoDataShape } from './LearningHeaderCourseInfo'; import messages from './messages'; import LearningHelpSlot from '../plugin-slots/LearningHelpSlot'; +import LanguageSelector from '../language-selector'; const LearningHeader = ({ courseOrg, courseNumber, courseTitle, intl, showUserDropdown, @@ -33,6 +34,7 @@ const LearningHeader = ({
+ {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} {showUserDropdown && authenticatedUser && ( <> diff --git a/src/studio-header/HeaderBody.jsx b/src/studio-header/HeaderBody.jsx index 3ed7403af..6f25f7475 100644 --- a/src/studio-header/HeaderBody.jsx +++ b/src/studio-header/HeaderBody.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import classNames from 'classnames'; import { @@ -12,7 +13,7 @@ import { Row, } from '@openedx/paragon'; import { Close, MenuIcon, Search } from '@openedx/paragon/icons'; - +import LanguageSelector from '../language-selector'; import CourseLockUp from './CourseLockUp'; import UserMenu from './UserMenu'; import BrandNav from './BrandNav'; @@ -116,6 +117,7 @@ const HeaderBody = ({ )} + {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} {searchButtonAction && (