Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement LocaleSwitcher Component #273

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
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
20 changes: 18 additions & 2 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"jest-axe": "^8.0.0",
"jest-cli": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"next-router-mock": "^0.9.10",
"postcss": "^8.4.31",
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^9.0.0",
Expand Down
15 changes: 10 additions & 5 deletions app/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
Header as USWDSHeader,
} from "@trussworks/react-uswds";

import LocaleSwitcher from "./LocaleSwitcher";

const primaryLinks = [
{
i18nKey: "nav_link_home",
Expand All @@ -28,11 +30,14 @@ const Header = () => {
setIsMobileNavExpanded(!isMobileNavExpanded);
};

const navItems = primaryLinks.map((link) => (
<a href={link.href} key={link.href}>
{t(link.i18nKey)}
</a>
));
const navItems = [
...primaryLinks.map((link) => (
<a href={link.href} key={link.href}>
{t(link.i18nKey)}
</a>
)),
<LocaleSwitcher key={"locale-switch"} />,
];

return (
<>
Expand Down
53 changes: 53 additions & 0 deletions app/src/components/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { usePathname, useRouter } from "src/i18n/navigation";

import { useLocale } from "next-intl";
import { CSSProperties, useTransition } from "react";
import { LanguageDefinition, LanguageSelector } from "@trussworks/react-uswds";

// Currently, the `react-uswds` component erroneously sets 'usa-language-container' class
// on both the container and the button, which causes incorrect positioning relative to nav items
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we open a PR to fix that for them? They're usually pretty open to contributions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const styleFixes: CSSProperties = {
display: "block",
top: "auto",
marginLeft: "auto",
marginTop: "auto",
};

export default function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const [, startTransition] = useTransition();
const pathname = usePathname();

const selectLocale = (newLocale: string) => {
startTransition(() => {
router.replace(pathname, { locale: newLocale });
});
};

// This should be modified to fit the project's language requirements
// If you have more than two languages, it will render as a dropdown
// The react-uswds component will just display the `label` for the current language;
// USWDS guidance is to display "Language" in the current language as the label, which isn't currently possible
// See https://designsystem.digital.gov/components/language-selector/
// We're using two languages by default here, but implementing such that it displays the language to which it will switch rather than the current language
const langs: LanguageDefinition[] = [
{
attr: "en-US",
label: "Español",
label_local: "Spanish",
on_click: () => selectLocale("es-US"),
},
{
attr: "es-US",
label: "English",
on_click: () => selectLocale("en-US"),
},
];

return (
<LanguageSelector displayLang={locale} langs={langs} style={styleFixes} />
);
}
8 changes: 8 additions & 0 deletions app/src/i18n/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createSharedPathnamesNavigation } from "next-intl/navigation";

import { locales } from "./config";

export const localePrefix = "always"; // Default

export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales, localePrefix });
39 changes: 39 additions & 0 deletions app/tests/components/LocaleSwitcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { mockRouter } from "tests/next-router-utils";
import { render, screen } from "tests/react-utils";

import LocaleSwitcher from "src/components/LocaleSwitcher";

describe("LocaleSwitcher", () => {
it("renders the language selector and updates routes when switching language", async () => {
// Set the initial url
await mockRouter.push("/initial-path");

render(<LocaleSwitcher />);

// We start in English, so we see the toggle to switch to Spanish
await userEvent.click(screen.getByRole("button", { name: "Español" }));

// Ensure the router was updated
// This is the best we can do for testing given constraints listed below
expect(mockRouter).toMatchObject({
asPath: "/es-US/initial-path",
pathname: "/es-US/initial-path",
});

// This won't actually work because the NextIntlProvider relies on middleware that isn't available in tests
// expect(
// await screen.findByRole("button", { name: "English" })
// ).toBeInTheDocument();
});
Comment on lines +25 to +29
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left this in to note an a11y issue with the current version of the react-uswds component.


// This fails when in 2-language mode because the react-uswds component sets aria-controls
// w/o corresponding element in the DOM
it("passes accessibility scan", async () => {
const { container } = render(<LocaleSwitcher />);
const results = await axe(container);

expect(results).toHaveNoViolations();
});
});
1 change: 1 addition & 0 deletions app/tests/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom";
import "./next-router-utils";

import { toHaveNoViolations } from "jest-axe";

Expand Down
40 changes: 40 additions & 0 deletions app/tests/next-router-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* eslint-disable */

// Taken from https://github.com/vercel/next.js/discussions/42527#discussioncomment-7234041
// This mocks important pieces from both next/router and next/navigation to enable testing of components
// that use next/router and next/navigation hooks.

import mockRouter from "next-router-mock";
import { createDynamicRouteParser } from "next-router-mock/dynamic-routes";

jest.mock("next/router", () => jest.requireActual("next-router-mock"));

mockRouter.useParser(
createDynamicRouteParser([
// @see https://github.com/scottrippey/next-router-mock#dynamic-routes
])
);

jest.mock<typeof import("next/navigation")>("next/navigation", () => {
const actual = jest.requireActual("next/navigation");
const nextRouterMock = jest.requireActual("next-router-mock");
const { useRouter } = nextRouterMock;
const usePathname = jest.fn().mockImplementation(() => {
const router = useRouter();
return router.asPath;
});

const useSearchParams = jest.fn().mockImplementation(() => {
const router = useRouter();
return new URLSearchParams(router.query);
});

return {
...actual,
useRouter: jest.fn().mockImplementation(useRouter),
usePathname,
useSearchParams,
};
});

export { mockRouter };
Loading