Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion docs/how_tos/i18n.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
-------------------

Expand Down
75 changes: 0 additions & 75 deletions shell/footer/LanguageMenu.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion shell/footer/app.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
2 changes: 1 addition & 1 deletion shell/header/AuthenticatedMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function AuthenticatedMenu({ className }: AuthenticatedMenuProps)
<Dropdown.Toggle
as={AvatarButton}
id="user-nav-dropdown"
variant="outline-primary"
variant="tertiary"
src={authenticatedUser?.avatar}
>
{displayUserName}
Expand Down
2 changes: 1 addition & 1 deletion shell/header/anonymous-menu/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function LoginButton({ ...props }) {
const url = getUrlByRouteRole(loginRole) ?? config.loginUrl;

return (
<Button variant="link" {...getLinkProps(url)} {...props}>
<Button variant="primary" {...getLinkProps(url)} {...props}>
{intl.formatMessage(messages['header.user.menu.login'])}
</Button>
);
Expand Down
28 changes: 25 additions & 3 deletions shell/header/app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -111,17 +113,23 @@ 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',
id: 'org.openedx.frontend.widget.header.anonymousMenuRegister.v1',
op: WidgetOperationTypes.APPEND,
component: RegisterButton,
Comment thread
arbrandes marked this conversation as resolved.
},
{
slotId: 'org.openedx.frontend.slot.header.anonymousMenu.v1',
id: 'org.openedx.frontend.widget.header.anonymousMenuLogin.v1',
op: WidgetOperationTypes.APPEND,
component: LoginButton,
},

// Mobile
{
Expand All @@ -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: <LanguageMenuCollapsible className="d-sm-none" />,
},
{
slotId: 'org.openedx.frontend.slot.header.mobileRight.v1',
id: 'org.openedx.frontend.widget.header.mobileAuthenticatedMenu.v1',
Expand All @@ -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: <LanguageMenu className="d-none d-sm-flex" />,
},
{
slotId: 'org.openedx.frontend.slot.header.courseNavigationBar.v1',
id: 'org.openedx.frontend.widget.header.courseNavigationBar.v1',
Expand Down
16 changes: 12 additions & 4 deletions shell/header/mobile/MobileLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,19 @@ export default function MobileLayout() {
<Slot id="org.openedx.frontend.slot.header.mobileRight.v1" />
</div>
</Container>
{/* scrollLock would put data-scroll-locked on <body>, whose scrollbar compensation
margin collapses the page on mobile. This menu is inline, so it doesn't need it. */}
{mobileOpen && (
<FocusOn onClickOutside={() => setMobileOpen(false)} onEscapeKey={() => setMobileOpen(false)}>
<Nav className="flex-column">
<Slot id="org.openedx.frontend.slot.header.mobileMenuLinks.v1" />
</Nav>
<FocusOn
scrollLock={false}
onClickOutside={() => setMobileOpen(false)}
onEscapeKey={() => setMobileOpen(false)}
>
<Container fluid size="xl">
<Nav className="flex-column">
<Slot id="org.openedx.frontend.slot.header.mobileMenuLinks.v1" />
</Nav>
</Container>
</FocusOn>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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);
Expand All @@ -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');
Expand Down
72 changes: 72 additions & 0 deletions shell/menus/LanguageMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Dropdown className={classNames('mx-2', className)}>
<Dropdown.Toggle
id={toggleId}
variant="tertiary"
className="d-flex align-items-center"
aria-label={formatMessage(messages.languageMenuToggle, { language: toggleLabel })}
>
<Icon src={Language} size="sm" />
{/* Below md the globe stands alone; the aria-label names the language at every width. */}
<span className="d-none d-md-inline ml-2">{toggleLabel}</span>
</Dropdown.Toggle>
<Dropdown.Menu className="overflow-auto" style={{ maxHeight: '320px' }}>
{languages.map((language) => (
<LanguageMenuItem
key={language.code}
language={language}
disabled={pendingLanguage !== null}
isActive={language.code === locale}
onSelect={selectLanguage}
/>
))}
</Dropdown.Menu>
</Dropdown>
{errorMessage && (
<Toast
show
onClose={dismissError}
>
{errorMessage}
</Toast>
)}
</>
);
}
Loading
Loading