diff --git a/docs/how_tos/i18n.rst b/docs/how_tos/i18n.rst index 1e211845..856429c4 100644 --- a/docs/how_tos/i18n.rst +++ b/docs/how_tos/i18n.rst @@ -151,7 +151,7 @@ Running ``npm run translations:pull`` will pull translations from ``openedx-tran Supported languages and switching languages ********************************************* -``frontend-base`` ships a language menu (in the footer shell) that lets users switch the site language at runtime. It is built on two optional ``SiteConfig`` values and a couple of i18n helpers exported from ``@openedx/frontend-base``: +``frontend-base`` ships a language menu that lets users switch the site language at runtime. It is built on two optional ``SiteConfig`` values and a couple of i18n helpers exported from ``@openedx/frontend-base``: - ``defaultLanguage``: The fallback locale when nothing in the resolution order above matches. Defaults to ``en``. - ``supportedLanguages``: An optional list of locale codes. When set, only locales in this list are considered supported; ``findSupportedLocale`` and ``getSupportedLanguageList`` filter by it. When empty (the default), every locale with loaded messages is considered supported. @@ -169,6 +169,44 @@ The language menu's list is produced by ``getSupportedLanguageList()``. It is de The ``name`` shown for each language is the localized name obtained from the browser's native ``Intl.DisplayNames`` API, so each language is displayed in its own language (e.g. ``Deutsch`` for ``de``). +Where the menu appears +---------------------- + +The menu comes in two presentations that share their switching behavior: a dropdown, ``LanguageMenu``, and a collapsible, ``LanguageMenuCollapsible``. They are registered as four widgets: + +.. list-table:: + :header-rows: 1 + + * - Slot + - Widget + - Where it shows + * - ``header.desktopRight.v1`` + - ``header.desktopLanguageMenu.v1`` + - Desktop header + * - ``header.mobileRight.v1`` + - ``header.mobileLanguageMenu.v1`` + - Mobile header, ``sm`` and up + * - ``header.mobileMenuLinks.v1`` + - ``header.mobileMenuLanguage.v1`` + - Top of the mobile menu, below ``sm`` + * - ``footer.desktopRightLinks.v1`` + - ``footer.desktopRightLinksLanguageMenu.v1`` + - Footer + +Slot IDs are prefixed with ``org.openedx.frontend.slot.`` and widget IDs with ``org.openedx.frontend.widget.``. + +The dropdown's toggle is a globe icon, with the current language's name beside it at ``md`` and up. Its ``aria-label`` names the language at every width. The collapsible shows the globe and the language name, and expands to the list in place. + +To hide or change the menu, apply ``REMOVE`` or ``REPLACE`` to each widget. Hiding it everywhere takes all four: + +.. code-block:: tsx + + { + slotId: 'org.openedx.frontend.slot.header.mobileMenuLinks.v1', + relatedId: 'org.openedx.frontend.widget.header.mobileMenuLanguage.v1', + op: WidgetOperationTypes.REMOVE, + }, + Switching languages ------------------- diff --git a/shell/footer/LanguageMenu.tsx b/shell/footer/LanguageMenu.tsx deleted file mode 100644 index c8abc288..00000000 --- a/shell/footer/LanguageMenu.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Dropdown, Toast } from '@openedx/paragon'; -import { useCallback, useContext, useState } from 'react'; - -import { - SiteContext, - getLocalizedLanguageName, - getSupportedLanguageList, - updateSiteLanguage, - useIntl, -} from '../../runtime'; - -import LanguageMenuItem from './LanguageMenuItem'; -import messages from './messages'; - -export default function LanguageMenu() { - const { formatMessage } = useIntl(); - const { locale } = useContext(SiteContext); - - const [pendingLanguage, setPendingLanguage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - const languages = getSupportedLanguageList(); - - const handleSelect = useCallback(async (languageCode: string) => { - setPendingLanguage(languageCode); - setErrorMessage(null); - try { - await updateSiteLanguage(languageCode); - } catch { - // The UI switch is optimistic and stays in the picked language; only the - // preference save failed, so surface that without reverting. - setErrorMessage(formatMessage(messages.languageSaveError)); - } finally { - setPendingLanguage(null); - } - }, [formatMessage]); - - // Hide the menu if there's only one language. - if (languages.length === 1) { - return null; - } - - const toggleLabel = pendingLanguage - ? getLocalizedLanguageName(pendingLanguage) - : getLocalizedLanguageName(locale); - - return ( - <> - - - {toggleLabel} - - - {languages.map((language) => ( - - ))} - - - {errorMessage && ( - setErrorMessage(null)} - > - {errorMessage} - - )} - - ); -} diff --git a/shell/footer/app.tsx b/shell/footer/app.tsx index fd316f49..ca804534 100644 --- a/shell/footer/app.tsx +++ b/shell/footer/app.tsx @@ -1,10 +1,10 @@ import { Slot, WidgetOperationTypes } from '../../runtime'; import { App } from '../../types'; import Logo from '../Logo'; +import LanguageMenu from '../menus/LanguageMenu'; import CopyrightNotice from './CopyrightNotice'; import DesktopFooterLayout from './DesktopFooterLayout'; import LabeledLinkColumn from './LabeledLinkColumn'; -import LanguageMenu from './LanguageMenu'; const app: App = { appId: 'org.openedx.frontend.app.footer', diff --git a/shell/header/AuthenticatedMenu.tsx b/shell/header/AuthenticatedMenu.tsx index 8ab786ef..82d040a5 100644 --- a/shell/header/AuthenticatedMenu.tsx +++ b/shell/header/AuthenticatedMenu.tsx @@ -19,7 +19,7 @@ export default function AuthenticatedMenu({ className }: AuthenticatedMenuProps) {displayUserName} diff --git a/shell/header/anonymous-menu/LoginButton.tsx b/shell/header/anonymous-menu/LoginButton.tsx index 5000762b..0a2b1508 100644 --- a/shell/header/anonymous-menu/LoginButton.tsx +++ b/shell/header/anonymous-menu/LoginButton.tsx @@ -13,7 +13,7 @@ export default function LoginButton({ ...props }) { const url = getUrlByRouteRole(loginRole) ?? config.loginUrl; return ( - ); diff --git a/shell/header/app.tsx b/shell/header/app.tsx index 02eef8d6..8a0c09cf 100644 --- a/shell/header/app.tsx +++ b/shell/header/app.tsx @@ -1,6 +1,8 @@ import { WidgetOperationTypes } from '../../runtime'; import { App } from '../../types'; import Logo from '../Logo'; +import LanguageMenu from '../menus/LanguageMenu'; +import LanguageMenuCollapsible from '../menus/LanguageMenuCollapsible'; import LinkMenuItem from '../menus/LinkMenuItem'; import ProfileLinkMenuItem from '../menus/ProfileLinkMenuItem'; import AnonymousMenu from './anonymous-menu/AnonymousMenu'; @@ -111,10 +113,10 @@ const config: App = { } }, { - slotId: 'org.openedx.frontend.slot.header.anonymousMenu.v1', - id: 'org.openedx.frontend.widget.header.anonymousMenuLogin.v1', + slotId: 'org.openedx.frontend.slot.header.desktopRight.v1', + id: 'org.openedx.frontend.widget.header.desktopLanguageMenu.v1', op: WidgetOperationTypes.APPEND, - component: LoginButton, + component: LanguageMenu, }, { slotId: 'org.openedx.frontend.slot.header.anonymousMenu.v1', @@ -122,6 +124,12 @@ const config: App = { op: WidgetOperationTypes.APPEND, component: RegisterButton, }, + { + slotId: 'org.openedx.frontend.slot.header.anonymousMenu.v1', + id: 'org.openedx.frontend.widget.header.anonymousMenuLogin.v1', + op: WidgetOperationTypes.APPEND, + component: LoginButton, + }, // Mobile { @@ -136,6 +144,13 @@ const config: App = { op: WidgetOperationTypes.APPEND, component: MobileNavLinks }, + { + slotId: 'org.openedx.frontend.slot.header.mobileMenuLinks.v1', + id: 'org.openedx.frontend.widget.header.mobileMenuLanguage.v1', + // Prepended so it stays at the top of the menu whatever order the site lists its apps in. + op: WidgetOperationTypes.PREPEND, + element: , + }, { slotId: 'org.openedx.frontend.slot.header.mobileRight.v1', id: 'org.openedx.frontend.widget.header.mobileAuthenticatedMenu.v1', @@ -154,6 +169,13 @@ const config: App = { authenticated: false, } }, + { + slotId: 'org.openedx.frontend.slot.header.mobileRight.v1', + id: 'org.openedx.frontend.widget.header.mobileLanguageMenu.v1', + op: WidgetOperationTypes.APPEND, + // Below sm this is mobileMenuLanguage.v1's job, in the mobile menu. + element: , + }, { slotId: 'org.openedx.frontend.slot.header.courseNavigationBar.v1', id: 'org.openedx.frontend.widget.header.courseNavigationBar.v1', diff --git a/shell/header/mobile/MobileLayout.tsx b/shell/header/mobile/MobileLayout.tsx index 6028093f..c243c59f 100644 --- a/shell/header/mobile/MobileLayout.tsx +++ b/shell/header/mobile/MobileLayout.tsx @@ -37,11 +37,19 @@ export default function MobileLayout() { + {/* scrollLock would put data-scroll-locked on , whose scrollbar compensation + margin collapses the page on mobile. This menu is inline, so it doesn't need it. */} {mobileOpen && ( - setMobileOpen(false)} onEscapeKey={() => setMobileOpen(false)}> - + setMobileOpen(false)} + onEscapeKey={() => setMobileOpen(false)} + > + + + )} diff --git a/shell/footer/LanguageMenu.test.tsx b/shell/menus/LanguageMenu.test.tsx similarity index 78% rename from shell/footer/LanguageMenu.test.tsx rename to shell/menus/LanguageMenu.test.tsx index 2c062399..cb6d1676 100644 --- a/shell/footer/LanguageMenu.test.tsx +++ b/shell/menus/LanguageMenu.test.tsx @@ -37,12 +37,20 @@ describe('LanguageMenu', () => { }); }); + it('marks the toggle with a globe and a label that only wide viewports show', () => { + renderLanguageMenu(); + + const toggle = screen.getByRole('button', { name: 'Change language: English' }); + expect(toggle.querySelector('svg')).toBeInTheDocument(); + expect(screen.getByText('English')).toHaveClass('d-none', 'd-md-inline'); + }); + it('switches to the selected language', async () => { const user = userEvent.setup(); mockUpdateSiteLanguage.mockResolvedValue(undefined); renderLanguageMenu(); - await user.click(screen.getByRole('button', { name: 'English' })); + await user.click(screen.getByRole('button', { name: 'Change language: English' })); await user.click(screen.getByText(/español/i)); await waitFor(() => expect(mockUpdateSiteLanguage).toHaveBeenCalledWith('es-419')); @@ -53,7 +61,7 @@ describe('LanguageMenu', () => { mockUpdateSiteLanguage.mockImplementation(() => new Promise(() => {})); renderLanguageMenu(); - await user.click(screen.getByRole('button', { name: 'English' })); + await user.click(screen.getByRole('button', { name: 'Change language: English' })); await user.click(screen.getByText(/español/i)); expect(screen.getByRole('button', { expanded: false })).toHaveTextContent(/español/i); @@ -64,7 +72,7 @@ describe('LanguageMenu', () => { mockUpdateSiteLanguage.mockRejectedValue(new Error('Network Error')); renderLanguageMenu(); - await user.click(screen.getByRole('button', { name: 'English' })); + await user.click(screen.getByRole('button', { name: 'Change language: English' })); await user.click(screen.getByText(/español/i)); const toast = await screen.findByRole('alert'); diff --git a/shell/menus/LanguageMenu.tsx b/shell/menus/LanguageMenu.tsx new file mode 100644 index 00000000..149795fa --- /dev/null +++ b/shell/menus/LanguageMenu.tsx @@ -0,0 +1,72 @@ +import { Dropdown, Icon, Toast } from '@openedx/paragon'; +import { Language } from '@openedx/paragon/icons'; +import classNames from 'classnames'; +import { useId } from 'react'; + +import { getLocalizedLanguageName, useIntl } from '../../runtime'; + +import LanguageMenuItem from './LanguageMenuItem'; +import messages from './messages'; +import useLanguageSelection from './useLanguageSelection'; + +interface LanguageMenuProps { + className?: string; +} + +export default function LanguageMenu({ className }: LanguageMenuProps) { + const { formatMessage } = useIntl(); + // Both header layouts and the footer mount at once, so the id has to be unique per instance. + const toggleId = useId(); + const { + languages, + locale, + activeLocale, + pendingLanguage, + errorMessage, + selectLanguage, + dismissError, + } = useLanguageSelection(); + + // Hide the menu if there's only one language. + if (languages.length === 1) { + return null; + } + + const toggleLabel = getLocalizedLanguageName(activeLocale); + + return ( + <> + + + + {/* Below md the globe stands alone; the aria-label names the language at every width. */} + {toggleLabel} + + + {languages.map((language) => ( + + ))} + + + {errorMessage && ( + + {errorMessage} + + )} + + ); +} diff --git a/shell/menus/LanguageMenuCollapsible.test.tsx b/shell/menus/LanguageMenuCollapsible.test.tsx new file mode 100644 index 00000000..6ab18e76 --- /dev/null +++ b/shell/menus/LanguageMenuCollapsible.test.tsx @@ -0,0 +1,67 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; + +import { SiteContext, configureI18n } from '../../runtime'; + +import LanguageMenuCollapsible from './LanguageMenuCollapsible'; + +jest.mock('../../runtime', () => ({ + ...jest.requireActual('../../runtime'), + updateSiteLanguage: jest.fn(), +})); + +const mockUpdateSiteLanguage = jest.requireMock('../../runtime').updateSiteLanguage as jest.Mock; + +function renderCollapsible(locale = 'en') { + return render( + + + + + , + ); +} + +describe('LanguageMenuCollapsible', () => { + beforeEach(() => { + jest.clearAllMocks(); + configureI18n({ + messages: { + 'es-419': {}, + ar: {}, + }, + }); + }); + + it('keeps the languages collapsed behind the current one', () => { + renderCollapsible(); + + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.queryByText(/español/i)).not.toBeInTheDocument(); + }); + + it('switches to a language picked from the expanded list', async () => { + const user = userEvent.setup(); + mockUpdateSiteLanguage.mockResolvedValue(undefined); + renderCollapsible(); + + await user.click(screen.getByText('English')); + await user.click(await screen.findByText(/español/i)); + + await waitFor(() => expect(mockUpdateSiteLanguage).toHaveBeenCalledWith('es-419')); + }); + + it('shows a toast when the preference save fails', async () => { + const user = userEvent.setup(); + mockUpdateSiteLanguage.mockRejectedValue(new Error('Network Error')); + renderCollapsible(); + + await user.click(screen.getByText('English')); + await user.click(await screen.findByText(/español/i)); + + const toast = await screen.findByRole('alert'); + expect(toast).toHaveTextContent(/could not save your language preference/i); + }); +}); diff --git a/shell/menus/LanguageMenuCollapsible.tsx b/shell/menus/LanguageMenuCollapsible.tsx new file mode 100644 index 00000000..3202f92e --- /dev/null +++ b/shell/menus/LanguageMenuCollapsible.tsx @@ -0,0 +1,73 @@ +import { Alert, Collapsible, Icon, Menu } from '@openedx/paragon'; +import classNames from 'classnames'; +import { Language } from '@openedx/paragon/icons'; + +import { getLocalizedLanguageName } from '../../runtime'; + +import LanguageMenuItem from './LanguageMenuItem'; +import useLanguageSelection from './useLanguageSelection'; + +import './languageMenu.scss'; + +interface LanguageMenuCollapsibleProps { + className?: string; +} + +/** + * The language menu as an in-flow collapsible, for the mobile menu. A dropdown inside that + * focus-trapped panel would have to escape it to be seen; this expands in place instead. + */ +export default function LanguageMenuCollapsible({ className }: LanguageMenuCollapsibleProps) { + const { + languages, + locale, + activeLocale, + pendingLanguage, + errorMessage, + selectLanguage, + dismissError, + } = useLanguageSelection(); + + // Hide the menu if there's only one language. + if (languages.length === 1) { + return null; + } + + const title = ( + + + {getLocalizedLanguageName(activeLocale)} + + ); + + return ( + + {/* Inline rather than a toast: a toast portals to the body, outside the mobile menu's + focus trap, so it can't be reached by keyboard and dismissing it closes the menu. */} + {errorMessage && ( + + {errorMessage} + + )} + {/* Paragon scopes .pgn__menu-item under .pgn__menu, so the rows are only styled + inside this wrapper, which also gives them arrow-key navigation. */} + + {languages.map((language) => ( + + ))} + + + ); +} diff --git a/shell/footer/LanguageMenuItem.tsx b/shell/menus/LanguageMenuItem.tsx similarity index 54% rename from shell/footer/LanguageMenuItem.tsx rename to shell/menus/LanguageMenuItem.tsx index 7dc07fb9..5d85ab91 100644 --- a/shell/footer/LanguageMenuItem.tsx +++ b/shell/menus/LanguageMenuItem.tsx @@ -1,4 +1,4 @@ -import { Dropdown } from '@openedx/paragon'; +import { Dropdown, MenuItem } from '@openedx/paragon'; import { useCallback } from 'react'; interface LanguageMenuItemProps { @@ -8,6 +8,7 @@ interface LanguageMenuItemProps { }; disabled?: boolean; isActive?: boolean; + variant?: 'dropdownItem' | 'menuItem'; onSelect: (code: string) => void; } @@ -15,15 +16,30 @@ export default function LanguageMenuItem({ language, disabled, isActive, + variant = 'dropdownItem', onSelect, }: LanguageMenuItemProps) { const handleClick = useCallback(() => { onSelect(language.code); }, [language.code, onSelect]); + if (variant === 'menuItem') { + return ( + + {language.name} + + ); + } + return ( (null); + const [errorMessage, setErrorMessage] = useState(null); + + const selectLanguage = useCallback(async (languageCode: string) => { + setPendingLanguage(languageCode); + setErrorMessage(null); + try { + await updateSiteLanguage(languageCode); + } catch { + // The UI switch is optimistic and stays in the picked language; only the + // preference save failed, so surface that without reverting. + setErrorMessage(formatMessage(messages.languageSaveError)); + } finally { + setPendingLanguage(null); + } + }, [formatMessage]); + + const dismissError = useCallback(() => setErrorMessage(null), []); + + return { + languages: getSupportedLanguageList(), + locale, + // The picked language shows immediately, before the preference finishes saving. + activeLocale: pendingLanguage ?? locale, + pendingLanguage, + errorMessage, + selectLanguage, + dismissError, + }; +} diff --git a/shell/public/index.html b/shell/public/index.html index 021d0868..29c855e8 100644 --- a/shell/public/index.html +++ b/shell/public/index.html @@ -1,6 +1,7 @@ + Shell Development Site diff --git a/test-site/public/index.html b/test-site/public/index.html index 4af44736..144f34e9 100644 --- a/test-site/public/index.html +++ b/test-site/public/index.html @@ -1,6 +1,7 @@ + Test Site